Docker Multi-Stage Builds: A Practical Guide to Smaller, Faster Production Images

Introduction

Continuous Integration and Continuous Deployment (CI/CD) is the backbone of modern software delivery. In this tutorial, you will learn how to build a fully automated, production-ready CI/CD pipeline using GitHub Actions for CI/CD orchestration, Docker for containerization, and AWS Elastic Container Service (ECS) for deployment. By the end, you will have a pipeline that automatically builds, tests, and deploys your application whenever you push to the main branch.

Prerequisites

Before you begin, ensure you have the following:

  • An AWS account with appropriate IAM permissions for ECS, ECR, and CloudFormation
  • A GitHub repository with your application code
  • Docker installed locally (for testing)
  • AWS CLI installed and configured on your local machine
  • Basic familiarity with YAML syntax and shell scripting

Architecture Overview

Our pipeline consists of the following stages:

  1. Code Push — Developer pushes code to the main branch
  2. Build — GitHub Actions builds a Docker image
  3. Test — Automated tests run inside the container
  4. Push — The image is pushed to Amazon ECR
  5. Deploy — ECS Fargate service is updated with the new image
  6. Verify — Health checks confirm the deployment succeeded

💡 Tip: This pattern — “build once, deploy everywhere” — ensures the exact same artifact that passes tests in CI is what gets deployed to production.

Step 1: Create a Dockerized Application

Create a simple Flask application at the root of your repository. This will serve as our deployable service.

1.1 — app.py

from flask import Flask, jsonify
import os

app = Flask(__name__)

@app.route('/')
def home():
    return jsonify({
        "message": "Hello from Nova Tech Cloud!",
        "version": os.getenv("APP_VERSION", "1.0.0")
    })

@app.route('/health')
def health():
    return jsonify({"status": "healthy"}), 200

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

1.2 — requirements.txt

flask==3.1.1
gunicorn==23.0.0

1.3 — Dockerfile

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 8080

CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]

Test the application locally:

docker build -t my-app:latest .
docker run -p 8080:8080 my-app:latest
curl http://localhost:8080/health

💡 Tip: Use gunicorn instead of the Flask development server in production — it handles concurrent connections far more efficiently.

Step 2: Set Up AWS ECR and ECS

We will deploy using AWS Fargate (serverless container orchestration). Let’s create the infrastructure.

2.1 — Create ECR Repository

aws ecr create-repository \
  --repository-name my-app-repo \
  --region eu-west-1

2.2 — Create ECS Cluster

aws ecs create-cluster \
  --cluster-name my-app-cluster \
  --region eu-west-1

2.3 — Register a Task Definition

Create a file called task-definition.json:

{
  "family": "my-app-task",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512",
  "executionRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskExecutionRole",
  "containerDefinitions": [
    {
      "name": "my-app-container",
      "image": "YOUR_ACCOUNT_ID.dkr.ecr.eu-west-1.amazonaws.com/my-app-repo:latest",
      "portMappings": [{ "containerPort": 8080, "protocol": "tcp" }],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/my-app",
          "awslogs-region": "eu-west-1",
          "awslogs-stream-prefix": "ecs"
        }
      }
    }
  ]
}

Register it:

aws ecs register-task-definition \
  --cli-input-json file://task-definition.json \
  --region eu-west-1

⚠️ Note: Replace YOUR_ACCOUNT_ID with your actual AWS account ID. The ecsTaskExecutionRole must exist in your account — if not, create it with the AmazonECSTaskExecutionRolePolicy managed policy attached.

2.4 — Create an ECS Service

You need a VPC with at least one public subnet and a security group allowing inbound traffic on port 8080.

aws ecs create-service \
  --cluster my-app-cluster \
  --service-name my-app-service \
  --task-definition my-app-task \
  --desired-count 2 \
  --launch-type FARGATE \
  --network-configuration \
    "awsvpcConfiguration={
      subnets=['subnet-XXXXXX'],
      securityGroups=['sg-XXXXXX'],
      assignPublicIp='ENABLED'
    }" \
  --region eu-west-1

Step 3: Configure GitHub Repository Secrets

In your GitHub repository, go to Settings → Secrets and variables → Actions and add the following repository secrets:

Secret Name Description
AWS_ACCESS_KEY_ID Your AWS IAM user access key
AWS_SECRET_ACCESS_KEY Your AWS IAM user secret key
AWS_REGION eu-west-1 (or your chosen region)
ECR_REPOSITORY my-app-repo
ECS_CLUSTER my-app-cluster
ECS_SERVICE my-app-service

🔒 Security Best Practice: Create an IAM user with the least-privilege policy. Attach only AmazonEC2ContainerRegistryPowerUser and a custom ECS deployment policy. Never use root credentials.

