Skip to main content

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

·853 words·5 mins· loading · loading ·
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, 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: true

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

For 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-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 (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 #

Reply by Email