All tutorials

Tutorial

CI/CD Pipeline for a Dockerized Full-Stack App: GitHub Actions to ECS

Production-ready GitHub Actions workflow that builds Docker images, pushes to ECR, and deploys to ECS Fargate—eliminating manual deploys and 11pm hotfixes.

If you’ve manually SSH’d into a server for an 11pm hotfix, you already know why automating deployment matters. A solid CI/CD pipeline isn’t optional—it’s how you stop being your own bottleneck.

This guide walks through a production-ready GitHub Actions pipeline that builds Docker images, pushes them to Amazon ECR, and deploys to ECS Fargate.

What We’re Deploying

A typical full-stack setup:

  • Frontend: Next.js (containerized)
  • Backend: FastAPI or Django (containerized)
  • Database: PostgreSQL on RDS (uncontainerized—don’t run stateful databases in ECS)
  • Orchestration: ECS Fargate with two separate services

Both containers live in the same ECR namespace, tagged by service and git SHA.

Prerequisites

  • An ECS cluster and task definitions (frontend, backend) already created
  • Two ECR repositories: myapp/frontend, myapp/backend
  • An IAM OIDC role with ECR push and ECS deploy permissions
  • GitHub repository with secrets configured

Repository Structure

.github/workflows/deploy.yml
frontend/Dockerfile
backend/Dockerfile

The GitHub Actions Workflow

name: Build and Deploy to ECS

on:
  push:
    branches: [main]

env:
  AWS_REGION: us-east-1
  ECR_REGISTRY: ${{ secrets.ECR_REGISTRY }}

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push frontend
        env:
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/myapp/frontend:$IMAGE_TAG ./frontend
          docker push $ECR_REGISTRY/myapp/frontend:$IMAGE_TAG

      - name: Build and push backend
        env:
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/myapp/backend:$IMAGE_TAG ./backend
          docker push $ECR_REGISTRY/myapp/backend:$IMAGE_TAG

      - name: Deploy frontend to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: frontend-task-def.json
          service: frontend-service
          cluster: myapp-cluster
          wait-for-service-stability: true

      - name: Deploy backend to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: backend-task-def.json
          service: backend-service
          cluster: myapp-cluster
          wait-for-service-stability: true

OIDC Instead of Static Keys

We use role-to-assume rather than long-lived IAM keys. Static credentials rotated poorly (or not at all) are a genuine liability. OIDC lets GitHub assume a short-lived token—no credentials sitting in secrets.

Setup takes ~15 minutes: configure an OIDC identity provider in IAM and create a role trusting token.actions.githubusercontent.com. Worth it.

Image Tagging with Git SHA

Using github.sha as your tag gives traceability. Every deployment maps to an exact commit. When production breaks, you know what code is running and can rollback instantly.

Never use :latest on production images. It breaks your rollback path and makes debugging harder.

Task Definition File

The amazon-ecs-deploy-task-definition action needs a local JSON file. Generate it once:

aws ecs describe-task-definition \
  --task-definition frontend-task-def \
  --query taskDefinition > frontend-task-def.json

Commit this file. The action updates the image URI and registers a new revision automatically.

Adding a Test Gate

A deployment pipeline without tests is just automated breakage. Add this job before build-and-deploy:

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run backend tests
        run: |
          cd backend
          pip install -r requirements.txt
          pytest

Make build-and-deploy depend on it:

  build-and-deploy:
    needs: test

Failed tests now block deployment. That’s the point.

Environment-Specific Deployments

For staging vs. production, use branch or tag conditions. A common pattern: main deploys to staging; tags deploy to production:

on:
  push:
    branches: [main]        # staging
    tags: ['v*']            # production

Use conditionals on deploy steps to target the right cluster based on github.ref.

What You Get

This pattern handles what actually matters in production: auditability via SHA tagging, security via OIDC, and confidence via a mandatory test gate. It’s simple enough to reason about at 2am when something breaks.

Natural next steps: Docker layer caching for faster builds, Slack notifications, and AWS Secrets Manager instead of environment variables in task definitions.

Get the basics working first. Optimize when you have evidence of what’s slow.