Canary deploys with GitHub Actions and Nginx for PHP monoliths
Deploy & DevOps · By Kevin Marcondes · Published on · 11 min read
This practical guide shows how to implement canary deployments for PHP monoliths with GitHub Actions and Nginx. You’ll split traffic between versions, force canary traffic via header or cookie, promote gradually and safely roll back when needed.
Prerequisites and canary architecture overview
The canary strategy reduces risk by sending a small portion of traffic to the new release. If it behaves well, you promote it; otherwise you revert.
We use Nginx for weighted routing and GitHub Actions for build, deploy, smoke tests, promotion and rollback. Two PHP‑FPM pools isolate executions.
Components: Nginx in front, two PHP‑FPM pools (stable and canary), versioned releases and a CI workflow that triggers SSH on the server.
You need root or sudo access, SSH keys and a health endpoint such as /health. Have basic logs and metrics to observe errors.
To avoid issues with static assets, use file fingerprinting (example: app.abc123.css). That prevents serving CSS/JS from the wrong release.
Prepare a simple, tested rollback plan. A checklist helps avoid forgetting critical steps. See the pre-deploy and rollback checklist.
- Nginx as reverse proxy and canary router
- Two PHP‑FPM pools: stable and canary
- Releases in /var/www/app/releases/<sha> + symlinks stable/canary
- GitHub Actions workflow with SSH and idempotent scripts
- Endpoint /health for smoke tests and basic monitoring
- Fingerprint assets to avoid visual inconsistencies
- Clear, tested rollback plan
Set up two PHP‑FPM pools (stable and canary)
Create two PHP‑FPM pools to isolate opcache and settings between stable and canary. They should use separate sockets and directories. A PHP‑FPM reload clears opcache and applies changes with minimal disruption.
Adjust pm.max_children according to capacity. The canary should receive less traffic. Ensure correct permissions for the application directories and sockets.
; /etc/php/8.2/fpm/pool.d/app.conf
[app_stable]
user = www-data
group = www-data
listen = /run/php-fpm-stable.sock
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
chdir = /var/www/app/releases/stable
php_admin_value[opcache.enable] = 1
php_admin_value[opcache.validate_timestamps] = 1
[app_canary]
user = www-data
group = www-data
listen = /run/php-fpm-canary.sock
pm = dynamic
pm.max_children = 8
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
chdir = /var/www/app/releases/canary
php_admin_value[opcache.enable] = 1
php_admin_value[opcache.validate_timestamps] = 1
Nginx: canary routing by percentage, header and cookie
Configure Nginx to split traffic between stable and canary by percentage using split_clients. Allow forcing canary with the X‑Canary: 1 header or a canary=1 cookie. Selection also defines the docroot used by PHP.
For assets, serve static files from the stable release for simplicity. Use fingerprinting to avoid visual inconsistencies. Alternatives: a CDN or duplicated rules with versioned paths.
If you prefer a different traffic swap, consider a blue‑green flow using containers. See the guide linked at the end of this page.
| Signal | How to set | When to use |
|---|---|---|
| Percentage | split_clients 5% canary | Default gradual rollout without user intervention |
| Header | X-Canary: 1 | Force canary in smoke tests and QA |
| Cookie | canary=1 | Pin a user/group to canary temporarily |
# Contexto http (ex.: nginx.conf)
map $http_x_canary $force_hdr_canary {
default 0;
"1" 1;
}
map $cookie_canary $force_ck_canary {
default 0;
"1" 1;
}
# Percentual no split_clients (editado via CI)
split_clients "$request_id" $bucket {
5% canary;
* stable;
}
# Prioriza header/cookie; senão usa bucket
map "$force_hdr_canary$force_ck_canary$bucket" $pool {
~1 canary;
~.*canary canary;
default stable;
}
map $pool $fpm_socket {
stable unix:/run/php-fpm-stable.sock;
canary unix:/run/php-fpm-canary.sock;
}
map $pool $docroot {
stable /var/www/app/releases/stable/public;
canary /var/www/app/releases/canary/public;
}
server {
listen 80;
server_name example.com;
# Fallback de estáticos aponta para stable
root /var/www/app/releases/stable/public;
location /health {
add_header Content-Type text/plain;
return 200 "ok";
}
location / {
try_files $uri /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $docroot$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $docroot;
fastcgi_pass $fpm_socket;
}
# Estáticos: use fingerprinting (ex.: app.abc123.css)
location ~* \.(?:css|js|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 7d;
try_files $uri @php_fallback;
}
location @php_fallback { rewrite ^ /index.php last; }
}
CI pipeline: build and package in GitHub Actions
The workflow installs dependencies, packages the application and prepares variables for deploy. Use Composer with production flags and produce a single tar.gz artifact.
Keep build separate from deploy. Trigger via workflow_dispatch and provide the initial canary percentage as input.
For pipeline best practices, check the automatic deploy pipeline guide.
- Composer in production mode
- Single artifact versioned by SHA
- Workflow inputs to parameterize rollout
- Clear separation of build and deploy
name: canary-deploy-php
on:
workflow_dispatch:
inputs:
percent_start:
description: 'Percentual inicial do canário'
default: '5'
host:
description: 'Host de produção (SSH)'
required: true
app_url:
description: 'URL pública para smoke tests'
required: true
jobs:
build:
runs-on: ubuntu-latest
outputs:
rel: ${{ steps.pkg.outputs.rel }}
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Install Composer deps
run: |
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
- name: Build assets (opcional)
run: |
echo 'adicione seu build de front aqui, se houver'
- name: Package artifact
id: pkg
run: |
REL=${GITHUB_SHA}
tar -czf release-$REL.tar.gz --exclude-vcs --exclude=.github .
echo rel=$REL >> $GITHUB_OUTPUT
- uses: actions/upload-artifact@v4
with:
name: php-release-${{ steps.pkg.outputs.rel }}
path: release-${{ steps.pkg.outputs.rel }}.tar.gz
Canary deploy over SSH and reloads without downtime
In this step we upload the artifact, extract it to releases/<sha> and update the canary symlink. Set the initial percentage in the split_clients snippet. Test the config (nginx -t) and reload services.
Save the current stable release path for quick rollback. Reloading PHP‑FPM clears opcache to avoid serving stale bytecode.
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: php-release-${{ needs.build.outputs.rel }}
- name: Prep SSH
env:
SSH_KEY: ${{ secrets.SSH_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
- name: Ship and activate canary
env:
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ inputs.host }}
REL: ${{ needs.build.outputs.rel }}
PSTART: ${{ inputs.percent_start }}
run: |
scp -o StrictHostKeyChecking=no release-$REL.tar.gz $SSH_USER@$SSH_HOST:/tmp/
ssh -o StrictHostKeyChecking=no $SSH_USER@$SSH_HOST bash -s <<EOF
set -euo pipefail
REL=${REL}
APP=/var/www/app
sudo mkdir -p $APP/releases/$REL
sudo tar -xzf /tmp/release-$REL.tar.gz -C $APP/releases/$REL
# Registra release estável atual
PREV=$(readlink -f $APP/releases/stable || true)
echo "$PREV" | sudo tee $APP/prev_stable.txt >/dev/null
# Ativa canary
sudo ln -sfn $APP/releases/$REL $APP/releases/canary
sudo systemctl reload php8.2-fpm
# Ajusta percentual no split_clients (arquivo snippet)
# Ex.: /etc/nginx/snippets/app_split.conf contém a linha "5% canary;"
sudo sed -i "s/^[[:space:]]*[0-9]\+% canary;/ ${PSTART}% canary;/" /etc/nginx/snippets/app_split.conf || true
sudo nginx -t && sudo systemctl reload nginx
EOF
Smoke tests and automated gradual promotion
With canary active, run smoke tests forcing the X‑Canary: 1 header. Validate critical endpoints and, if they pass, increase the percentage in steps like 5→25→50→100.
Between steps wait and monitor for errors. Validate HTTP 200 responses and abort on the first failure. Integrate alerts so the team can act quickly. See also health checks and readiness.
- Force canary with a header in all test calls
- Test login, checkout and critical API routes
- Wait and watch for 5xx errors between promotions
- Abort and trigger rollback on the first failure
#!/usr/bin/env bash
# run on GitHub Actions or jump host
set -euo pipefail
APP_URL="$1" # ex.: https://example.com
PERCENTS=(5 25 50 100)
SMOKE_ENDPOINTS=("/health" "/" "/api/status")
smoke() {
local url="$1"; local fail=0
for i in {1..10}; do
for ep in "${SMOKE_ENDPOINTS[@]}"; do
code=$(curl -fsS -o /dev/null -w "%{http_code}" -H "X-Canary: 1" "$url$ep" || true)
if [[ "$code" != "200" ]]; then
echo "Smoke FAIL $ep -> $code"; fail=1
else
echo "Smoke OK $ep -> $code"
fi
done
sleep 1
done
return $fail
}
for p in "${PERCENTS[@]}"; do
echo "Promovendo canário para ${p}%..."
ssh $SSH_OPTS $SSH_USER@$SSH_HOST "sudo sed -i 's/^[[:space:]]*[0-9]\+% canary;/ ${p}% canary;/' /etc/nginx/snippets/app_split.conf && sudo nginx -t && sudo systemctl reload nginx"
echo "Aguardando estabilizar..."; sleep 60
if ! smoke "$APP_URL"; then
echo "Falha nos smoke tests em ${p}%. Iniciando rollback..."; exit 42
fi
echo "OK em ${p}%"
done
echo "100% canário aprovado. Promovendo a estável..."
ssh $SSH_OPTS $SSH_USER@$SSH_HOST 'APP=/var/www/app; sudo ln -sfn "$APP/releases/canary" "$APP/releases/stable" && sudo systemctl reload php8.2-fpm && sudo sed -i "s/^[[:space:]]*[0-9]\+% canary;/ 0% canary;/" /etc/nginx/snippets/app_split.conf && sudo nginx -t && sudo systemctl reload nginx'
Automatic rollback and safe cleanup
If something fails, revert immediately. Zero the canary traffic, restore the stable symlink to the previous release and reload Nginx and PHP‑FPM. Do not delete the failed release until all traffic has returned to stable.
Store the stable release path before deploy and use it during rollback. After rollback, investigate calmly outside peak hours.
#!/usr/bin/env bash
# rollback.sh (executar no runner)
set -euo pipefail
ssh $SSH_OPTS $SSH_USER@$SSH_HOST '
set -euo pipefail
APP=/var/www/app
PREV=$(cat $APP/prev_stable.txt || true)
echo "Restaurando stable -> $PREV"
# 0% tráfego no canário
sudo sed -i "s/^[[:space:]]*[0-9]\+% canary;/ 0% canary;/" /etc/nginx/snippets/app_split.conf || true
sudo nginx -t && sudo systemctl reload nginx
# Reaponta o estável
if [ -n "$PREV" ] && [ -d "$PREV" ]; then
sudo ln -sfn "$PREV" "$APP/releases/stable"
fi
sudo systemctl.reload php8.2-fpm
'
How to verify success and next steps
Verify that anonymous and authenticated users see the correct version according to the configured percentage. Force the canary with the header and validate end-to-end: login, cart, checkout and APIs.
Monitor 5xx rates, response times and application logs. Integrate team alerts. After validation, clean up old releases and document percentages, timings and metrics.
Want a container-based alternative using headers and weights? See the canary with Traefik. For full swaps, check the blue-green deploy for PHP. You’ve completed the canary deploy tutorial with GitHub Actions and Nginx for PHP monoliths.
- Verify percentages with controlled samples
- Monitor logs and metrics for 24–48 hours
- Remove old releases after validation
- Document the rollout and lessons learned
Frequently asked questions
Can I use a single PHP‑FPM pool for stable and canary?
You can, but separate pools isolate opcache and settings, reducing interference between releases. With a single pool, invalidate opcache on each release and verify the memory limits can handle caches for both versions.
How do I handle different assets (CSS/JS) between releases?
Use fingerprinted filenames so browsers and Nginx serve the correct asset even when only dynamic traffic goes to the canary. Alternatively, publish static assets per version to a CDN and use versioned absolute URLs.
Does this flow guarantee zero downtime?
Nginx and PHP‑FPM reloads are graceful and typically preserve connections. Downtime is usually minimal, but always test configs with nginx -t and promote in short steps to minimize risk and avoid visible interruptions.
What about database migrations during a canary?
Plan migrations to be backward-compatible. Apply changes that don't break the old version first, then promote the canary. If a new version requires schema changes, use feature flags and phased migrations to allow rollback without data loss.
How should I monitor errors during promotion?
Collect 5xx rates, p95/p99 latency and application errors. Integrate alerts to your team channel. If you lack observability, start with a /health endpoint and simple counters, then evolve to metrics and alerts as maturity grows.
Can I pin a specific user to the canary?
Yes. Set a canary=1 cookie when authenticating users you want pinned to the canary. Nginx prioritizes header and cookie over percentage sampling, enabling targeted QA and support without relying on random hashing.
Need help running safe canaries in production?
Implement gradual rollouts, smoke tests and automatic rollback for your PHP monolith with expert support.
Talk about canary deploysSee also
- Tutorial: set up mTLS between Traefik and Docker Compose services using smallstep CA and automatic certificates — step by step
- Tutorial: integrate Sentry into your GitHub Actions deploy pipeline — automatic source map upload & issue creation (PHP & JavaScript)
- Configure retention and compression for Docker container logs with journald + logrotate + rsyslog on a low-disk VPS
- Tutorial: Configure GitHub Actions for integration tests with PostgreSQL in ephemeral containers and automatic migration rollback — a practical guide