The first deploy pipeline I wrote for a client SSH'd into a server with a private key stored in GitHub Secrets and ran git pull. It worked until the day someone pushed a broken migration at 6 pm and there was no way back. The pipeline below is what replaced it, refined across several projects since: OIDC instead of keys, immutable images, health-checked rolling deploys and automatic rollback. It is around 120 lines of YAML and it has saved me from a bad Friday more than once.

The shape of the pipeline

pull request ──▶ [ lint + typecheck + unit tests ] ──▶ status check on the PR (nothing is built or deployed) push to main ──▶ [ test ] ──▶ [ build image, tag :sha, push to ECR ] ──▶ [ deploy to staging ] │ manual approval (environment) │ ▼ [ deploy to production ] rolling, health-checked circuit breaker → auto rollback

Step 1: OIDC trust instead of access keys

GitHub can mint a short-lived OIDC token for each workflow run. AWS trusts that token for a specific role, scoped to a specific repository and branch. No secret ever leaves AWS, and a leaked workflow file cannot deploy from a fork.

// IAM role trust policy: only this repo, only the main branch, may assume the role
{
  "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:umm-digital/client-api:ref:refs/heads/main" }
    }
  }]
}

Attach a permissions policy that allows only what the pipeline does: ecr:* on the one repository, ecs:UpdateService and ecs:RegisterTaskDefinition on the one service, and iam:PassRole for the task's execution role. Create the OIDC provider once per account (Terraform: aws_iam_openid_connect_provider).

Step 2: The workflow

# .github/workflows/deploy.yml
name: CI/CD

on:
  pull_request:
  push:
    branches: [main]

permissions:
  id-token: write        # required for OIDC
  contents: read
  packages: read

env:
  AWS_REGION: ap-south-1
  ECR_REPO: client-api
  ECS_CLUSTER: prod
  ECS_SERVICE: client-api

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false      # never cancel a deploy halfway

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    services:
      postgres:
        image: postgres:16-alpine
        env: { POSTGRES_PASSWORD: test, POSTGRES_DB: app_test }
        ports: ['5432:5432']
        options: --health-cmd pg_isready --health-interval 5s --health-retries 10
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - run: bun run lint && bun run typecheck
      - run: bun run db:migrate
        env: { DATABASE_URL: postgres://postgres:test@localhost:5432/app_test }
      - run: bun test --coverage
        env: { DATABASE_URL: postgres://postgres:test@localhost:5432/app_test }

  build:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    outputs:
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy-client-api
          aws-region: ${{ env.AWS_REGION }}
      - id: ecr
        uses: aws-actions/amazon-ecr-login@v2
      - uses: docker/setup-buildx-action@v3
      - id: meta
        run: echo "image=${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPO }}:${{ github.sha }}" >> "$GITHUB_OUTPUT"
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.image }}
          cache-from: type=gha                       # layer cache across runs: 4 min → 40 s
          cache-to: type=gha,mode=max
          provenance: false

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with: { role-to-assume: arn:aws:iam::123456789012:role/github-deploy-client-api, aws-region: ${{ env.AWS_REGION }} }
      - uses: ./.github/actions/ecs-deploy
        with: { cluster: staging, service: client-api, image: ${{ needs.build.outputs.image }} }
      - run: curl --fail --retry 5 --retry-delay 5 https://staging.client.com/health

  deploy-production:
    needs: [build, deploy-staging]
    runs-on: ubuntu-latest
    environment: production          # configured with "required reviewers" → manual approval gate
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with: { role-to-assume: arn:aws:iam::123456789012:role/github-deploy-client-api, aws-region: ${{ env.AWS_REGION }} }
      - uses: ./.github/actions/ecs-deploy
        with: { cluster: ${{ env.ECS_CLUSTER }}, service: ${{ env.ECS_SERVICE }}, image: ${{ needs.build.outputs.image }} }

The test job runs a real Postgres service container and applies migrations, so a migration that breaks is caught on the PR, not in production. The concurrency block guarantees two pushes to main deploy in order rather than racing.

Step 3: The deploy action

ECS deployments work by registering a new task definition revision pointing at the new image and telling the service to use it. A composite action keeps that logic in one place for staging and production:

# .github/actions/ecs-deploy/action.yml
name: ECS rolling deploy
inputs:
  cluster: { required: true }
  service: { required: true }
  image:   { required: true }