Step 4: Create the GitHub Actions Workflow

Create the file .github/workflows/deploy.yml in your repository:

name: Build, Test, and Deploy to ECS

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

env:
  AWS_REGION: ${{ secrets.AWS_REGION }}
  ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
  ECS_CLUSTER: ${{ secrets.ECS_CLUSTER }}
  ECS_SERVICE: ${{ secrets.ECS_SERVICE }}
  IMAGE_TAG: ${{ github.sha }}

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install Dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run Tests
        run: |
          python -c "from app import app; assert app.test_client().get('/health').status_code == 200"
          echo "✅ All tests passed!"

  build-and-push:
    name: Build and Push Docker Image
    needs: test
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.build-image.outputs.image }}
    steps:
      - name: Checkout Code
        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 Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and Tag Docker Image
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT

      - name: Push Docker Image to ECR
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest

  deploy:
    name: Deploy to ECS Fargate
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - 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: Download Task Definition
        run: |
          aws ecs describe-task-definition \
            --task-definition my-app-task \
            --query taskDefinition \
            --region $AWS_REGION \
            > task-def.json

      - name: Render New Task Definition
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: task-def.json
          container-name: my-app-container
          image: ${{ needs.build-and-push.outputs.image }}

      - name: Deploy to Amazon 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

Step 5: Understanding the Workflow

Let’s break down what each job does:

Job 1: test

Runs on every push and pull request. It validates the application code by installing dependencies and running a basic health-check assertion. In a real project, you would run your full test suite here.

Job 2: build-and-push

Executes only after tests pass. It authenticates to AWS ECR, builds the Docker image with two tags (the commit SHA and latest), then pushes both tags to the ECR repository. The image name is passed to the next job via the outputs mechanism.

Job 3: deploy

Fetches the current task definition, replaces the container image with the newly built one, then updates the ECS service. The wait-for-service-stability: true flag ensures the workflow waits until the new tasks are healthy before reporting success.

Step 6: Verify the Deployment

Once the pipeline completes, find the public IP or DNS name of your ECS tasks:

aws ecs list-tasks \
  --cluster my-app-cluster \
  --service my-app-service \
  --region eu-west-1

Describe one of the running tasks to get the public IP:

aws ecs describe-tasks \
  --cluster my-app-cluster \
  --tasks YOUR_TASK_ARN \
  --region eu-west-1

Then curl the endpoint:

curl http://PUBLIC_IP:8080/

You should see:

{"message":"Hello from Nova Tech Cloud!","version":"1.0.0"}

Step 7: Add Rollback Capabilities

Deployments can fail. Add a rollback mechanism to your pipeline by appending a rollback step that runs on failure:

rollback:
  name: Rollback on Failure
  if: failure()
  needs: deploy
  runs-on: ubuntu-latest
  steps:
    - name: Rollback ECS Service
      run: |
        aws ecs update-service \
          --cluster ${{ env.ECS_CLUSTER }} \
          --service ${{ env.ECS_SERVICE }} \
          --force-new-deployment \
          --region ${{ env.AWS_REGION }}

💡 Tip: For more sophisticated rollbacks, consider registering a my-app-task:previous revision before deploying so you can explicitly revert to the known-good task definition. Tools like AWS CodeDeploy support blue/green deployments natively with built-in rollback.

Best Practices

  • Tag images with commit SHAs — Never rely solely on the latest tag. The SHA allows precise rollbacks and traceability.
  • Use OIDC instead of static keys — GitHub Actions supports OpenID Connect for AWS. This eliminates the need to store long-lived AWS_SECRET_ACCESS_KEY in secrets. Configure an IAM identity provider trust policy for your GitHub repository.
  • Run security scans — Integrate docker scout or trivy in your pipeline to scan for vulnerabilities in your container image before deployment.
  • Implement health check endpoints — ECS uses these to decide if your container is healthy. Keep them fast and lightweight.
  • Set deployment circuit breakers — ECS can automatically roll back if a deployment fails to stabilize. Enable this in the service configuration.

Conclusion

In this tutorial, you built a fully automated CI/CD pipeline with GitHub Actions, Docker, and AWS ECS Fargate. Your application is now containerized, tested automatically on every push, and deployed to a scalable, serverless compute environment. This pattern — GitHub Actions → ECR → ECS Fargate — is battle-tested at companies of every scale and forms a solid foundation for modern cloud-native delivery.

Next steps:

  • Add a staging environment with manual approval gates
  • Instrument your application with OpenTelemetry for observability
  • Implement feature flags to decouple deployment from release
  • Explore Amazon ECS Service Connect for service-to-service networking

📚 Further Reading:

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *