Building a Fault-Tolerant CI/CD Pipeline for PHP Applications with Docker and Kubernetes
Introduction: CI/CD for PHP Projects in 2026
In 2026, CI/CD is not an optional practice — it's a fundamental requirement for any serious PHP development. Manual deployments, forgotten migrations, and "it worked on my machine" are symptoms of teams that haven't yet built a reliable automation pipeline.
For PHP projects, especially those built on Laravel or Symfony, continuous code delivery introduces additional risks: Composer artifacts, OPcache configurations, database migrations, and task queues. Without clear automation, every deployment becomes a manual stress test for the team.
In this article, we'll build a complete, fault-tolerant CI/CD pipeline for a PHP application using Docker, Kubernetes, GitLab CI, and GitHub Actions. We'll walk through every stage: from code linting to Canary Releases and automatic rollback.
Tool Overview: GitHub Actions vs GitLab CI for PHP/Docker
Choosing a CI/CD platform is a strategic decision. Let's compare the two leading options in the context of PHP and Docker:
GitLab CI/CD — built into GitLab, configured via
.gitlab-ci.yml. Supports a built-in Container Registry, Auto DevOps, and a mature environment system. Ideal for self-hosted infrastructure and enterprise teams. Native Kubernetes integration via GitLab Agent.GitHub Actions — configured via YAML in the
.github/workflows/directory. Massive ecosystem of ready-made actions, free tier for public repositories. Ideal for open-source projects and teams already using GitHub.
For PHP/Docker, both tools perform equally well. GitLab CI has the edge for self-hosted deployments and built-in registry. GitHub Actions wins on time-to-start and ecosystem of ready-made components.
In this article, we'll show examples for both, with a focus on GitLab CI as the more comprehensive solution for enterprise PHP projects.
Containerizing a PHP Application: The Optimal Dockerfile
The foundation of any PHP CI/CD pipeline is a well-crafted Dockerfile. We'll use multi-stage builds, Alpine Linux, and OPcache for optimal performance.
# Dockerfile
# ==========================================
# Stage 1: Composer dependencies
# ==========================================
FROM composer:2.7 AS composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-interaction \
--prefer-dist \
--optimize-autoloader \
--no-scripts
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative
# ==========================================
# Stage 2: Assets build (if frontend exists)
# ==========================================
FROM node:20-alpine AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# ==========================================
# Stage 3: Production PHP image
# ==========================================
FROM php:8.3-fpm-alpine AS production
# System dependencies
RUN apk add --no-cache \
nginx \
supervisor \
libpq \
libzip \
&& apk add --no-cache --virtual .build-deps \
libpq-dev \
libzip-dev \
autoconf \
gcc \
g++ \
make \
&& docker-php-ext-install \
pdo_pgsql \
opcache \
zip \
pcntl \
bcmath \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del .build-deps
# OPcache configuration for production
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.interned_strings_buffer=16" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.max_accelerated_files=20000" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.revalidate_freq=0" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini
WORKDIR /var/www/html
# Copy dependencies from previous stages
COPY --from=composer /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .
# File permissions
RUN chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
# Nginx config
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
Key principles of this Dockerfile:
Multi-stage build — the final image contains no Composer, Node.js, or build dependencies
Alpine Linux — minimal base image (~5 MB vs ~900 MB for Debian)
OPcache with validate_timestamps=0 — maximum performance in production
Layer caching —
composer.jsonandcomposer.lockare copied separately before the rest of the code
CI Stage: Linting, Tests, and Image Build
PHP_CodeSniffer and PHPStan
Static analysis is the first gate in the pipeline. Set up phpcs.xml and phpstan.neon in the project root:
# phpstan.neon
parameters:
level: 8
paths:
- app
- tests
excludePaths:
- vendor
checkMissingIterableValueType: false
PHPUnit Configuration
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
</php>
</phpunit>
Complete .gitlab-ci.yml Pipeline
Below is a production-ready GitLab CI configuration for PHP/Laravel with Kubernetes deployment:
# .gitlab-ci.yml
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
KUBERNETES_NAMESPACE: production
stages:
- validate
- test
- build
- migrate
- deploy
- verify
# ==========================================
# Templates
# ==========================================
.php_template: &php_template
image: php:8.3-cli-alpine
before_script:
- apk add --no-cache git unzip libzip-dev
- docker-php-ext-install zip
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer
- composer install --no-interaction --prefer-dist --optimize-autoloader
# ==========================================
# Stage: validate
# ==========================================
phpcs:
<<: *php_template
stage: validate
script:
- vendor/bin/phpcs --standard=PSR12 app/ --report=checkstyle --report-file=phpcs-report.xml
artifacts:
reports:
codequality: phpcs-report.xml
when: always
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
phpstan:
<<: *php_template
stage: validate
script:
- vendor/bin/phpstan analyse --memory-limit=512M --error-format=gitlab > phpstan-report.json || true
artifacts:
reports:
codequality: phpstan-report.json
when: always
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
# ==========================================
# Stage: test
# ==========================================
unit_tests:
<<: *php_template
stage: test
services:
- name: postgres:16-alpine
alias: postgres
variables:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
DB_HOST: postgres
DB_DATABASE: test_db
DB_USERNAME: test_user
DB_PASSWORD: test_password
before_script:
- apk add --no-cache git unzip libzip-dev libpq-dev
- docker-php-ext-install zip pdo_pgsql
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer
- composer install --no-interaction --prefer-dist
- cp .env.testing .env
- php artisan key:generate
- php artisan migrate --force
script:
- vendor/bin/phpunit --coverage-text --coverage-cobertura=coverage.xml
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
when: always
coverage: '/^\s*Lines:\s*\d+\.?\d*%/'
# ==========================================
# Stage: build
# ==========================================
build_image:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build
--cache-from $IMAGE_LATEST
--build-arg BUILDKIT_INLINE_CACHE=1
--tag $IMAGE_TAG
--tag $IMAGE_LATEST
.
- docker push $IMAGE_TAG
- docker push $IMAGE_LATEST
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH =~ /^release\/.*/
# ==========================================
# Stage: migrate
# ==========================================
run_migrations:
stage: migrate
image:
name: bitnami/kubectl:latest
entrypoint: [""]
script:
- kubectl config use-context $KUBE_CONTEXT
- |
kubectl run migration-$CI_COMMIT_SHORT_SHA \
--image=$IMAGE_TAG \
--restart=Never \
--namespace=$KUBERNETES_NAMESPACE \
--env="APP_ENV=production" \
--command -- php artisan migrate --force
- kubectl wait --for=condition=complete \
job/migration-$CI_COMMIT_SHORT_SHA \
--timeout=300s \
--namespace=$KUBERNETES_NAMESPACE || \
(kubectl logs -l job-name=migration-$CI_COMMIT_SHORT_SHA --namespace=$KUBERNETES_NAMESPACE && exit 1)
- kubectl delete pod migration-$CI_COMMIT_SHORT_SHA --namespace=$KUBERNETES_NAMESPACE
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs:
- build_image
# ==========================================
# Stage: deploy (Rolling Update)
# ==========================================
deploy_production:
stage: deploy
image:
name: bitnami/kubectl:latest
entrypoint: [""]
environment:
name: production
url: https://app.example.com
script:
- kubectl config use-context $KUBE_CONTEXT
- kubectl set image deployment/php-app
php-app=$IMAGE_TAG
--namespace=$KUBERNETES_NAMESPACE
- kubectl rollout status deployment/php-app
--namespace=$KUBERNETES_NAMESPACE
--timeout=300s
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs:
- run_migrations
# ==========================================
# Stage: verify (smoke tests)
# ==========================================
smoke_tests:
stage: verify
image: curlimages/curl:latest
script:
- sleep 10
- curl -f -s -o /dev/null https://app.example.com/health || (echo "Health check failed" && exit 1)
- echo "Deployment verified successfully"
needs:
- deploy_production
rules:
- if: $CI_COMMIT_BRANCH == "main"
GitHub Actions: Equivalent Workflow
# .github/workflows/deploy.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2, phpcs, phpstan
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: PHP CodeSniffer
run: vendor/bin/phpcs --standard=PSR12 app/
- name: PHPStan
run: vendor/bin/phpstan analyse --memory-limit=512M
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_pgsql, zip, redis
coverage: xdebug
- run: composer install --no-interaction --prefer-dist
- run: cp .env.testing .env && php artisan key:generate
- run: php artisan migrate --force
- run: vendor/bin/phpunit --coverage-clover coverage.xml
- uses: codecov/codecov-action@v4
with:
file: coverage.xml
build-and-push:
needs: [validate, test]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v4
- name: Configure kubectl
run: |
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > kubeconfig.yaml
export KUBECONFIG=kubeconfig.yaml
- name: Deploy to Kubernetes
run: |
export KUBECONFIG=kubeconfig.yaml
kubectl set image deployment/php-app \
php-app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }} \
--namespace=production
kubectl rollout status deployment/php-app \
--namespace=production --timeout=300s
Kubernetes Deployment Strategies
Rolling Update — The Default Strategy
Rolling Update is the most common strategy for PHP applications. Kubernetes gradually replaces old pods with new ones:
# kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-app
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: php-app
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # +1 new pod during update
maxUnavailable: 0 # 0 pods unavailable during update
template:
metadata:
labels:
app: php-app
version: "{{ .Values.image.tag }}"
spec:
containers:
- name: php-app
image: registry.example.com/php-app:{{ .Values.image.tag }}
ports:
- containerPort: 80
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 30
periodSeconds: 10
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
envFrom:
- secretRef:
name: php-app-secrets
- configMapRef:
name: php-app-config
Blue-Green Deployment
Blue-Green lets you maintain two identical environments and switch traffic instantly. For PHP, this is especially useful during major migrations:
# Deploy the green version
kubectl apply -f kubernetes/deployment-green.yaml
# Wait for green to be ready
kubectl rollout status deployment/php-app-green --timeout=300s
# Switch the Service to green
kubectl patch service php-app-service \
-p '{"spec":{"selector":{"version":"green"}}}'
# Verify and roll back if needed
kubectl patch service php-app-service \
-p '{"spec":{"selector":{"version":"blue"}}}'
Canary Releases
Canary Releases let you route a small percentage of traffic (5–10%) to the new version and gradually increase it:
# deployment-canary.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-app-canary
namespace: production
spec:
replicas: 1 # 1 out of 10 pods = 10% of traffic
selector:
matchLabels:
app: php-app
track: canary
template:
metadata:
labels:
app: php-app
track: canary
spec:
containers:
- name: php-app
image: registry.example.com/php-app:new-version
Secrets Management: Kubernetes Secrets and HashiCorp Vault
Never store secrets in your Git repository. There are two main approaches:
Kubernetes Secrets (Basic Level)
# Create a secret from environment variables
kubectl create secret generic php-app-secrets \
--from-literal=APP_KEY=base64:your_key \
--from-literal=DB_PASSWORD=secret_password \
--from-literal=REDIS_PASSWORD=redis_password \
--namespace=production
# Or via a manifest (values must be base64-encoded)
apiVersion: v1
kind: Secret
metadata:
name: php-app-secrets
namespace: production
type: Opaque
data:
APP_KEY: YmFzZTY0OmtleQ==
DB_PASSWORD: c2VjcmV0
HashiCorp Vault (Production Level)
For production environments, use Vault with the Vault Agent Injector:
# kubernetes/vault-annotations.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-app
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "php-app"
vault.hashicorp.com/agent-inject-secret-env: "secret/data/php-app/production"
vault.hashicorp.com/agent-inject-template-env: |
{{- with secret "secret/data/php-app/production" -}}
export APP_KEY={{ .Data.data.app_key }}
export DB_PASSWORD={{ .Data.data.db_password }}
{{- end }}
In GitLab CI, secrets are passed via masked environment variables: Settings → CI/CD → Variables → Masked. In GitHub Actions, use Settings → Secrets and variables → Actions.
Automated Database Migrations
Migrations are a critical moment in any deployment. There are two common patterns:
Init Container (Recommended for Kubernetes)
# Add initContainers to deployment.yaml
spec:
initContainers:
- name: run-migrations
image: registry.example.com/php-app:{{ .Values.image.tag }}
command: ["php", "artisan", "migrate", "--force"]
envFrom:
- secretRef:
name: php-app-secrets
- configMapRef:
name: php-app-config
The Init Container runs before the main pods and exits. Only after it completes successfully will Kubernetes start the main container. This guarantees that migrations are applied before the application starts.
Principles of Safe Migrations
Backward-compatible migrations: the new code version must work with the old DB schema (expand-contract pattern)
Never drop columns in the same version where you remove them from code
Use transactions in migrations for atomicity
Test rollback: every migration should have a
down()method
Deployment Rollback: Strategies and Automation
Manual Rollback via kubectl
# View deployment history
kubectl rollout history deployment/php-app --namespace=production
# Roll back to the previous version
kubectl rollout undo deployment/php-app --namespace=production
# Roll back to a specific revision
kubectl rollout undo deployment/php-app \
--to-revision=3 \
--namespace=production
# Check status after rollback
kubectl rollout status deployment/php-app --namespace=production
Automatic Rollback in GitLab CI
# Add an auto-rollback stage to .gitlab-ci.yml
auto_rollback:
stage: verify
image:
name: bitnami/kubectl:latest
entrypoint: [""]
script:
- kubectl config use-context $KUBE_CONTEXT
- |
# Check the health endpoint 3 times with a 10-second interval
for i in 1 2 3; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://app.example.com/health)
if [ "$STATUS" != "200" ]; then
echo "Health check failed (attempt $i), HTTP $STATUS"
if [ "$i" -eq 3 ]; then
echo "Rolling back deployment..."
kubectl rollout undo deployment/php-app --namespace=$KUBERNETES_NAMESPACE
exit 1
fi
sleep 10
else
echo "Health check passed"
exit 0
fi
done
needs:
- deploy_production
when: on_success
Pipeline Monitoring and Notifications
A fault-tolerant pipeline without monitoring is just an illusion of reliability. Integrate the following tools:
Slack/Telegram Notifications from GitLab CI
# Add to the end of .gitlab-ci.yml
notify_success:
stage: .post
image: curlimages/curl:latest
script:
- |
curl -X POST $SLACK_WEBHOOK_URL \
-H 'Content-type: application/json' \
--data '{"text":"✅ Deployment of *'$CI_PROJECT_NAME'* succeeded. Commit: '$CI_COMMIT_SHORT_SHA' ('$CI_COMMIT_AUTHOR')"}'
when: on_success
rules:
- if: $CI_COMMIT_BRANCH == "main"
notify_failure:
stage: .post
image: curlimages/curl:latest
script:
- |
curl -X POST $SLACK_WEBHOOK_URL \
-H 'Content-type: application/json' \
--data '{"text":"🚨 Deployment of *'$CI_PROJECT_NAME'* FAILED! Pipeline: '$CI_PIPELINE_URL'"}'
when: on_failure
rules:
- if: $CI_COMMIT_BRANCH == "main"
Pipeline Metrics: What to Monitor
Deployment Frequency — how often the team deploys (DORA metric)
Lead Time for Changes — time from commit to production
Change Failure Rate — percentage of deployments requiring rollback
Mean Time to Recovery (MTTR) — average time to recover from a failure
For Kubernetes monitoring, use the Prometheus + Grafana stack. For deployment tracing — ArgoCD or the built-in GitLab Environments dashboard.
The /health Endpoint for PHP/Laravel
ReadinessProbe and LivenessProbe in Kubernetes require a working health endpoint. Add the following to Laravel:
<?php
// routes/web.php
Route::get('/health', function () {
try {
// Check DB connection
DB::select('SELECT 1');
// Check Redis
Redis::ping();
return response()->json([
'status' => 'ok',
'timestamp' => now()->toISOString(),
'version' => config('app.version'),
], 200);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
], 503);
}
})->middleware('throttle:60,1');
Checklist: Signs of a Production-Ready CI/CD Pipeline
All secrets are stored in Vault or CI/CD variables (not in code)
The pipeline does not deploy when tests fail
Docker images have specific tags (not just
latest)Migrations run via Init Container, not manually
ReadinessProbe is configured — Kubernetes won't send traffic to an unready pod
Rollback takes no more than 2 minutes
The team receives notifications about the status of every deployment
Test coverage is at least 70%, with reports published to MR/PR
The build takes no more than 10 minutes (otherwise developers start ignoring the pipeline)
Conclusion
Building a fault-tolerant CI/CD pipeline for PHP with Docker and Kubernetes is an investment that pays off with the very first saved deployment. Start with a basic pipeline: linting → tests → build → deploy. Then iterate: add secrets management via Vault, configure automatic rollback, and introduce Canary Releases.
The key principle: every step of the pipeline must be automated, idempotent, and observable. If something can't be automatically rolled back — it's a risk that must be addressed before the next deployment.
Invest time in pipeline quality today, and your team will deploy on Fridays without fear.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →