Canary deploys with GitHub Actions and Nginx for PHP monoliths

Deploy & DevOps · By · Published on · 11 min read

Person working at a desk with a large monitor and laptop, bright office background
Professional workspace with monitor and laptop, suited for technical tutorials and deploy operations. Photo: RDNE Stock project / Pexels

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

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 deploys

See also