TL;DR #
- GitHub Actions for CI/CD — free for public repos, cheap for private ones
- Docker for packaging — one Dockerfile, same image everywhere
- AWS ECR for container registry — no extra vendor, IAM handles auth
- ECS Fargate for running containers — no servers to manage, no capacity to think about
- Total setup time: one afternoon, total monthly cost for a small app: ~$30-50, plus ~$33/month if your tasks sit in private subnets behind a NAT Gateway
Who this stack is for #
You’re a team of 3-10 engineers shipping a web app or API. You want containers in production but don’t want to manage Kubernetes. You’re already on AWS (or willing to be). You want something you can set up once and forget about until you outgrow it.
The stack #
| Layer | Tool | Why |
|---|---|---|
| CI/CD | GitHub Actions | Where your code already lives |
| Container build | Docker | Industry standard, no learning curve |
| Registry | AWS ECR | Native AWS auth, no extra credentials |
| Runtime | ECS Fargate | Serverless containers, no EC2 instances |
| Load balancer | ALB | Health checks, TLS termination, path routing |
Dockerfile #
A multi-stage build that works for most Node.js apps. Adapt the base image and commands for your runtime.
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
# Full install — the build tool (tsc, vite, webpack) lives in devDependencies.
# `npm ci --omit=dev` here would make the next line fail with "command not found".
RUN npm ci
COPY . .
RUN npm run build
# Now drop the dev dependencies so the runtime stage copies a lean tree
RUN npm prune --omit=dev
FROM node:22-alpine
WORKDIR /app
RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -s /bin/sh -D appuser
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/main.js"]ECS task definition #
Save this as ecs-task-def.json in your repo root. Replace the placeholder values.
{
"family": "your-app-name",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "your-app-name",
"image": "YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/your-app-name:latest",
"portMappings": [
{
"containerPort": 3000,
"protocol": "tcp"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/your-app-name",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
},
"healthCheck": {
"command": ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 60
},
"environment": [
{ "name": "NODE_ENV", "value": "production" },
{ "name": "PORT", "value": "3000" }
]
}
]
}GitHub Actions workflow #
Save as .github/workflows/deploy.yml. This builds on every push to main, pushes the image to ECR, and updates the ECS service.
name: Build and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: your-app-name
ECS_CLUSTER: your-cluster-name
ECS_SERVICE: your-service-name
CONTAINER_NAME: your-app-name
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm test
- run: npm run lint
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
# Required for OIDC. Without it, role-to-assume fails with
# "Credentials could not be loaded".
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
# Short-lived credentials via OIDC. Requires `permissions:
# id-token: write` on the job — see below.
role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/github-actions-deploy
aws-region: ${{ env.AWS_REGION }}
- name: Login to ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image
id: build
env:
ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
# One build, two tags. Two `docker build` invocations would
# rebuild every layer on a cold cache.
docker build \
-t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
-t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Update ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ecs-task-def.json
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.build.outputs.image }}
- name: Deploy to ECS
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: trueRequired AWS setup #
Before the pipeline works, you need these resources. Create them once via the AWS console or Terraform.
# Create ECR repository with vulnerability scanning on push
aws ecr create-repository \
--repository-name your-app-name \
--image-scanning-configuration scanOnPush=true \
--region us-east-1
# Keep the last 20 images so storage doesn't grow forever. Note the rule
# selects tagStatus: any, not untagged: every push tags the image with the
# commit sha as well as latest, so when latest moves the old image is still
# tagged and an untagged-only rule would never match it.
aws ecr put-lifecycle-policy \
--repository-name your-app-name \
--region us-east-1 \
--lifecycle-policy-text '{
"rules": [{
"rulePriority": 1,
"description": "Keep the 20 most recent images",
"selection": {
"tagStatus": "any",
"countType": "imageCountMoreThan",
"countNumber": 20
},
"action": { "type": "expire" }
}]
}'
# Create CloudWatch log group
aws logs create-log-group --log-group-name /ecs/your-app-name --region us-east-1
# Create ECS cluster
aws ecs create-cluster --cluster-name your-cluster-name --region us-east-1For the ECS service, ALB, target group, and VPC networking, use the AWS console or see our Infrastructure as Code Starter stack.
AWS access from GitHub Actions #
Don’t create an IAM user with long-lived access keys for this. A key that can deploy to production, stored in repo secrets and never rotated, is the most valuable thing in your GitHub account and the easiest to leak.
Use GitHub OIDC instead: GitHub hands AWS a short-lived signed token, AWS returns credentials valid for the length of the job, and there is nothing to rotate or to steal from your secrets store.
Two pieces are needed. In the workflow, grant the job permission to request a token:
permissions:
id-token: write
contents: readIn AWS, create the OIDC provider once and a role that trusts your repository specifically:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::YOUR_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:*" }
}
}Scope that sub condition as tightly as you can — repo:org/repo:ref:refs/heads/main if only main deploys. Without a condition on the subject, any repository on GitHub can assume your role.
Minimum permissions for the role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
],
"Resource": "arn:aws:ecr:us-east-1:YOUR_ACCOUNT_ID:repository/your-app-name"
},
{
"Effect": "Allow",
"Action": "ecr:GetAuthorizationToken",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"ecs:DescribeServices",
"ecs:UpdateService",
"ecs:RegisterTaskDefinition",
"ecs:DescribeTaskDefinition"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskExecutionRole"
}
]
}Rollback #
ECS keeps previous task definitions. To roll back:
# List recent task definitions.
# Note: the flag is --family-prefix. There is no --family.
aws ecs list-task-definitions \
--family-prefix your-app-name \
--sort DESC \
--max-items 5
# Roll back to previous version
aws ecs update-service \
--cluster your-cluster-name \
--service your-service-name \
--task-definition your-app-name:PREVIOUS_VERSION \
--force-new-deploymentWhen to outgrow this stack #
This setup handles most apps comfortably up to ~20 containers. Consider moving on when:
- You need multiple services with complex networking between them (look at ECS Service Connect or switch to Kubernetes)
- You want canary deployments or traffic splitting (use ECS blue/green deployments via CodeDeploy, or switch to Kubernetes with Argo Rollouts — not AWS App Mesh, which stopped accepting new customers in September 2024 and reaches end of support on 30 September 2026)
- Your container count makes Fargate pricing painful (switch to EC2-backed ECS or Kubernetes)
Related reads #
- CI/CD for Small Teams — principles behind this pipeline
- Zero-Downtime Deployments — deployment patterns that complement this stack
- Container Registry Options — if you want alternatives to ECR