Symfony CI/CD pipeline with GitHub Actions: from commit to production
Continuous delivery, not baroque delivery
A healthy CI/CD pipeline reads like a recipe. Install, check, test, secure, build, deploy. No need for a Jenkins with 200 plugins, no need for ArgoCD when the team is 4 developers, no need for Kubernetes to deploy a Symfony API.
The clients we work with have often inherited a 2016 Jenkins monolith, or a GitLab Runner on a forgotten VM. It works, but the pipeline is slow, opaque, and nobody knows how to change it. Our approach: GitHub Actions or GitLab CI, short declarative workflows, tested, with OIDC for cloud access.
This article details the complete pipeline we deploy on our Symfony projects in production: the 7 stages, the commented ci.yml, deploy strategies and the anti-patterns that kill quality.
Anatomy of a 7-stage pipeline
Every push to a branch opens a Pull Request, the PR triggers the full pipeline. Merge to main triggers deployment. Here is the sequence.
- Install.
composer installwith Composer cache and OPcache preload artifact. - Lint. PHP-CS-Fixer, Twig-CS-Fixer, YAML lint, Markdown lint, ESLint for the frontend if any.
- Static analysis. PHPStan level 8 or 9, Psalm, Rector dry-run.
- Test. PHPUnit unit + integration, Behat on critical journeys, functional PHPUnit, coverage > 80%.
- Security.
composer audit,symfony security:check, Snyk, Trivy on the Docker image. - Build & push. Docker multi-stage to private ECR or GHCR,
shatag andenvtag. - Deploy. ECS rolling update, blue-green or canary depending on environment.
Stages are parallelizable from Install onward. On a medium Symfony monorepo (300 tests, 60 kloc), the full pipeline runs in 8 to 12 minutes with proper caching.
Full GitHub Actions workflow
Here is the workflow we install and adapt on every client project. It goes all the way to ECS deploy via OIDC (no clear AWS secrets).
name: CI/CD
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: read
id-token: write # required for OIDC to AWS
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
COMPOSER_VERSION: 2.8.4
AWS_REGION: eu-west-3
ECR_REPOSITORY: invoicing-api
jobs:
install:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: intl, pdo_pgsql, redis, zip, bcmath, apcu
coverage: none
tools: composer:${{ env.COMPOSER_VERSION }}
- name: Cache Composer
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
- name: Install dependencies
run: composer install --no-progress --no-interaction --prefer-dist
- name: Upload vendor
uses: actions/upload-artifact@v4
with:
name: vendor
path: vendor
retention-days: 1
lint:
runs-on: ubuntu-24.04
needs: install
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- uses: actions/download-artifact@v4
with:
name: vendor
path: vendor
- name: PHP-CS-Fixer
run: vendor/bin/php-cs-fixer fix --dry-run --diff --show-progress=none
- name: Twig lint
run: bin/console lint:twig templates
- name: YAML lint
run: bin/console lint:yaml config translations
- name: Container lint
run: bin/console lint:container --env=prod
static:
runs-on: ubuntu-24.04
needs: install
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- uses: actions/download-artifact@v4
with:
name: vendor
path: vendor
- name: PHPStan
run: vendor/bin/phpstan analyse --memory-limit=1G --error-format=github
- name: Psalm
run: vendor/bin/psalm --output-format=github --threads=4
- name: Rector dry-run
run: vendor/bin/rector process --dry-run
test:
runs-on: ubuntu-24.04
needs: install
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
symfony: ['6.4.*', '7.2.*']
exclude:
- php: '8.2'
symfony: '7.2.*'
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 5s
--health-timeout 3s --health-retries 5
redis:
image: redis:7-alpine
ports: ['6379:6379']
options: >-
--health-cmd "redis-cli ping" --health-interval 5s
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: intl, pdo_pgsql, redis, zip, bcmath
coverage: pcov
- name: Lock Symfony version
run: composer require --no-update symfony/symfony:${{ matrix.symfony }}
- name: Install
run: composer update --no-progress --prefer-dist
- name: DB schema
run: |
bin/console doctrine:database:create --env=test --if-not-exists
bin/console doctrine:migrations:migrate --env=test --no-interaction
env:
DATABASE_URL: postgresql://test:test@127.0.0.1:5432/test
- name: PHPUnit
run: vendor/bin/phpunit --coverage-clover=coverage.xml
env:
DATABASE_URL: postgresql://test:test@127.0.0.1:5432/test
REDIS_URL: redis://127.0.0.1:6379
- name: Coverage gate
run: php tools/coverage-check.php coverage.xml 80
- name: Behat
run: vendor/bin/behat --no-colors
security:
runs-on: ubuntu-24.04
needs: install
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- uses: actions/download-artifact@v4
with:
name: vendor
path: vendor
- name: Composer audit
run: composer audit --locked
- name: Symfony Security
run: |
curl -sS https://get.symfony.com/cli/installer | bash
$HOME/.symfony5/bin/symfony security:check
- name: Snyk
uses: snyk/actions/php@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
build:
runs-on: ubuntu-24.04
needs: [lint, static, test, security]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
image_tag: ${{ steps.meta.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Configure AWS OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-ecr-push
aws-region: ${{ env.AWS_REGION }}
- name: Login ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Set image tag
id: meta
run: echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.tag }}
${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:main
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.tag }}
format: 'sarif'
output: 'trivy.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1'
deploy:
runs-on: ubuntu-24.04
needs: build
environment:
name: production
url: https://app.example.com
steps:
- uses: actions/checkout@v4
- name: Configure AWS OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-ecs-deploy
aws-region: ${{ env.AWS_REGION }}
- name: Render task definition
id: task
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: deploy/task-def.prod.json
container-name: app
image: ${{ needs.build.outputs.image_tag }}
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.task.outputs.task-definition }}
service: invoicing-api
cluster: prod-cluster
wait-for-service-stability: true
- name: Smoke tests
run: ./tools/smoke-tests.sh https://app.example.com
Key takeaways from this workflow:
- Concurrency with
cancel-in-progresson PRs: each new push cancels the previous run, CI minutes are not wasted. - Shared
vendorartifact between jobs to avoid Ncomposer installruns. - PHP 8.2 / 8.3 / 8.4 and Symfony 6.4 LTS / 7.2 matrix: we validate future compatibility today.
- Postgres and Redis services in the runner for realistic integration tests.
- OIDC to AWS rather than long-lived access keys stored in secrets: IAM roles are assumed temporarily, no long-lived credentials in GitHub.
- Trivy scans the Docker image right after ECR push,
exit-code: 1on CRITICAL/HIGH. - Production environment in GitHub Environments enforces protections (approvers, branch rules, wait timer).
PHP / Symfony matrix
We maintain the following matrix on every active Symfony project.
| PHP | Symfony 6.4 LTS | Symfony 7.2 | Status |
|---|---|---|---|
| 8.2 | Yes | No (7.x requires 8.2+) | LTS validation |
| 8.3 | Yes | Yes | Primary target |
| 8.4 | Yes | Yes | EOL anticipation |
The benefit is twofold. First, we spot incompatibilities quickly when a new PHP or Symfony version ships. Second, we show tech leaders that the migration trajectory is anticipated by CI, not improvised when end-of-life hits.
Deployment strategies
Strategy choice depends on SLO, traffic and tolerance to regressions.
- Rolling update. Default on ECS. New tasks replace old ones progressively, ALB health checks. Simple, reliable, some residual risk on cross-version bugs.
- Blue-green. Two target environments, ALB switch. Zero downtime, instant rollback. Cost: 2x compute during the switch. Recommended for SLOs > 99.95%.
- Canary. 5% traffic on the new version, metrics watched for 15 to 30 minutes, then progressive promotion. Tools: AWS CodeDeploy canary, Argo Rollouts on EKS, or a home-grown ALB weight implementation.
- Feature flags. Unleash, Flagsmith or LaunchDarkly. The new code has been in production for a while, disabled. Toggle for 1%, 10%, 100% of users. Decouples deploy from release.
We recommend rolling update + feature flags as the combination for most B2B apps. Strict zero downtime (blue-green or canary) is justified above a 99.95% SLO or on systems with direct revenue impact.
Secrets management
Three rules enforced everywhere:
- No secrets in the repo, even encrypted.
.envin production is not in git. - OIDC for AWS access from GitHub Actions, no AWS access keys in GitHub secrets.
- Secrets Manager for runtime secrets (DB password, API keys). Injected into ECS tasks via the task definition, never copied elsewhere.
Server-side OIDC configuration is an IAM role with a trust policy that validates the GitHub issuer and the repository. Simplified example:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:acme/invoicing-api:ref:refs/heads/main"
}
}
}
]
}
Quality gates
Merge to main requires:
- All checks green:
lint,static,test,security. - Coverage > 80%, enforced by a small script that parses the clover.xml.
- Review approved by a module owner (CODEOWNERS).
- Dependabot alerts resolved on critical and high.
- No direct commit on
main(branch protection enabled).
Rollback
A deployment must be reversible in under 5 minutes. The two levers we install systematically:
- ECS previous task definition. The rollback workflow pulls task def
prod-N-1, updates the service, waits for stability. About thirty lines of script. - Reversible DB migrations. Every Doctrine migration has its tested
down()method. Non-reversible migrations (DROP COLUMN, data loss) are forbidden outside maintenance windows.
Rollback must be tested in staging every quarter, otherwise it will not work the day you need it.
Post-deploy observability
After ecs-deploy, the deploy job runs smoke tests, then we monitor the next 30 minutes.
- Smoke tests: 10 to 20 HTTP requests on critical endpoints. Bash + curl, or a PHP script. Failure → automatic rollback.
- Canary analysis: compare error rates and p95 latency before/after over a sliding window. Implemented via CloudWatch Metrics + a Lambda, or Datadog release tracking.
- Alerting: Slack and PagerDuty for SLO alerts, with release info (commit sha, author) in the message.
DORA metrics
DORA's four metrics are the only ones we ask the ops steering committee for.
| Metric | Elite target | High target | Our typical client baseline |
|---|---|---|---|
| Deploy frequency | Multiple per day | 1/day to 1/week | 2 to 5 per day |
| Lead time for changes | < 1 hour | < 1 day | 4 to 8 hours |
| Change failure rate | < 15% | < 15% | 5 to 10% |
| Mean time to restore | < 1 hour | < 1 day | 20 to 60 minutes |
A healthy pipeline lets you ship several times a day without fear. A team that needs a week between merge and production has a process problem, not a technical one.
Anti-patterns we refuse
- Tolerated flaky tests. A test passing 7 times out of 10 pollutes the signal. We isolate, fix, quarantine. We do not disable.
- Merge to
mainwithout a PR. Even for hotfixes. The PR is the unit of traceability. - Secrets in clear in GitHub. Even for non-production secrets. Everything goes through a vault or OIDC.
- No rollback tested. The rollback that works in slides will not work under fire.
- Non-deterministic build.
composer installwithoutcomposer.lockcommitted,npm installwithoutpackage-lock.json,apt installwithout version pin: reproducibility is broken. - Pipeline > 20 minutes. Developers route around it, push direct, skip PRs. CI budget = 15 minutes max on PRs, 25 minutes max on main.
- Integration tests hitting production. Never. Never. Never.
Conclusion
A modern Symfony CI/CD pipeline fits in a 200-line GitHub Actions file, runs in 10 minutes, covers lint, static analysis, multi-version tests, security, hardened Docker image and ECS deploy via OIDC. No long-lived secrets, no machine to maintain, DORA metrics approaching the elite tier.
For a pipeline audit, a from-scratch setup or a Jenkins to GitHub Actions migration, email us at contact@your-digital-hub.com or discover our PHP expertise and DevOps services.