Skip to main content

Startup CI/CD Stack: GitHub Actions + Docker + AWS ECS

·1242 words·6 mins
Author
Maksim P.
DevOps Engineer / SRE

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: true

Required 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-1

For 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: read

In 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-deployment

When 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 #

Reply by Email