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
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
| Change | Before | After |
|---|---|---|
bun install instead of npm ci (see benchmarks) | 1m 40s | 12 s |
Docker layer cache via type=gha | 4m 10s | 45 s |
| Skip build on PRs (test only) | Every PR built an image | PR check in 2 min |
| Parallel lint/typecheck/test jobs | Serial | Bounded 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.
- 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; grantid-token: writeonly to jobs that deploy.- Never run deploy jobs on
pull_requestfrom forks; the OIDCsubcondition 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.