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, scales to zero thought
- Total setup time: one afternoon, total monthly cost for a small app: ~$30-50
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:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:20-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: '20'
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
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
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: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker build -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
aws ecr create-repository --repository-name your-app-name --region us-east-1
# 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.
GitHub Secrets to configure #
Go to your repo Settings > Secrets and variables > Actions:
| Secret | Value |
|---|---|
AWS_ACCESS_KEY_ID |
IAM user access key with ECR + ECS permissions |
AWS_SECRET_ACCESS_KEY |
Corresponding secret key |
Minimum IAM permissions needed:
{
"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": "*"
},
{
"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
aws ecs list-task-definitions --family 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 (look at App Mesh or switch to Kubernetes with Argo Rollouts)
- 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