runs:
  using: composite
  steps:
    - shell: bash
      run: |
        set -euo pipefail
        # 1. Fetch the current task definition, swap the image, strip read-only fields
        aws ecs describe-task-definition --task-definition "${{ inputs.service }}" \
          --query taskDefinition > td.json
        jq --arg IMG "${{ inputs.image }}" \
          '.containerDefinitions[0].image = $IMG
           | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)' \
          td.json > td-new.json
        NEW_ARN=$(aws ecs register-task-definition --cli-input-json file://td-new.json --query taskDefinition.taskDefinitionArn --output text)

        # 2. Roll the service; ECS keeps old tasks until new ones pass the ALB health check
        aws ecs update-service --cluster "${{ inputs.cluster }}" --service "${{ inputs.service }}" \
          --task-definition "$NEW_ARN" \
          --deployment-configuration "minimumHealthyPercent=100,maximumPercent=200,deploymentCircuitBreaker={enable=true,rollback=true}"

        # 3. Wait for stability (fails the job if the circuit breaker rolled back)
        aws ecs wait services-stable --cluster "${{ inputs.cluster }}" --services "${{ inputs.service }}"
        echo "Deployed ${{ inputs.image }} as $NEW_ARN"

Three settings do the zero-downtime work: minimumHealthyPercent=100 means old tasks stay until replacements are healthy; maximumPercent=200 lets ECS run both sets briefly; and the deployment circuit breaker watches new tasks fail their health checks and automatically rolls the service back to the previous task definition. Your app needs a proper /health endpoint and graceful shutdown for this to work, both covered in the production REST API guide.

Step 4: Manual rollback in one command

Because every image is tagged with its git SHA and every task definition revision is kept, rolling back is pointing the service at the previous revision:

# List recent revisions; pick the last known-good
aws ecs list-task-definitions --family-prefix client-api --sort DESC --max-items 5

# Roll back
aws ecs update-service --cluster prod --service client-api --task-definition client-api:41
aws ecs wait services-stable --cluster prod --services client-api

I keep this as a workflow_dispatch workflow with a "revision" input so anyone on the team can roll back from the GitHub UI without AWS console access. Database migrations are the exception: they must be backward-compatible with the previous code (expand, deploy, contract), otherwise a code rollback leaves the schema ahead of the app.

Variant: a WordPress site on a single VPS

Not every client needs ECS. For agency WordPress sites on a Hetzner or Lightsail VPS, the same principles apply with a simpler deploy step: build the image in CI (the multi-stage Dockerfile), push to GHCR, then SSH in with a deploy-only key and pull:

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: deploy
          key: ${{ secrets.VPS_DEPLOY_KEY }}       # a restricted key: ForceCommand to the deploy script only
          script: |
            cd /srv/client-site
            export GIT_SHA=${{ github.sha }}
            docker compose -f compose.yaml -f compose.prod.yaml pull wordpress
            docker compose -f compose.yaml -f compose.prod.yaml up -d --no-deps wordpress
            docker compose run --rm cli wp core update-db --quiet
            curl --fail --retry 5 --retry-delay 3 https://client-site.com/health || (docker compose up -d --no-deps wordpress@previous && exit 1)

Here the SSH key is a real secret, so lock it down: a dedicated deploy user, an authorized_keys entry with command="/srv/deploy.sh" so the key can run nothing else, and Cloudflare in front of the server so port 22 is only reachable through a tunnel or allow-listed IP.

Keeping it fast

ChangeBeforeAfter
bun install instead of npm ci (see benchmarks)1m 40s12 s
Docker layer cache via type=gha4m 10s45 s
Skip build on PRs (test only)Every PR built an imagePR check in 2 min
Parallel lint/typecheck/test jobsSerialBounded by the slowest
Total (push to main → prod ready)~14 min~5 min + approval

Ten minutes is the threshold in my experience. Above it, developers batch changes to avoid waiting, batches get bigger, and bigger deploys fail more often. Below it, they deploy small changes many times a day, which is the whole point.

Security checklist for the pipeline itself
  • Pin third-party actions to a commit SHA, not a tag, for anything with access to credentials.
  • permissions: at the top of the workflow, minimal; grant id-token: write only to jobs that deploy.
  • Never run deploy jobs on pull_request from forks; the OIDC sub condition enforces this even if the YAML is wrong.
  • Enable branch protection on main: required status checks, no force push, require a review.
  • Rotate any long-lived secrets you still have quarterly, and audit the repo's secrets list.

This pipeline is boring, which is exactly the compliment you want for something that ships code. Once it exists, the next steps are observability (structured logs shipped to CloudWatch or an APM) and the caching layer from Redis Caching Strategies. If you would like this set up for a project, DevOps for small teams is a large part of what I do.