YOUR DIGITAL HUB
← Back to blog

Symfony CI/CD pipeline with GitHub Actions: from commit to production

· 10 min read
Cover visual — Symfony CI/CD pipeline with GitHub Actions

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.

  1. Install. composer install with Composer cache and OPcache preload artifact.
  2. Lint. PHP-CS-Fixer, Twig-CS-Fixer, YAML lint, Markdown lint, ESLint for the frontend if any.
  3. Static analysis. PHPStan level 8 or 9, Psalm, Rector dry-run.
  4. Test. PHPUnit unit + integration, Behat on critical journeys, functional PHPUnit, coverage > 80%.
  5. Security. composer audit, symfony security:check, Snyk, Trivy on the Docker image.
  6. Build & push. Docker multi-stage to private ECR or GHCR, sha tag and env tag.
  7. 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-progress on PRs: each new push cancels the previous run, CI minutes are not wasted.
  • Shared vendor artifact between jobs to avoid N composer install runs.
  • 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: 1 on 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:

  1. No secrets in the repo, even encrypted. .env in production is not in git.
  2. OIDC for AWS access from GitHub Actions, no AWS access keys in GitHub secrets.
  3. 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 main without 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 install without composer.lock committed, npm install without package-lock.json, apt install without 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.