Setting Up an Automated CI/CD Deployment Pipeline via GitHub Actions to AWS ECS
Design choices that matter here
This pipeline authenticates to AWS using GitHub's OIDC provider instead of long-lived IAM access keys stored as repo secrets — the single highest-value security improvement you can make to a GitHub Actions → AWS pipeline. It builds once, scans the image, pushes to ECR, then updates the ECS service with a new task definition revision, relying on ECS's built-in rolling deployment and the ALB health check to roll back automatically if the new tasks never become healthy.
1. Technical architecture
┌──────────────┐ push to main ┌───────────────────────────┐
│ GitHub repo │ ───────────────▶│ GitHub Actions workflow │
└──────────────┘ │ 1. Checkout │
│ 2. Build Docker image │
│ 3. Trivy vulnerability scan │
│ 4. Assume AWS role via OIDC │
│ 5. Push image to ECR │
│ 6. Render new task definition │
│ 7. Update ECS service │
│ 8. Wait for service stability │
└──────────────┬────────────────┘
▼
┌───────────────────────────┐
│ ECS Fargate service │
│ - Rolling deployment (min 100%, │
│ max 200%) │
│ - ALB target group health check │
│ - Auto-rollback if new tasks │
│ never reach healthy state │
└───────────────────────────┘
2. Complete code implementation
2.1 Dockerfile (multi-stage, non-root)
# Dockerfile
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
COPY --from=build --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=build --chown=appuser:appgroup /app/dist ./dist
COPY --from=build --chown=appuser:appgroup /app/package.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=15s --timeout=3s --retries=3 CMD node dist/healthcheck.js
CMD ["node", "dist/server.js"]
2.2 ECS task definition template
{
"family": "my-api-service",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::ACCOUNT_ID:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::ACCOUNT_ID:role/myApiTaskRole",
"containerDefinitions": [
{
"name": "api",
"image": "IMAGE_PLACEHOLDER",
"portMappings": [{ "containerPort": 3000, "protocol": "tcp" }],
"essential": true,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/my-api-service",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
},
"environment": [{ "name": "NODE_ENV", "value": "production" }],
"secrets": [
{ "name": "DATABASE_URL", "valueFrom": "arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:prod/db-url" }
],
"healthCheck": {
"command": ["CMD-SHELL", "node dist/healthcheck.js || exit 1"],
"interval": 15,
"timeout": 3,
"retries": 3,
"startPeriod": 30
}
}
]
}
2.3 GitHub Actions workflow
# .github/workflows/deploy.yml
name: Build and Deploy to ECS
on:
push:
branches: [main]
permissions:
id-token: write # required for OIDC
contents: read
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: my-api-service
ECS_CLUSTER: production-cluster
ECS_SERVICE: my-api-service
ECS_TASK_DEFINITION: .aws/task-definition.json
CONTAINER_NAME: api
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-ecs-deploy
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2
- name: Build image
id: build
env:
REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t "$REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" .
echo "image=$REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> "$GITHUB_OUTPUT"
- name: Scan image for critical vulnerabilities
uses: aquasecurity/[email protected]
with:
image-ref: ${{ steps.build.outputs.image }}
severity: CRITICAL
exit-code: "1" # fail the job if any CRITICAL CVE is found
ignore-unfixed: true
- name: Push image to ECR
run: docker push "${{ steps.build.outputs.image }}"
- name: Render new task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ${{ env.ECS_TASK_DEFINITION }}
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.build.outputs.image }}
- name: Deploy to ECS with rolling update
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
wait-for-minutes: 10
2.4 AWS OIDC trust policy (for the deploy role)
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::ACCOUNT_ID: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:YOUR_ORG/YOUR_REPO:ref:refs/heads/main" }
}
}
]
}
3. Step-by-step configuration guide
- Create the GitHub OIDC identity provider in IAM (one-time, per AWS account): IAM → Identity providers → Add provider → OpenID Connect → URL
https://token.actions.githubusercontent.com, audiencests.amazonaws.com. - Create the deploy IAM role with the trust policy from §2.4, scoped to your exact
repo:ORG/REPO:ref:refs/heads/main— never use a wildcard*here, which would let any repo in your GitHub org assume the role. - Attach a least-privilege permissions policy to that role:
ecr:GetAuthorizationToken,ecr:BatchCheckLayerAvailability,ecr:PutImage,ecr:InitiateLayerUpload,ecr:UploadLayerPart,ecr:CompleteLayerUpload,ecs:DescribeServices,ecs:DescribeTaskDefinition,ecs:RegisterTaskDefinition,ecs:UpdateService, andiam:PassRolescoped to the ECS execution/task role ARNs only. - Store
AWS_ACCOUNT_IDas a repository secret (it's an account number, not a credential, but keeping it out of the workflow file avoids hardcoding per-environment values). - Create the ECR repository:
aws ecr create-repository --repository-name my-api-service --image-scanning-configuration scanOnPush=true. - Commit
.aws/task-definition.json(§2.2) to the repo with a placeholder image value — the workflow overwrites it on every run viaamazon-ecs-render-task-definition. - Create the ECS cluster, service, and ALB target group (via Console, CDK, or Terraform) with the deployment configuration:
minimumHealthyPercent: 100,maximumPercent: 200, and a target group health check path matching your app's actual health endpoint. - Push to
mainand watch the Actions run — the final step blocks until ECS reports the new deployment stable (or times out and fails the job).
4. Error handling and edge cases
- Failed health checks after deployment — because the ECS service is configured with a rolling deployment (
minimumHealthyPercent: 100,maximumPercent: 200), ECS starts new tasks alongside the old ones and only drains old tasks once new ones pass the ALB health check; if new tasks never become healthy,wait-for-service-stability: truecauses the GitHub Actions job to fail explicitly afterwait-for-minutes, rather than silently leaving a half-deployed service — and ECS itself will have already stopped trying to scale up the unhealthy revision, leaving the previous stable tasks serving traffic. - Critical CVEs in the image — the Trivy scan step runs with
exit-code: "1"forCRITICALseverity, failing the build before the image is ever pushed to ECR;ignore-unfixed: trueavoids blocking on CVEs with no available patch yet, which you can't act on immediately anyway. - OIDC trust policy too broad — scoping the
subcondition torepo:YOUR_ORG/YOUR_REPO:ref:refs/heads/main(not a wildcard, and not just the repo without a ref) means only workflow runs triggered from pushes tomainin that exact repo can assume the deploy role — a PR from a fork, or a push to a feature branch, cannot. - Secrets in the task definition —
DATABASE_URLis referenced viasecrets.valueFrompointing at Secrets Manager, not embedded inenvironmentas plaintext; the ECS execution role needssecretsmanager:GetSecretValueon that specific secret ARN, and rotating the secret in Secrets Manager does not require a new deployment since ECS resolves it at task start. - Concurrent deployments — if two pushes to
mainland close together, GitHub Actions will run both workflow jobs; addconcurrency: { group: deploy-production, cancel-in-progress: false }at the workflow level so a second deployment queues behind the first rather than racing it (usecancel-in-progress: trueonly if a superseded deployment is genuinely safe to abandon mid-flight for your service). - Task execution role vs. task role confusion —
executionRoleArnis what ECS itself uses to pull the image and fetch secrets before the container starts;taskRoleArnis what your application code uses at runtime (e.g., to call other AWS services). Granting your application's runtime permissions to the execution role instead of the task role is a common misconfiguration that either over-privileges the ECS agent or leaves your app unable to call the AWS APIs it needs. - Image tag collisions — tagging images with
${{ github.sha }}(notlatest) guarantees every deployment references an immutable, traceable image; enable ECR tag immutability (imageTagMutability: IMMUTABLE) so a tag can never be silently overwritten after the fact.