You just pushed broken code to production. Again. The client is calling. Your weekend is ruined. Manual deployments are killing your productivity and your sanity.
We’ve automated PHP deployments for teams shipping 50+ times per day with zero downtime and zero stress. The secret? A bulletproof CI/CD pipeline that catches bugs before they reach production and deploys flawlessly every single time.
This guide reveals our production-tested PHP deployment automation system - the same one that reduced deployment time from 2 hours to 3 minutes while eliminating 99% of deployment-related incidents.
Note: This guide covers CI/CD automation for PHP applications on Ubuntu servers. CloudPloy currently provides automated CI/CD pipelines for Laravel applications, with support for other PHP frameworks coming soon.
The True Cost of Manual Deployments
Let’s quantify what manual deployments really cost:
- Time Lost: 2 hours per deployment × 20 deployments/month = 40 hours wasted
- Error Rate: Manual deployments have a 15% failure rate
- Recovery Time: Average rollback takes 45 minutes
- Opportunity Cost: Developers afraid to deploy = slower feature delivery
For a team of 5 developers, manual deployments cost approximately $150,000/year in lost productivity. That’s before counting the cost of downtime and customer churn.
The Perfect PHP CI/CD Pipeline Architecture
graph LR
A[Git Push] --> B[Trigger Pipeline]
B --> C[Code Quality Checks]
C --> D[Security Scanning]
D --> E[Unit Tests]
E --> F[Integration Tests]
F --> G[Build Docker Image]
G --> H[Deploy to Staging]
H --> I[Smoke Tests]
I --> J[Deploy to Production]
J --> K[Health Checks]
K --> L[Monitoring]
Step 1: GitHub Actions - Complete PHP Pipeline
The Ultimate PHP Workflow
# .github/workflows/deploy.yml
name: PHP Deployment Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
PHP_VERSION: '8.3'
NODE_VERSION: '18'
jobs:
code-quality:
runs-on: ubuntu-latest
name: Code Quality Checks
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0 # Full history for better analysis
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ env.PHP_VERSION }}
extensions: mbstring, xml, ctype, iconv, intl, pdo_mysql, dom, filter, gd, json, opcache, zip
tools: cs2pr, phpstan, psalm, phpcs, phpcbf, phpmd
coverage: xdebug
- name: Cache Composer packages
uses: actions/cache@v3
with:
path: vendor
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-php-
- name: Install Dependencies
run: |
composer install --prefer-dist --no-progress --no-scripts --no-interaction
composer dump-autoload --optimize
- name: PHP Code Sniffer
run: vendor/bin/phpcs --report=checkstyle src/ | cs2pr
- name: PHPStan Analysis
run: vendor/bin/phpstan analyse src/ --level=8 --error-format=github
- name: Psalm Static Analysis
run: vendor/bin/psalm --output-format=github
- name: PHP Mess Detector
run: vendor/bin/phpmd src/ github cleancode,codesize,controversial,design,naming,unusedcode
- name: Check for Security Vulnerabilities
run: |
composer audit
vendor/bin/security-checker security:check
testing:
runs-on: ubuntu-latest
name: Testing Suite
needs: code-quality
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test_db
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis:alpine
ports:
- 6379:6379
options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ env.PHP_VERSION }}
extensions: mbstring, xml, ctype, iconv, intl, pdo_mysql, dom, filter, gd, json, opcache, zip, redis
coverage: xdebug
- name: Install Dependencies
run: composer install --prefer-dist --no-progress
- name: Setup Test Environment
run: |
cp .env.testing .env
php artisan key:generate
php artisan migrate --force
php artisan db:seed --force
- name: Run Unit Tests
run: |
vendor/bin/phpunit --testsuite=Unit --coverage-clover=coverage-unit.xml
- name: Run Integration Tests
run: |
vendor/bin/phpunit --testsuite=Integration --coverage-clover=coverage-integration.xml
- name: Run Feature Tests
run: |
vendor/bin/phpunit --testsuite=Feature --coverage-clover=coverage-feature.xml
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage-unit.xml,./coverage-integration.xml,./coverage-feature.xml
fail_ci_if_error: true
- name: Run Mutation Tests
run: vendor/bin/infection --min-msi=80 --min-covered-msi=80
build:
runs-on: ubuntu-latest
name: Build & Push Docker Image
needs: testing
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: |
myapp/php:latest
myapp/php:${{ github.sha }}
cache-from: type=registry,ref=myapp/php:buildcache
cache-to: type=registry,ref=myapp/php:buildcache,mode=max
build-args: |
PHP_VERSION=${{ env.PHP_VERSION }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
deploy-staging:
runs-on: ubuntu-latest
name: Deploy to Staging
needs: build
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/checkout@v3
- name: Deploy to Staging Server
uses: appleboy/ssh-action@v0.1.5
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
cd /var/www/staging
docker pull myapp/php:${{ github.sha }}
docker-compose down
docker-compose up -d
docker-compose exec app php artisan migrate --force
docker-compose exec app php artisan cache:clear
docker-compose exec app php artisan config:cache
docker-compose exec app php artisan route:cache
- name: Run Smoke Tests
run: |
sleep 30
curl -f https://staging.example.com/health || exit 1
npm run test:e2e:staging
deploy-production:
runs-on: ubuntu-latest
name: Deploy to Production
needs: deploy-staging
environment:
name: production
url: https://example.com
steps:
- uses: actions/checkout@v3
- name: Blue-Green Deployment
uses: appleboy/ssh-action@v0.1.5
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /var/www/production
# Pull new image
docker pull myapp/php:${{ github.sha }}
# Start green environment
docker-compose -f docker-compose.green.yml up -d
# Wait for green to be healthy
sleep 30
curl -f http://localhost:8081/health || exit 1
# Switch load balancer to green
sudo nginx -s reload
# Stop blue environment
docker-compose -f docker-compose.blue.yml down
# Update blue configuration for next deployment
sed -i "s|image: .*|image: myapp/php:${{ github.sha }}|" docker-compose.blue.yml
- name: Verify Production Deployment
run: |
curl -f https://example.com/health || exit 1
npm run test:smoke:production
- name: Notify Deployment Success
uses: 8398a7/action-slack@v3
with:
status: success
text: 'Production deployment successful! :rocket:'
webhook_url: ${{ secrets.SLACK_WEBHOOK }}
Step 2: GitLab CI - Enterprise PHP Pipeline
# .gitlab-ci.yml
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ""
PHP_VERSION: "8.3"
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test_db
stages:
- quality
- test
- build
- deploy
- rollback
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- vendor/
- node_modules/
- .composer-cache/
before_script:
- apt-get update -yqq
- apt-get install -yqq git libzip-dev zip unzip
- docker-php-ext-install zip pdo_mysql
- pecl install redis && docker-php-ext-enable redis
- composer install --prefer-dist --no-ansi --no-interaction --no-progress
code-quality:
stage: quality
image: php:${PHP_VERSION}
script:
- vendor/bin/phpcs src/
- vendor/bin/phpstan analyse src/ --level=8
- vendor/bin/psalm
- vendor/bin/phpmd src/ text cleancode,codesize,controversial,design,naming,unusedcode
artifacts:
reports:
codequality: gl-code-quality-report.json
only:
- merge_requests
- main
security-scan:
stage: quality
image: php:${PHP_VERSION}
script:
- composer audit
- vendor/bin/security-checker security:check
- |
docker run --rm -v "$PWD":/src \
-e SONAR_HOST_URL="${SONAR_HOST_URL}" \
-e SONAR_LOGIN="${SONAR_TOKEN}" \
sonarsource/sonar-scanner-cli
only:
- main
unit-tests:
stage: test
image: php:${PHP_VERSION}
services:
- mysql:8.0
- redis:alpine
script:
- cp .env.testing .env
- php artisan migrate --force
- vendor/bin/phpunit --testsuite=Unit --coverage-text --coverage-cobertura=coverage.xml
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
integration-tests:
stage: test
image: php:${PHP_VERSION}
services:
- mysql:8.0
- redis:alpine
script:
- vendor/bin/phpunit --testsuite=Integration
only:
- main
- develop
performance-tests:
stage: test
image: php:${PHP_VERSION}
script:
- apt-get install -yqq apache2-utils
- php -S localhost:8000 -t public/ &
- sleep 5
- ab -n 1000 -c 100 http://localhost:8000/ | tee performance.txt
- |
if grep -q "Requests per second:.*[0-9]\{1,2\}\." performance.txt; then
echo "Performance test failed: Less than 100 req/s"
exit 1
fi
artifacts:
paths:
- performance.txt
build-docker:
stage: build
image: docker:latest
services:
- docker:dind
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker push $CI_REGISTRY_IMAGE:latest
only:
- main
deploy-staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
script:
- |
ssh -o StrictHostKeyChecking=no $STAGING_USER@$STAGING_HOST <<EOF
cd /var/www/staging
docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
docker-compose down
export IMAGE_TAG=$CI_COMMIT_SHA
docker-compose up -d
docker-compose exec -T app php artisan migrate --force
docker-compose exec -T app php artisan cache:clear
EOF
environment:
name: staging
url: https://staging.example.com
only:
- main
deploy-production:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client curl
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
script:
- |
# Create deployment record
DEPLOYMENT_ID=$(curl -X POST https://api.example.com/deployments \
-H "Content-Type: application/json" \
-d '{"version":"'$CI_COMMIT_SHA'","environment":"production"}' \
| jq -r '.id')
# Blue-Green Deployment
ssh -o StrictHostKeyChecking=no $PROD_USER@$PROD_HOST <<EOF
cd /var/www/production
# Backup current state
docker-compose -f docker-compose.blue.yml ps > /tmp/blue-state.txt
# Deploy to green
export IMAGE_TAG=$CI_COMMIT_SHA
docker-compose -f docker-compose.green.yml pull
docker-compose -f docker-compose.green.yml up -d
# Health check
for i in {1..30}; do
if curl -f http://localhost:8081/health; then
break
fi
sleep 2
done
# Switch traffic
ln -sfn /etc/nginx/sites-available/green /etc/nginx/sites-enabled/active
nginx -s reload
# Stop blue
docker-compose -f docker-compose.blue.yml down
EOF
# Update deployment status
curl -X PATCH https://api.example.com/deployments/$DEPLOYMENT_ID \
-H "Content-Type: application/json" \
-d '{"status":"completed"}'
environment:
name: production
url: https://example.com
when: manual
only:
- main
rollback:
stage: rollback
image: alpine:latest
script:
- |
ssh -o StrictHostKeyChecking=no $PROD_USER@$PROD_HOST <<EOF
cd /var/www/production
# Get previous version
PREVIOUS_VERSION=$(docker images $CI_REGISTRY_IMAGE --format "{{.Tag}}" | head -2 | tail -1)
# Deploy previous version
export IMAGE_TAG=$PREVIOUS_VERSION
docker-compose -f docker-compose.blue.yml up -d
# Switch traffic back
ln -sfn /etc/nginx/sites-available/blue /etc/nginx/sites-enabled/active
nginx -s reload
# Stop green
docker-compose -f docker-compose.green.yml down
EOF
when: manual
only:
- main
Step 3: Advanced Deployment Strategies
Zero-Downtime Database Migrations
// database/migrations/SafeMigration.php
abstract class SafeMigration extends Migration
{
protected $timeout = 900; // 15 minutes
public function up()
{
// Set statement timeout
DB::statement("SET statement_timeout = {$this->timeout}000");
// Acquire advisory lock
$lockId = crc32($this->getTableName());
if (!DB::select("SELECT pg_try_advisory_lock($lockId)")[0]->pg_try_advisory_lock) {
throw new Exception('Migration already running');
}
try {
$this->safeUp();
} finally {
// Release lock
DB::select("SELECT pg_advisory_unlock($lockId)");
}
}
abstract protected function safeUp();
abstract protected function getTableName(): string;
}
// Usage
class AddIndexToUsersTable extends SafeMigration
{
protected function safeUp()
{
// Create index concurrently (non-blocking)
DB::statement('CREATE INDEX CONCURRENTLY idx_users_email ON users(email)');
}
protected function getTableName(): string
{
return 'users';
}
}
Feature Flag Deployment
// app/Services/FeatureFlag.php
class FeatureFlag
{
private $redis;
public function __construct(Redis $redis)
{
$this->redis = $redis;
}
public function isEnabled(string $feature, ?User $user = null): bool
{
// Check kill switch
if ($this->redis->get("feature:kill:$feature")) {
return false;
}
// Check global flag
if ($this->redis->get("feature:global:$feature")) {
return true;
}
// Check percentage rollout
if ($percentage = $this->redis->get("feature:percentage:$feature")) {
return $this->isInPercentage($user, $feature, (int)$percentage);
}
// Check user whitelist
if ($user && $this->redis->sismember("feature:users:$feature", $user->id)) {
return true;
}
return false;
}
private function isInPercentage(?User $user, string $feature, int $percentage): bool
{
if (!$user) return false;
$hash = crc32($user->id . $feature);
return ($hash % 100) < $percentage;
}
}
// Deployment script
class FeatureDeployment
{
public function gradualRollout(string $feature)
{
$redis = new Redis();
// Start with 1% of users
$redis->set("feature:percentage:$feature", 1);
sleep(300); // Monitor for 5 minutes
// Increase to 10%
if ($this->metricsHealthy($feature)) {
$redis->set("feature:percentage:$feature", 10);
sleep(600); // Monitor for 10 minutes
}
// Increase to 50%
if ($this->metricsHealthy($feature)) {
$redis->set("feature:percentage:$feature", 50);
sleep(1800); // Monitor for 30 minutes
}
// Full rollout
if ($this->metricsHealthy($feature)) {
$redis->set("feature:global:$feature", 1);
$redis->del("feature:percentage:$feature");
}
}
}
Canary Deployments with Monitoring
# kubernetes/canary-deployment.yaml
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
name: php-app
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: php-app
service:
port: 80
analysis:
interval: 1m
threshold: 10
maxWeight: 50
stepWeight: 10
metrics:
- name: request-success-rate
thresholdRange:
min: 99
interval: 1m
- name: request-duration
thresholdRange:
max: 500
interval: 1m
webhooks:
- name: load-test
url: http://flagger-loadtester.test/
timeout: 5s
metadata:
cmd: "hey -z 1m -q 10 -c 2 http://php-app.test/"
Step 4: Automated Testing Pipeline
Parallel Test Execution
# .github/workflows/parallel-tests.yml
test-matrix:
strategy:
matrix:
testsuite: [Unit, Integration, Feature, Browser]
shard: [1, 2, 3, 4]
steps:
- name: Run Tests in Parallel
run: |
vendor/bin/phpunit \
--testsuite=${{ matrix.testsuite }} \
--filter=shard:${{ matrix.shard }}:4 \
--coverage-php=coverage-${{ matrix.testsuite }}-${{ matrix.shard }}.cov
- name: Merge Coverage Reports
if: matrix.shard == 4
run: |
vendor/bin/phpcov merge coverage/ --html=report/
Contract Testing
// tests/Contract/ApiContractTest.php
class ApiContractTest extends TestCase
{
private $pact;
protected function setUp(): void
{
parent::setUp();
$this->pact = new Pact\Consumer\ConsumerBuilder('API-Consumer');
$this->pact
->hasPactWith('API-Provider')
->uponReceiving('a request for user data')
->with([
'method' => 'GET',
'path' => '/api/users/1',
'headers' => ['Accept' => 'application/json']
])
->willRespondWith([
'status' => 200,
'headers' => ['Content-Type' => 'application/json'],
'body' => [
'id' => 1,
'name' => Pact\Matchers::like('John Doe'),
'email' => Pact\Matchers::email('john@example.com')
]
]);
}
public function testApiContract()
{
$client = new GuzzleHttp\Client(['base_uri' => $this->pact->getServer()]);
$response = $client->get('/api/users/1');
$this->assertEquals(200, $response->getStatusCode());
$this->pact->verify();
}
}
Step 5: Security Scanning Integration
SAST and DAST Pipeline
security-scan:
stage: security
parallel:
matrix:
- SCANNER: [semgrep, snyk, trivy, sonarqube]
script:
- |
case $SCANNER in
semgrep)
docker run --rm -v "${PWD}:/src" \
returntocorp/semgrep:latest \
--config=auto --json -o semgrep-report.json
;;
snyk)
snyk test --all-projects --json > snyk-report.json
snyk container test myapp/php:latest --json > snyk-container.json
;;
trivy)
trivy image --format json -o trivy-report.json myapp/php:latest
;;
sonarqube)
sonar-scanner \
-Dsonar.projectKey=php-app \
-Dsonar.sources=src \
-Dsonar.host.url=$SONAR_HOST \
-Dsonar.login=$SONAR_TOKEN
;;
esac
artifacts:
reports:
sast: "*-report.json"
Runtime Security Monitoring
// app/Middleware/SecurityMonitoring.php
class SecurityMonitoring
{
private $alerts;
public function handle($request, Closure $next)
{
// SQL Injection Detection
$this->detectSQLInjection($request);
// XSS Detection
$this->detectXSS($request);
// Rate Limiting
$this->enforceRateLimit($request);
// Log Security Events
$this->logSecurityEvent($request);
return $next($request);
}
private function detectSQLInjection($request)
{
$patterns = [
'/(\bUNION\b.*\bSELECT\b)/i',
'/(\bOR\b.*=.*)/i',
'/(--|\#|\/\*)/i'
];
foreach ($request->all() as $input) {
foreach ($patterns as $pattern) {
if (preg_match($pattern, $input)) {
$this->alert('SQL Injection Attempt', [
'ip' => $request->ip(),
'input' => $input,
'url' => $request->fullUrl()
]);
abort(403);
}
}
}
}
}
Step 6: Monitoring and Observability
Comprehensive Monitoring Stack
# docker-compose.monitoring.yml
version: '3.8'
services:
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.retention.time=30d'
ports:
- "9090:9090"
grafana:
image: grafana/grafana:latest
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/dashboards:/etc/grafana/provisioning/dashboards
environment:
- GF_SECURITY_ADMIN_PASSWORD=secret
ports:
- "3000:3000"
jaeger:
image: jaegertracing/all-in-one:latest
environment:
- COLLECTOR_ZIPKIN_HOST_PORT=:9411
ports:
- "5775:5775/udp"
- "6831:6831/udp"
- "6832:6832/udp"
- "5778:5778"
- "16686:16686"
- "14250:14250"
- "14268:14268"
- "14269:14269"
- "9411:9411"
elasticsearch:
image: elasticsearch:7.17.9
environment:
- discovery.type=single-node
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- elasticsearch_data:/usr/share/elasticsearch/data
ports:
- "9200:9200"
kibana:
image: kibana:7.17.9
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
ports:
- "5601:5601"
volumes:
prometheus_data:
grafana_data:
elasticsearch_data:
Application Metrics
// app/Services/MetricsCollector.php
class MetricsCollector
{
private $prometheus;
public function __construct()
{
$this->prometheus = new Prometheus\CollectorRegistry(
new Prometheus\Storage\Redis()
);
}
public function recordDeployment(string $version, string $environment)
{
$gauge = $this->prometheus->getOrRegisterGauge(
'deployment',
'info',
'Deployment information',
['version', 'environment']
);
$gauge->set(1, [$version, $environment]);
}
public function recordRequestDuration(float $duration, string $route, int $statusCode)
{
$histogram = $this->prometheus->getOrRegisterHistogram(
'http',
'request_duration_seconds',
'HTTP request duration',
['route', 'status'],
[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
);
$histogram->observe($duration, [$route, (string)$statusCode]);
}
public function recordDatabaseQuery(float $duration, string $query)
{
$histogram = $this->prometheus->getOrRegisterHistogram(
'database',
'query_duration_seconds',
'Database query duration',
['query_type'],
[0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1]
);
$queryType = $this->extractQueryType($query);
$histogram->observe($duration, [$queryType]);
}
}
Deployment Rollback Strategies
Automatic Rollback on Failure
#!/bin/bash
# rollback.sh
HEALTH_CHECK_URL="https://api.example.com/health"
MAX_RETRIES=5
RETRY_INTERVAL=10
# Function to check application health
check_health() {
response=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_CHECK_URL)
if [ $response -eq 200 ]; then
return 0
else
return 1
fi
}
# Deploy new version
echo "Deploying new version..."
docker-compose up -d --no-deps --build app
# Wait for application to start
sleep 30
# Health check loop
for i in $(seq 1 $MAX_RETRIES); do
if check_health; then
echo "Deployment successful!"
# Tag as stable
docker tag myapp/php:latest myapp/php:stable
# Clean up old images
docker image prune -f
exit 0
else
echo "Health check failed (attempt $i/$MAX_RETRIES)"
if [ $i -eq $MAX_RETRIES ]; then
echo "Deployment failed! Rolling back..."
# Rollback to previous version
docker-compose down
docker tag myapp/php:stable myapp/php:latest
docker-compose up -d
# Send alert
curl -X POST $SLACK_WEBHOOK -d '{
"text": "Deployment failed and rolled back!"
}'
exit 1
fi
sleep $RETRY_INTERVAL
fi
done
Cost Optimization
Pipeline Resource Management
# Optimize CI/CD costs
optimization:
cache:
- vendor/
- node_modules/
- ~/.composer/cache
- ~/.npm
parallel:
limit: 4 # Limit parallel jobs
timeout: 30m # Global timeout
only:
- main
- develop
- /^release\/.*$/
except:
- schedules
Setting Up CI/CD on Ubuntu Servers
Jenkins Setup on Ubuntu for PHP CI/CD
# Install Jenkins on Ubuntu 22.04
wget -q -O - https://pkg.jenkins.io/debian-stable/jenkins.io.key | sudo apt-key add -
sudo sh -c 'echo deb http://pkg.jenkins.io/debian-stable binary/ > /etc/apt/sources.list.d/jenkins.list'
sudo apt update
sudo apt install -y jenkins openjdk-11-jdk
# Install PHP and tools
sudo apt install -y php8.3 php8.3-cli php8.3-xml php8.3-mbstring \
php8.3-curl php8.3-zip composer phpunit
# Start Jenkins
sudo systemctl start jenkins
sudo systemctl enable jenkins
# Get initial admin password
sudo cat /var/lib/jenkins/secrets/initialAdminPassword
GitLab Runner on Ubuntu
# Install GitLab Runner on Ubuntu
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt install gitlab-runner
# Register runner
sudo gitlab-runner register \
--non-interactive \
--url "https://gitlab.com/" \
--registration-token "YOUR_TOKEN" \
--executor "shell" \
--description "ubuntu-php-runner" \
--tag-list "php,ubuntu" \
--run-untagged="true" \
--locked="false"
# Install PHP dependencies for runner
sudo -u gitlab-runner composer global require phpunit/phpunit phpstan/phpstan squizlabs/php_codesniffer
Automated Deployment Script for Ubuntu Servers
#!/bin/bash
# /home/deploy/php-deploy.sh
set -e
# Configuration
APP_DIR="/var/www/php-app"
BACKUP_DIR="/var/backups/php-app"
DEPLOY_USER="deploy"
WEB_USER="www-data"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
echo -e "${GREEN}Starting PHP deployment on Ubuntu...${NC}"
# Create backup
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
sudo -u $WEB_USER tar -czf "$BACKUP_DIR/backup_$TIMESTAMP.tar.gz" -C "$APP_DIR" .
echo -e "${GREEN}Backup created: backup_$TIMESTAMP.tar.gz${NC}"
# Pull latest code
cd $APP_DIR
sudo -u $DEPLOY_USER git fetch origin main
sudo -u $DEPLOY_USER git reset --hard origin/main
# Install dependencies
sudo -u $DEPLOY_USER composer install --no-dev --optimize-autoloader
# Run database migrations
sudo -u $DEPLOY_USER php artisan migrate --force
# Clear caches
sudo -u $DEPLOY_USER php artisan cache:clear
sudo -u $DEPLOY_USER php artisan config:cache
sudo -u $DEPLOY_USER php artisan route:cache
sudo -u $DEPLOY_USER php artisan view:cache
# Set permissions
sudo chown -R $WEB_USER:$WEB_USER storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
# Restart PHP-FPM
sudo systemctl reload php8.3-fpm
# Health check
sleep 5
if curl -f http://localhost/health > /dev/null 2>&1; then
echo -e "${GREEN}Deployment successful!${NC}"
else
echo -e "${RED}Health check failed! Rolling back...${NC}"
# Restore from backup
sudo -u $WEB_USER tar -xzf "$BACKUP_DIR/backup_$TIMESTAMP.tar.gz" -C "$APP_DIR"
sudo systemctl reload php8.3-fpm
exit 1
fi
Setting Up GitHub Actions Runner on Ubuntu
# Create actions runner user
sudo useradd -m -s /bin/bash actions-runner
sudo usermod -aG sudo actions-runner
# Download and configure runner
cd /home/actions-runner
curl -o actions-runner-linux-x64-2.311.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz
tar xzf actions-runner-linux-x64-2.311.0.tar.gz
# Configure runner
./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO \
--token YOUR_RUNNER_TOKEN \
--name ubuntu-runner \
--work _work \
--labels ubuntu,php
# Install as service
sudo ./svc.sh install
sudo ./svc.sh start
# Install PHP and dependencies
sudo apt install -y php8.3-cli php8.3-mbstring php8.3-xml composer
Ansible Playbook for PHP Deployment on Ubuntu
# deploy-php-ubuntu.yml
---
- name: Deploy PHP Application to Ubuntu Servers
hosts: webservers
become: yes
vars:
app_dir: /var/www/php-app
deploy_user: deploy
tasks:
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
- name: Install PHP and extensions
apt:
name:
- php8.3-fpm
- php8.3-cli
- php8.3-mysql
- php8.3-redis
- php8.3-mbstring
- php8.3-xml
- php8.3-curl
- nginx
- git
- composer
state: present
- name: Clone/update repository
git:
repo: 'git@github.com:company/php-app.git'
dest: "{{ app_dir }}"
version: main
accept_hostkey: yes
become_user: "{{ deploy_user }}"
- name: Install composer dependencies
composer:
command: install
working_dir: "{{ app_dir }}"
no_dev: yes
optimize_autoloader: yes
become_user: "{{ deploy_user }}"
- name: Run migrations
command: php artisan migrate --force
args:
chdir: "{{ app_dir }}"
become_user: "{{ deploy_user }}"
- name: Set permissions
file:
path: "{{ app_dir }}/{{ item }}"
owner: www-data
group: www-data
mode: '0775'
recurse: yes
loop:
- storage
- bootstrap/cache
- name: Restart PHP-FPM
systemd:
name: php8.3-fpm
state: reloaded
- name: Restart Nginx
systemd:
name: nginx
state: reloaded
Monitoring Your Ubuntu PHP Deployment
Setting Up Monitoring Stack
# Install Prometheus Node Exporter on each Ubuntu server
wget https://github.com/prometheus/node_exporter/releases/download/v1.7.0/node_exporter-1.7.0.linux-amd64.tar.gz
tar xvf node_exporter-1.7.0.linux-amd64.tar.gz
sudo cp node_exporter-1.7.0.linux-amd64/node_exporter /usr/local/bin/
sudo useradd -rs /bin/false node_exporter
# Create systemd service
sudo tee /etc/systemd/system/node_exporter.service > /dev/null <<EOF
[Unit]
Description=Node Exporter
After=network.target
[Service]
User=node_exporter
Group=node_exporter
Type=simple
ExecStart=/usr/local/bin/node_exporter
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl start node_exporter
sudo systemctl enable node_exporter
Conclusion
Building a robust PHP CI/CD pipeline on Ubuntu servers provides complete control over your deployment process. By combining GitHub Actions or GitLab CI with proper server configuration, automated testing, and deployment scripts, you can achieve enterprise-grade deployment automation.
This guide has covered everything from setting up CI/CD tools on Ubuntu to implementing zero-downtime deployments and monitoring. With these configurations, your PHP applications will deploy reliably and automatically while maintaining high security and performance standards.
Remember: automation is an investment that pays dividends. Every manual deployment avoided is time saved and errors prevented. Start with basic automation and gradually add sophistication as your needs grow.