Building Production-Ready CI/CD Pipelines with GitHub Actions and AWS ECS
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:
- Code Push — Developer pushes code to the
mainbranch - Build — GitHub Actions builds a Docker image
- Test — Automated tests run inside the container
- Push — The image is pushed to Amazon ECR
- Deploy — ECS Fargate service is updated with the new image
- 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
gunicorninstead 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_IDwith your actual AWS account ID. TheecsTaskExecutionRolemust exist in your account — if not, create it with theAmazonECSTaskExecutionRolePolicymanaged 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
AmazonEC2ContainerRegistryPowerUserand 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:previousrevision 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
latesttag. 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_KEYin secrets. Configure an IAM identity provider trust policy for your GitHub repository. - Run security scans — Integrate
docker scoutortrivyin 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: