CI/CD Pipeline with GitHub Actions
Automate Docker builds and deployments to AWS ECS with GitHub Actions.
Introduction
30 Second Summary
Every time you change code and want it live, you face a tedious manual cycle: build, tag, push, deploy. Modern engineering teams automate this entire process so that a single git push triggers everything.
In this project, you will build a fully automated CI/CD pipeline using GitHub Actions that builds a Docker container, pushes it to AWS ECR, and deploys it to an ECS Fargate service every time you push code to the main branch.
What You'll Build
A GitHub Actions workflow that automates the Docker build-push-deploy cycle to AWS ECS Fargate, with a live web application that updates automatically on every push to main.
By the end of this project, you'll have:
- A Dockerized web application served by Nginx and running live on AWS ECS Fargate.
- A GitHub Actions workflow that automatically builds, pushes, and deploys your container on every code change.
- AWS infrastructure (ECR, ECS, IAM, networking) configured entirely via CLI commands.
- Secret Mission: Add a pre-deployment verification job that tests your container starts correctly before it reaches production.
Are there any prerequisites?
This project assumes familiarity with Docker (building images and running containers), basic AWS CLI usage (aws configure), and Git/GitHub basics.
You should already have VS Code, Docker Desktop, and the AWS CLI installed and configured with an active AWS Free Tier account.
Set Up AWS Infrastructure
Before you can automate deployments, you need a place to store your Docker images and a service to run your containers.
In this step, you will create the AWS resources that form the foundation of your pipeline. You will set up an image registry, a container cluster, and the networking and permissions that connect them.
In this step, get ready to:
- Create an ECR repository to store your Docker images.
- Create an ECS cluster and a security group for HTTP traffic.
- Verify the IAM role that ECS needs to pull images.
Create the ECR repository
Your CI/CD pipeline will build a new Docker image every time you push code. Those images need a home, and that home is Amazon ECR (Elastic Container Registry).
- Open your terminal in VS Code by pressing Cmd+` (macOS) or Ctrl+` (Windows).
- Create a private ECR repository by running this command:
aws ecr create-repository --repository-name cicd-demo-repo --region us-east-1
What does this command do?
This creates a private container image registry called cicd-demo-repo in the us-east-1 region.
ECR works like Docker Hub, but it lives inside your AWS account. Your pipeline will push images here, and ECS will pull from here when deploying.
You should see JSON output that includes a repositoryUri field. It looks something like 123456789012.dkr.ecr.us-east-1.amazonaws.com/cicd-demo-repo.
- Note down the repositoryUri value from the output. You will use the 12-digit number at the start (your AWS account ID) in later steps.
Seeing an access denied error?
Make sure your AWS CLI is configured with credentials that have ECR permissions. Run aws sts get-caller-identity to confirm you are authenticated.
If you see a region error, verify you have not set a different default region by checking aws configure list.
Help me troubleshoot my ECR repository creation error.
Create the ECS cluster and security group
An ECS cluster is a logical grouping for your containers. You also need a security group that allows web traffic to reach your container on port 80.
- Create the ECS cluster by running this command:
aws ecs create-cluster --cluster-name cicd-demo-cluster --region us-east-1
You should see JSON output containing "clusterName": "cicd-demo-cluster" and a status of ACTIVE.
What is an ECS cluster?
A cluster is a namespace for running tasks and services. The cluster itself costs nothing. You only pay for the compute resources (Fargate tasks) that run inside it.
Next, you need to set up networking so the public internet can reach your container. You will create a security group inside your default VPC.
- Get your default VPC ID by running:
aws ec2 describe-vpcs --filters "Name=isDefault,Values=true" --query "Vpcs[0].VpcId" --output text --region us-east-1
This outputs a single VPC ID like vpc-0abc1234def56789. Copy this value for the next command.
- Create the security group by running this command, replacing the VPC ID placeholder with the value you just copied:
aws ec2 create-security-group --group-name cicd-demo-sg --description "Allow HTTP for CI/CD demo" --vpc-id [[VPC_ID="your VPC ID e.g. vpc-0abc1234"]] --region us-east-1
The output contains a GroupId field (e.g. sg-0abc1234def56789). Copy this value for the next command.
- Add an inbound rule to allow HTTP traffic on port 80 by running:
aws ec2 authorize-security-group-ingress --group-id [[SG_ID="your security group ID e.g. sg-0abc1234"]] --protocol tcp --port 80 --cidr 0.0.0.0/0 --region us-east-1
What does this security group do?
A security group acts as a virtual firewall. This rule allows anyone on the internet (0.0.0.0/0) to connect to your container on port 80 (HTTP).
Without this rule, your container would be running but unreachable from your browser.
- Keep both your VPC ID and security group ID noted somewhere handy. You will need them again in Step 3 when creating the ECS service.
Seeing a VPC or security group error?
If the VPC command returns None, your account may not have a default VPC in us-east-1. You can create one in the AWS Console under VPC > Your VPCs > Actions > Create default VPC.
If the security group command fails with a duplicate name error, you may have already created one called cicd-demo-sg. Find its ID with aws ec2 describe-security-groups --group-names cicd-demo-sg and use that ID for the ingress rule.
Help me troubleshoot my security group creation.
Verify the ECS task execution role
ECS Fargate needs an IAM role to pull images from ECR on your behalf. This role is called ecsTaskExecutionRole and often already exists if you have used ECS before.
- Check if the role exists by running:
aws iam get-role --role-name ecsTaskExecutionRole
✔️ I see role details in the output
The role already exists. You are all set for this substep and can move on.
ⓧ I see a NoSuchEntity error
The role does not exist yet. Create it by running these two commands.
- Create the role with a trust policy that allows ECS tasks to assume it:
aws iam create-role --role-name ecsTaskExecutionRole --assume-role-policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ecs-tasks.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}"
- Attach the managed policy that grants ECR pull permissions:
aws iam attach-role-policy --role-name ecsTaskExecutionRole --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
You should see no error output from either command. That means both succeeded.
What is the task execution role?
When Fargate starts your container, it needs to pull the image from ECR and send logs to CloudWatch. The task execution role gives Fargate permission to do this on your behalf.
Getting an access denied error on role creation?
Your AWS CLI user needs IAM permissions to create roles. If you are using an admin user or a user with the IAMFullAccess policy, this should work. Check your CLI user's permissions in the IAM console.
Help me fix my IAM role creation error.
Your AWS infrastructure is ready. Next up, you will create the web application and Dockerfile that this pipeline will deploy.
Create the Application
Your AWS infrastructure is ready and waiting for a container to run. Now you need to build the actual application that your pipeline will deploy.
In this step, you will create a simple web page served by Nginx, package it into a Docker container, and verify it works locally before sending it to the cloud.
In this step, get ready to:
- Create a project folder with the web page that your pipeline will deploy.
- Write a Dockerfile that packages the web page into a container.
- Build and test the container locally to confirm it works.
Create the project files
You need a project folder on your Desktop to hold the application files. This folder will eventually become a Git repository that connects to GitHub Actions.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir cicd-demo
cd cicd-demo
- Confirm the folder exists by running:
ls ~/Desktop | grep cicd-demo
You should see cicd-demo in the output.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the cicd-demo folder, then click Open.
Why Nginx for this project?
The goal of this project is learning CI/CD, not application development. An Nginx container serving a static HTML page builds in seconds and has zero runtime dependencies.
This keeps your focus entirely on the pipeline mechanics. The same GitHub Actions workflow pattern works with Node.js, Python, Go, or any other app later.
- Create a new file in VS Code by clicking File then New File.
- Name the file index.html and save it inside the cicd-demo folder.
- Paste the following code into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CI/CD Pipeline Demo</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container { text-align: center; padding: 2rem; }
h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
p { font-size: 1.2rem; opacity: 0.9; }
.version {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: rgba(255,255,255,0.2);
border-radius: 4px;
display: inline-block;
}
</style>
</head>
<body>
<div class="container">
<h1>Deployed with GitHub Actions</h1>
<p>This container was automatically built and deployed via CI/CD pipeline.</p>
<div class="version">Version 1.0</div>
</div>
</body>
</html>
What does this code do?
This is a single-page website with a purple gradient background. It displays a heading, a description, and a version number.
The version text is what you will change later to prove your pipeline deploys automatically. When you push a code change to GitHub, the updated version will appear on your live site without any manual steps.
- Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
File saved in the wrong location?
If VS Code saved the file outside your cicd-demo folder, drag it into the correct folder using the VS Code Explorer sidebar on the left. The file must be directly inside cicd-demo/, not in a subfolder.
help me move my index.html file to the right location
✔️ Awesome, I've got everything!
Great. Make sure index.html is saved before moving on.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CI/CD Pipeline Demo</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container { text-align: center; padding: 2rem; }
h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
p { font-size: 1.2rem; opacity: 0.9; }
.version {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: rgba(255,255,255,0.2);
border-radius: 4px;
display: inline-block;
}
</style>
</head>
<body>
<div class="container">
<h1>Deployed with GitHub Actions</h1>
<p>This container was automatically built and deployed via CI/CD pipeline.</p>
<div class="version">Version 1.0</div>
</div>
</body>
</html>
Write the Dockerfile
A Dockerfile tells Docker how to package your application into a container image. Your Dockerfile will copy the HTML page into an Nginx web server image.
- Create a new file in VS Code by clicking File then New File.
- Name the file Dockerfile (no extension) and save it inside the cicd-demo folder.
- Paste the following code into Dockerfile:
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 80
What does this Dockerfile do?
- FROM nginx:alpine starts from a lightweight Nginx image that already knows how to serve web pages.
- COPY index.html /usr/share/nginx/html/index.html places your HTML page where Nginx expects to find it.
- EXPOSE 80 documents that the container listens on port 80 for HTTP traffic.
- Save Dockerfile by pressing Cmd+S (macOS) or Ctrl+S (Windows).
You won't see a change yet. The next substep builds this Dockerfile into a runnable container.
VS Code added a file extension?
If your file is named Dockerfile.txt or Dockerfile.dockerfile, right-click it in the Explorer sidebar and select Rename. Remove any extension so the name is exactly Dockerfile with no dot.
help me fix my Dockerfile name
✔️ Awesome, I've got everything!
Great. Make sure Dockerfile is saved before moving on.
ⓧ I'd like to double check the full code
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 80
Build and test locally
Before deploying to AWS, you should confirm the container works on your own machine. This catches errors early and proves your Dockerfile is correct.
- Build your Docker image by running this command in the terminal (make sure you are inside the cicd-demo folder):
docker build -t cicd-demo .
What does this command do?
- docker build reads the Dockerfile and creates an image.
- -t cicd-demo tags (names) the image so you can reference it later.
- . tells Docker to look for the Dockerfile in the current directory.
- Run the container by executing this command:
docker run -d -p 8080:80 cicd-demo
What does this command do?
- -d runs the container in the background (detached mode).
- -p 8080:80 maps port 8080 on your machine to port 80 inside the container.
- cicd-demo is the image name you tagged during the build.
- Open your web browser and navigate to http://localhost:8080.
You should see a purple gradient page with the heading "Deployed with GitHub Actions" and "Version 1.0" displayed at the bottom.
Don't see the page at localhost:8080?
Make sure Docker Desktop is running. If you see a "connection refused" error, the container may not have started. Run docker ps to check if the container is listed.
If no container is listed, re-run docker build -t cicd-demo . and check for error messages during the build. Verify that index.html and Dockerfile are both saved in the same cicd-demo folder.
help me debug my Docker container not starting
- Stop the container by running this command:
docker stop $(docker ps -q --filter ancestor=cicd-demo)
This finds and stops the running container created from your cicd-demo image.
Your application works locally. Next up, you will push this container to AWS and deploy it to your ECS cluster for the world to see.
Deploy Manually to ECS
Your Docker image works locally. Now it is time to get it running in the cloud on AWS.
Before automating anything, you will deploy the container by hand. This teaches you every command the CI/CD pipeline will eventually run for you. Once you see the full manual process, the automation in the next step will make perfect sense.
In this step, get ready to:
- Push your Docker image to ECR.
- Register a task definition and create an ECS service.
- Verify your app is live on a public IP address.
Push the Docker image to ECR
ECR is a private container registry where AWS services (like ECS) pull your images from. You need to authenticate Docker to your registry before pushing.
- Authenticate Docker to your ECR registry by running this command:
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.us-east-1.amazonaws.com
What does this command do?
This command retrieves a temporary authentication token from AWS and pipes it into docker login. The --password-stdin flag reads the password from the pipe instead of prompting you to type it.
You should see Login Succeeded in your terminal output.
Seeing an authorization error?
Make sure your AWS CLI credentials are configured correctly. Run aws sts get-caller-identity to confirm your account ID matches what you used in the command.
Help me fix my ECR login error.
- Tag your local image with the ECR repository URI by running:
docker tag cicd-demo:latest [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.us-east-1.amazonaws.com/cicd-demo-repo:initial
- Push the tagged image to ECR by running:
docker push [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.us-east-1.amazonaws.com/cicd-demo-repo:initial
What does the :initial tag mean?
The :initial tag is a label you chose for this first version of the image. Later, the CI/CD pipeline will tag images with the Git commit SHA instead. This makes every deployment traceable back to a specific code change.
You should see each layer being pushed, followed by a digest and size confirmation. This means your image is now stored in ECR.
Push hanging or timing out?
Check that Docker Desktop is running. Also confirm your internet connection is stable. Large layers can take a moment to upload on slower connections.
Help me troubleshoot my ECR push.
Register the task definition and create the ECS service
A task definition tells ECS how to run your container. It specifies the image location, CPU and memory allocation, networking mode, and port mappings. Think of it as a recipe card for your container.
- In VS Code, create a new file called task-definition.json in your project folder.
- Paste the following content into task-definition.json:
{
"family": "cicd-demo-task",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::[[AWS_ACCOUNT_ID=\"123456789012\"]]:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "cicd-demo-container",
"image": "[[AWS_ACCOUNT_ID=\"123456789012\"]].dkr.ecr.us-east-1.amazonaws.com/cicd-demo-repo:initial",
"portMappings": [
{
"containerPort": 80,
"protocol": "tcp"
}
],
"essential": true
}
]
}
What does this task definition do?
This JSON file tells ECS Fargate exactly how to run your container:
- "cpu": "256" and "memory": "512" allocate the smallest Fargate size (0.25 vCPU, 0.5 GB RAM).
- "networkMode": "awsvpc" gives the task its own network interface with a public IP.
- "executionRoleArn" grants ECS permission to pull your image from ECR.
- "containerPort": 80 exposes the Nginx port so traffic can reach your app.
- Save task-definition.json.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before continuing.
ⓧ I'd like to double check the full code
{
"family": "cicd-demo-task",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::[[AWS_ACCOUNT_ID=\"123456789012\"]]:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "cicd-demo-container",
"image": "[[AWS_ACCOUNT_ID=\"123456789012\"]].dkr.ecr.us-east-1.amazonaws.com/cicd-demo-repo:initial",
"portMappings": [
{
"containerPort": 80,
"protocol": "tcp"
}
],
"essential": true
}
]
}
- Register the task definition with ECS by running:
aws ecs register-task-definition --cli-input-json file://task-definition.json --region us-east-1
What does registering do?
Registering uploads your task definition to AWS so ECS knows how to launch your container. The --cli-input-json file:// flag tells the CLI to read the JSON from your local file.
You should see a JSON response containing your full task definition with a revision number of 1.
Seeing a ClientException or validation error?
Double-check that you replaced YOUR_ACCOUNT_ID with your actual 12-digit AWS account ID in task-definition.json. Also confirm the file is saved and you are running the command from the same folder.
Help me fix my task definition registration error.
Now you need a public subnet ID from your default VPC so ECS can assign a public IP to your task.
- Get a public subnet ID by running:
aws ec2 describe-subnets --filters "Name=vpc-id,Values=[[VPC_ID="vpc-0abc1234"]]" "Name=default-for-az,Values=true" --query "Subnets[0].SubnetId" --output text --region us-east-1
Copy the subnet ID from the output. It will look like subnet-0abc1234def56789.
- Create the ECS service by running this command:
aws ecs create-service --cluster cicd-demo-cluster --service-name cicd-demo-service --task-definition cicd-demo-task --desired-count 1 --launch-type FARGATE --network-configuration "awsvpcConfiguration={subnets=[[[SUBNET_ID="subnet-0abc1234"]]],securityGroups=[[[SG_ID="sg-0abc1234"]]],assignPublicIp=ENABLED}" --region us-east-1
What does this service do?
An ECS service keeps your task running continuously. If the container crashes, the service automatically restarts it. The --desired-count 1 flag means ECS will maintain exactly one running copy of your container.
The assignPublicIp=ENABLED setting gives your task a public IP address so you can reach it from your browser.
You should see a JSON response with your service details, including "status": "ACTIVE".
Service creation failed?
Confirm your subnet ID and security group ID are correct. They must belong to the same VPC. Also check that ecsTaskExecutionRole exists with the correct policy attached.
Help me fix my ECS service creation error.
Verify the live application
Your task takes 1-2 minutes to start. Once it is running, you can find its public IP address through a chain of AWS CLI commands.
- List the running tasks in your service by running:
aws ecs list-tasks --cluster cicd-demo-cluster --service-name cicd-demo-service --region us-east-1
Copy the task ARN from the output. It is the long string starting with arn:aws:ecs:.
- Get the network interface ID from your task by running:
aws ecs describe-tasks --cluster cicd-demo-cluster --tasks [[TASK_ARN="arn:aws:ecs:us-east-1:123456789012:task/cicd-demo-cluster/abc123"]] --region us-east-1 --query "tasks[0].attachments[0].details[?name=='networkInterfaceId'].value" --output text
Copy the ENI ID from the output. It will look like eni-0abc1234def56789.
- Get the public IP address from the network interface by running:
aws ec2 describe-network-interfaces --network-interface-ids [[ENI_ID="eni-0abc1234"]] --query "NetworkInterfaces[0].Association.PublicIp" --output text --region us-east-1
- Copy the IP address from the output.
- Paste it into your browser's address bar and press Enter.
You should see the same purple gradient page you tested locally, with the heading "Deployed with GitHub Actions" and "Version 1.0" displayed.
Page not loading?
If you see a timeout, wait another minute and try again. Fargate tasks can take up to 2 minutes to start. Also confirm your security group allows inbound traffic on port 80.
If the list-tasks command returns an empty list, your task may have failed to start. Run aws ecs describe-services --cluster cicd-demo-cluster --services cicd-demo-service --region us-east-1 and check the events array for error messages.
Help me debug why my ECS task is not starting.
Why deploy manually first?
You just ran about 6 commands to authenticate, push, register, and deploy. Every single one of these steps will be automated by the GitHub Actions workflow in the next step. Understanding the manual process means you will know exactly what the pipeline does behind the scenes.
Your app is live on AWS. Next up, you will automate this entire deployment process with GitHub Actions so that a single git push replaces all of these manual steps.
Set Up GitHub Actions CI/CD
You proved the deployment works manually in the previous step. Now it is time to automate it so you never have to run those commands again.
GitHub Actions is a CI/CD platform built directly into GitHub. You will define a workflow file that tells GitHub Actions exactly what to do every time code arrives on the main branch: build the Docker image, push it to ECR, and deploy it to ECS.
In this step, get ready to:
- Push your project to a new GitHub repository.
- Add your AWS credentials as GitHub Secrets.
- Create a GitHub Actions workflow that automates the entire deploy process.
Push code to GitHub
Your code lives only on your local machine right now. To trigger automated workflows, it needs to be hosted in a GitHub repository.
- Open GitHub in your browser and sign in to your account.
- In the upper-right corner, click the + menu, then click New repository.
- Type cicd-ecs-pipeline in the Repository name field.
- Select Public for the repository visibility.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
Why no README?
You already have files locally. Initializing with a README would create a commit on GitHub that conflicts with your local history. Keeping the repo empty lets you push your existing code cleanly.
- Switch back to your terminal in VS Code. Make sure you are inside your project folder (the one containing index.html, Dockerfile, and task-definition.json).
- Initialize a git repository and push your code by running these commands:
git init
git add .
git commit -m "Initial commit: app + task definition"
git branch -M main
git remote add origin https://github.com/[[YOUR_USERNAME="your GitHub username"]]/cicd-ecs-pipeline.git
git push -u origin main
What do these commands do?
- git init turns your folder into a git repository.
- git add . stages all your files for commit.
- git branch -M main renames the default branch to main.
- git remote add origin connects your local repo to the GitHub remote.
- git push -u origin main uploads your code to GitHub.
- Refresh your GitHub repository page in the browser. You should see index.html, Dockerfile, and task-definition.json listed.
Push rejected or authentication error?
- If you see a permission denied error, make sure you have authenticated with GitHub. You may need a personal access token or SSH key configured.
- If the push is rejected because the remote already has commits, you likely initialized with a README. Delete the repository on GitHub, recreate it with no initializations, and try again.
Why can't I push to my GitHub repository?
Add AWS secrets to GitHub
The workflow needs your AWS credentials to authenticate with ECR and ECS. GitHub Secrets encrypt these values so they never appear in logs or code.
- On your GitHub repository page, click the Settings tab.
- In the left sidebar under Security, click Secrets and variables, then click Actions.
- Click the Secrets tab.
- Click New repository secret.
- In the Name field, type AWS_ACCESS_KEY_ID.
- In the Secret field, paste your AWS access key ID.
- Click Add secret.
- Click New repository secret again to add the second secret.
- In the Name field, type AWS_SECRET_ACCESS_KEY.
- In the Secret field, paste your AWS secret access key.
- Click Add secret.
Where do I find my AWS access keys?
These are the same credentials you used when you ran aws configure earlier. If you need to retrieve them, check your ~/.aws/credentials file or create a new access key pair in the AWS IAM console.
- You should now see both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY listed on the Actions secrets page.
Don't see the Secrets and variables option?
Make sure you are clicking Settings on your repository page (not your account settings). The Secrets and variables option appears under the Security section in the left sidebar.
Why can't I find the repository secrets setting?
Create the workflow file
The workflow file is the heart of your pipeline. It defines every step that runs automatically when you push to main.
First, you need to update task-definition.json so the workflow can inject the correct image at deploy time.
- In VS Code, open task-definition.json.
- Find the image field inside containerDefinitions. It currently looks like this:
"image": "YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/cicd-demo-repo:initial"
- Replace the entire image URI with PLACEHOLDER, so the line now looks like this:
"image": "PLACEHOLDER"
Why use a placeholder?
The GitHub Actions workflow uses the amazon-ecs-render-task-definition action to inject the real image URI at deploy time. It reads this file, replaces the image value with the freshly-built image tag, and registers the updated task definition. The placeholder value does not matter because it always gets overwritten.
- Save task-definition.json.
✔️ Awesome, I've got everything!
Great. Double check you have saved task-definition.json with PLACEHOLDER as the image value.
ⓧ I'd like to double check the full code
{
"family": "cicd-demo-task",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "cicd-demo-container",
"image": "PLACEHOLDER",
"portMappings": [
{
"containerPort": 80,
"protocol": "tcp"
}
],
"essential": true
}
]
}
Now create the workflow file. GitHub Actions looks for YAML files in a specific directory structure.
- Create the workflow directory by running this command:
mkdir -p .github/workflows
- Create the workflow file .github/workflows/deploy.yml and paste the following content:
name: Deploy to Amazon ECS
on:
push:
branches:
- main
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: cicd-demo-repo
ECS_SERVICE: cicd-demo-service
ECS_CLUSTER: cicd-demo-cluster
ECS_TASK_DEFINITION: task-definition.json
CONTAINER_NAME: cicd-demo-container
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
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, tag, and push image to Amazon ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ${{ env.ECS_TASK_DEFINITION }}
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.build-image.outputs.image }}
- name: Deploy Amazon ECS task definition
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
What does this workflow do?
The workflow triggers on every push to the main branch. It runs a single job called deploy on a fresh Ubuntu runner.
- Checkout pulls your repository code onto the runner.
- Configure AWS credentials uses your GitHub Secrets to authenticate with AWS.
- Login to Amazon ECR authenticates Docker on the runner so it can push images.
- Build, tag, and push builds your Docker image and tags it with the git commit SHA for traceability.
- Render task definition injects the new image URI into your task-definition.json file.
- Deploy registers the updated task definition and updates the ECS service, waiting until the new task is healthy.
Why tag with the commit SHA?
Using ${{ github.sha }} as the image tag means every deployment maps to a specific commit. If something breaks, you can trace the exact code that was deployed and roll back to a previous image.
- Save .github/workflows/deploy.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file at .github/workflows/deploy.yml.
ⓧ I'd like to double check the full code
name: Deploy to Amazon ECS
on:
push:
branches:
- main
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: cicd-demo-repo
ECS_SERVICE: cicd-demo-service
ECS_CLUSTER: cicd-demo-cluster
ECS_TASK_DEFINITION: task-definition.json
CONTAINER_NAME: cicd-demo-container
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
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, tag, and push image to Amazon ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ${{ env.ECS_TASK_DEFINITION }}
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.build-image.outputs.image }}
- name: Deploy Amazon ECS task definition
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
Now commit and push everything to trigger the pipeline for the first time.
- Commit and push the workflow file by running these commands:
git add .
git commit -m "Add GitHub Actions CI/CD workflow"
git push origin main
- Open your GitHub repository in the browser.
- Click the Actions tab at the top of the repository.
- You should see a workflow run named Add GitHub Actions CI/CD workflow in progress (shown with a yellow spinning icon).
No workflow appearing in the Actions tab?
- Confirm that .github/workflows/deploy.yml exists in your repo by checking the file list on your repository's main page.
- Make sure you pushed to the main branch. The workflow only triggers on pushes to main.
- Check that the YAML file has no indentation errors. YAML is sensitive to spacing.
Why isn't my GitHub Actions workflow running?
Your pipeline is live. Next up, you will make a visible change to the app and watch the entire cycle run end-to-end.
Trigger and Verify Automated Deployment
Your pipeline is fully wired up. The workflow is configured to trigger on every push to the main branch, and your AWS secrets are in place.
Now it's time for the real test. You'll make a visible change to your application, push it to GitHub, and watch the pipeline handle building, pushing, and deploying automatically.
In this step, get ready to:
- Make a visible code change to your application.
- Push the change and watch GitHub Actions deploy it automatically.
- Verify the update is live on your public IP.
Make a visible change
To prove the pipeline works end-to-end, you need a change you can actually see in the browser. You'll update the heading and version number in your HTML.
- In VS Code, open index.html.
- Find these three lines inside the <div class="container"> section:
<h1>Deployed with GitHub Actions</h1>
<p>This container was automatically built and deployed via CI/CD pipeline.</p>
<div class="version">Version 1.0</div>
- Replace those three lines with the following:
<h1>Auto-Deployed with CI/CD</h1>
<p>This update was deployed automatically by pushing to main.</p>
<div class="version">Version 2.0</div>
- Save index.html.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CI/CD Pipeline Demo</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container { text-align: center; padding: 2rem; }
h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
p { font-size: 1.2rem; opacity: 0.9; }
.version {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: rgba(255,255,255,0.2);
border-radius: 4px;
display: inline-block;
}
</style>
</head>
<body>
<div class="container">
<h1>Auto-Deployed with CI/CD</h1>
<p>This update was deployed automatically by pushing to main.</p>
<div class="version">Version 2.0</div>
</div>
</body>
</html>
Push and watch the pipeline
Now you'll commit your change and push it to GitHub. The moment your push hits the main branch, the workflow triggers automatically.
- Commit and push your changes by running these commands:
git add .
git commit -m "Update to v2 - testing CI/CD pipeline"
git push origin main
What does this trigger?
Your workflow file has on: push: branches: - main as its trigger. Every time code lands on the main branch, GitHub Actions spins up a fresh runner and executes the pipeline.
- Open your repository on GitHub in your browser.
- Click the **Actions** tab at the top of the repository page.
- You should see a new workflow run in progress with the commit message Update to v2 - testing CI/CD pipeline.
- Click on the workflow run to watch each step execute in real time: Checkout, Configure AWS credentials, Login to Amazon ECR, Build tag and push, Fill in the new image ID, and Deploy.
How long does the workflow take?
The full pipeline typically runs in 2-4 minutes. The wait-for-service-stability setting in the deploy step means the workflow waits for ECS to confirm the new task is running healthy before marking the job as complete.
Workflow failed?
Click the failed step in the workflow run to see the error log. Common causes:
- If the "Configure AWS credentials" step failed, double check that your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets are saved correctly in your repository settings.
- If the "Deploy" step timed out, your ECS service may not have stabilized. Check that your security group still allows port 80 inbound.
Help me debug my failed GitHub Actions workflow
Verify the update is live
Once the workflow shows a green checkmark, ECS has swapped in the new task running your updated image. The public IP may change when ECS replaces the task, so you may need to look it up again.
- Wait about 1 minute after the workflow completes for ECS to finish the swap.
- Find the current task ARN by running:
aws ecs list-tasks --cluster cicd-demo-cluster --service-name cicd-demo-service --region us-east-1
- Get the network interface ID from the task by running this command (replace YOUR_TASK_ARN with the ARN from the previous output):
aws ecs describe-tasks --cluster cicd-demo-cluster --tasks [[TASK_ARN="your task ARN from the previous command"]] --region us-east-1 --query "tasks[0].attachments[0].details[?name=='networkInterfaceId'].value" --output text
- Get the public IP from the network interface (replace YOUR_ENI_ID with the ID from the previous output):
aws ec2 describe-network-interfaces --network-interface-ids [[ENI_ID="your network interface ID from the previous command"]] --query "NetworkInterfaces[0].Association.PublicIp" --output text --region us-east-1
- Open the public IP address in your browser.
- You should see the purple gradient page with the heading "Auto-Deployed with CI/CD" and "Version 2.0" displayed.
What just happened?
You changed three lines of HTML and pushed to GitHub. Without any manual intervention, the pipeline built a new Docker image, pushed it to ECR, updated the task definition, and told ECS to deploy the new version.
This is the exact workflow modern engineering teams use to ship code multiple times per day. The entire build-push-deploy cycle that you did manually in Step 3 now happens on every single push.
Still seeing Version 1.0?
- Make sure the workflow completed successfully (green checkmark in the Actions tab). If it's still running, wait for it to finish.
- The public IP often changes when ECS replaces the task. Use the commands above to get the new IP instead of reusing the old one.
- Try a hard refresh in your browser (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows) to bypass cached content.
Help me debug why my ECS deployment isn't showing the updated version
Secret mission
Add Pre-Deployment Container Verification
Your pipeline deploys whatever you push, even if the container is broken. Add a verification job that tests whether the container actually starts and responds before it reaches production.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The ECS Fargate task incurs per-second charges while running, so consider pausing or deleting if you are done experimenting.
Cost warning
Your ECS Fargate task (0.25 vCPU, 0.5 GB memory) costs approximately $0.01/hour while running. Left running 24/7 for a month, that adds up to roughly $7-8. This is likely covered by your AWS Free Tier credits, but you should still pause or delete the service when you are done.
Resources you used:
- ECR repository (cicd-demo-repo): stores your Docker images. 500 MB free for 12 months.
- ECS cluster (cicd-demo-cluster): no charge by itself.
- ECS service (cicd-demo-service): runs one Fargate task, billed per-second for vCPU and memory while running.
- Security group (cicd-demo-sg): no charge.
- IAM role (ecsTaskExecutionRole): no charge.
- GitHub repository (cicd-ecs-pipeline): free for public repos.
- Local project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep the live demo running to show interviewers.
- Monitor your AWS billing dashboard to keep an eye on Fargate costs.
- Your GitHub Actions workflow will continue to deploy on every push to main.
- The ecsTaskExecutionRole stays in your AWS account. It may be reused by other ECS services you create later.
Pause - I'll come back to this later
Stop billing by scaling the service to zero tasks. Your infrastructure stays in place so you can restart with a single command.
- Set the desired task count to 0 by running:
aws ecs update-service --cluster cicd-demo-cluster --service cicd-demo-service --desired-count 0 --region us-east-1
- To restart later, set the desired count back to 1:
aws ecs update-service --cluster cicd-demo-cluster --service cicd-demo-service --desired-count 1 --region us-east-1
- Your ECR images, cluster, security group, and GitHub repository all remain intact at no cost while the task is stopped.
Delete - I don't want to use this again
Remove all project resources and start fresh. Run each of the following commands in order.
- Scale the service to zero and delete it:
aws ecs update-service --cluster cicd-demo-cluster --service cicd-demo-service --desired-count 0 --region us-east-1
aws ecs delete-service --cluster cicd-demo-cluster --service cicd-demo-service --force --region us-east-1
- Delete the ECS cluster:
aws ecs delete-cluster --cluster-name cicd-demo-cluster --region us-east-1
- Delete the ECR repository (this removes all stored images):
aws ecr delete-repository --repository-name cicd-demo-repo --force --region us-east-1
- Delete the security group:
aws ec2 delete-security-group --group-id [[SG_ID="your security group ID from Step 1"]] --region us-east-1
- Delete the IAM role (detach the policy first, then delete the role):
aws iam detach-role-policy --role-name ecsTaskExecutionRole --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
aws iam delete-role --role-name ecsTaskExecutionRole
Should I delete ecsTaskExecutionRole?
Only delete this role if you are sure no other ECS services in your account depend on it. If you plan to use ECS again, keep the role. It costs nothing.
- Delete the GitHub repository by going to your repository page, then Settings, scroll to the bottom, and click Delete this repository.
- Delete the local project folder:
macOS
rm -rf ~/Desktop/cicd-ecs-pipeline
Windows
rmdir /s /q %USERPROFILE%\Desktop\cicd-ecs-pipeline
- Remove the GitHub Actions secrets from your repository (if you haven't deleted the repo). Go to Settings > Secrets and variables > Actions and delete AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully automated CI/CD pipeline that deploys a containerized application to AWS ECS Fargate every time you push code to GitHub.
You've learned how to:
- Built a containerized web application with Docker and deployed it to AWS ECS Fargate.
- Created a complete CI/CD pipeline using GitHub Actions that automates build, push, and deploy on every code change.
- Configured AWS infrastructure (ECR, ECS, IAM, networking) entirely via CLI commands.
- Proved the pipeline works end-to-end by making a code change and watching it go live automatically.
- Secret Mission: Added pre-deployment container verification to prevent broken deployments from reaching production.
Ready to quiz yourself?