Auto-Deploy Containers to AWS ECS
Build a CI/CD pipeline that auto-deploys Docker containers to ECS Fargate.
Introduction
30 Second Summary
Every time a developer finishes writing code, someone has to get it running on a live server. Doing this manually is slow, error-prone, and a bottleneck that real engineering teams eliminated years ago.
In this project, you will build a fully automated pipeline that takes your code from a Git push all the way to a live, publicly accessible application with zero manual steps in between.
What You'll Build
A complete CI/CD pipeline using GitHub Actions that automatically builds a Docker container and deploys it to AWS ECS Fargate every time you push code to your main branch.
By the end of this project, you'll have:
- A containerized Node.js app with a Dockerfile, stored in Amazon ECR and running on ECS Fargate with a public IP.
- A GitHub Actions workflow that automates the entire build, push, and deploy cycle on every commit to main.
- A live, auto-updating web application that proves you understand the full delivery cycle from commit to production.
- Secret Mission: Add a health check endpoint and configure ECS to automatically recover unhealthy containers.
Are there any prerequisites?
You need an AWS account with the AWS CLI configured, Docker installed on your machine, and a GitHub account. Step 1 walks you through verifying each of these.
Set Up AWS Infrastructure
Before you can build an automated pipeline, you need the cloud infrastructure to receive your container images and run them.
In this step, you will create the AWS resources that form the backbone of your CI/CD pipeline. This includes a container registry to store your Docker images, a cluster to run them, and an IAM user that gives GitHub Actions permission to deploy on your behalf.
In this step, get ready to:
- Confirm your AWS CLI is authenticated and Docker is running.
- Create an ECR repository and ECS cluster.
- Create an IAM user and store its credentials as GitHub secrets.
Confirm your AWS identity and Docker
Your AWS CLI and Docker are already installed. Let's confirm they can reach your account and run containers before creating any resources.
- Verify your AWS credentials are working by running this command:
aws sts get-caller-identity
What does this command do?
This command asks AWS to report back the account, user ARN, and user ID associated with your configured credentials. If it returns a JSON response, your CLI is properly authenticated.
- You should see a JSON response containing your Account, UserId, and Arn.
Seeing an error instead of JSON?
If you see a credentials error, run aws configure and re-enter your AWS Access Key ID, Secret Access Key, and default region (us-east-1).
Help me troubleshoot my AWS CLI credentials
- Confirm Docker is running by checking its version:
docker --version
- You should see a version number in the output, confirming Docker is active.
Create the ECR repository and ECS cluster
Amazon ECR (Elastic Container Registry) stores your Docker images. Amazon ECS (Elastic Container Service) runs containers from those images. You need one of each.
- Create your ECR repository by running this command:
aws ecr create-repository --repository-name cicd-pipeline-app --region us-east-1
What does this command do?
This creates a private container registry called cicd-pipeline-app in the us-east-1 region. Your GitHub Actions workflow will push Docker images here on every commit.
- You should see a JSON response containing the repositoryUri field. Copy and save this value for later.
- Create your ECS cluster by running this command:
aws ecs create-cluster --cluster-name cicd-pipeline-cluster
What does this command do?
This creates an ECS cluster called cicd-pipeline-cluster. A cluster is a logical grouping where your containers will run. It is the home base for your deployed application.
- Confirm the cluster status is ACTIVE in the JSON response.
- Verify both resources exist by running these commands:
aws ecr describe-repositories --repository-names cicd-pipeline-app
aws ecs describe-clusters --clusters cicd-pipeline-cluster
- The first command should show your cicd-pipeline-app repository. The second should show cicd-pipeline-cluster with status ACTIVE.
Repository or cluster not found?
Double check that you included --region us-east-1 in the ECR command. If your default region is different, the repository may have been created in another region.
Help me troubleshoot my ECR or ECS resources
Create an IAM user for GitHub Actions
GitHub Actions needs AWS credentials to push images to ECR and deploy to ECS. You will create a dedicated IAM user with only the permissions the workflow needs.
- Create the IAM user by running this command:
aws iam create-user --user-name github-actions-ecs
- You should see a JSON response with the new user's ARN.
Now you need to give this user permissions for ECR and ECS operations. You will attach an inline policy that grants the minimum required access.
- Attach the ECR and ECS permissions by running this command:
aws iam put-user-policy \
--user-name github-actions-ecs \
--policy-name GitHubActionsECSPolicy \
--policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:CompleteLayerUpload",
"ecr:UploadLayerPart",
"ecr:InitiateLayerUpload",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer"
],
"Resource": "arn:aws:ecr:us-east-1:*:repository/cicd-pipeline-app"
},
{
"Effect": "Allow",
"Action": "ecr:GetAuthorizationToken",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"ecs:DescribeServices",
"ecs:UpdateService",
"ecs:DescribeTaskDefinition",
"ecs:RegisterTaskDefinition"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "*"
}
]
}'
What does this policy grant?
The first statement allows pushing Docker image layers to your specific ECR repository. The second grants the token needed to log in to ECR.
The third statement lets the user update ECS services and register new task definitions. The fourth allows passing IAM roles to ECS tasks, which is required when ECS deploys your container.
- Generate access keys for the user by running this command:
aws iam create-access-key --user-name github-actions-ecs
Save these credentials immediately
The output contains AccessKeyId and SecretAccessKey. Copy both values and store them somewhere safe. The secret access key is only shown once. If you lose it, you must delete the key and create a new one.
Now store these credentials as GitHub repository secrets so your workflow can use them.
- Open your GitHub repository in the browser.
- Click Settings in the repository navigation bar. If you cannot see the Settings tab, click the dropdown menu (three dots) and select Settings from there.
- In the Security section of the sidebar, select 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 the AccessKeyId value from the command output.
- Click Add secret.
- Click New repository secret again.
- In the Name field, type AWS_SECRET_ACCESS_KEY.
- In the Secret field, paste the SecretAccessKey value from the command output.
- Click Add secret.
Why store credentials as GitHub secrets?
GitHub encrypts secrets before storing them. Your workflow can access the values at runtime through the ${{ secrets.AWS_ACCESS_KEY_ID }} syntax without ever exposing them in logs or code.
- Confirm you see both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY listed on the repository secrets page.
Don't see the Settings tab?
You need write access to the repository to manage secrets. If you are using a forked repository, make sure you are the owner of the fork.
Help me find the Settings tab or Secrets section in my GitHub repository
Your AWS infrastructure is ready. You have a container registry, a running cluster, and a set of credentials safely stored in GitHub. Next up, you will build the application and its Dockerfile.
Build the App and Dockerfile
Your AWS infrastructure is in place. You have an ECR repository ready to store container images and an ECS cluster waiting to run them.
Now you need an application to push through the pipeline. In this step, you'll create a simple Node.js web app, write a Dockerfile to containerize it, and verify the container runs locally before deploying it to the cloud.
In this step, get ready to:
- Create a Node.js Express app that serves JSON data.
- Write a Dockerfile to package the app as a container image.
- Build and test the container locally.
Create the Node.js Express application
Express is a lightweight web framework for Node.js. Your app will serve a simple JSON response that includes the app name, version, and a timestamp.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir cicd-pipeline-app
cd cicd-pipeline-app
- Confirm the folder was created by running:
ls ~/Desktop | grep cicd-pipeline-app
You should see cicd-pipeline-app in the output.
- Open the cicd-pipeline-app folder in your preferred code editor.
- Create a new file called package.json in the root of your cicd-pipeline-app folder and add the following content:
{
"name": "cicd-pipeline-app",
"version": "1.0.0",
"description": "A simple app deployed via CI/CD pipeline to ECS Fargate",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "4.21.2"
}
}
What does this file do?
The package.json file defines your Node.js project. It lists metadata like the project name and version, and declares dependencies that your app needs to run.
The express dependency at version 4.21.2 is the web framework your server uses to handle HTTP requests. Docker will install this dependency inside the container during the build.
- Save package.json.
You won't see any visible output yet. Docker uses this file during the container build in the next substep.
- Create a new file called server.js in the same folder and add the following content:
const express = require('express');
const app = express();
const port = 3000;
// Return JSON with app info on every request to the root URL
app.get('/', (req, res) => {
res.json({
app: 'cicd-pipeline-app',
version: '1.0.0',
timestamp: new Date().toISOString(),
message: 'Deployed via GitHub Actions CI/CD pipeline!'
});
});
// Start the server on all network interfaces
app.listen(port, '0.0.0.0', () => {
console.log(`Server running on port ${port}`);
});
What does this code do?
- app.get('/', ...) creates a route that responds to requests at the root URL with a JSON object.
- The JSON response includes a version field. Later, you'll change this value to prove the CI/CD pipeline deployed your update automatically.
- app.listen(port, '0.0.0.0', ...) starts the server on all network interfaces. This is important inside a Docker container so that requests from outside the container can reach it.
- Save server.js.
- Confirm both files exist by running this in your terminal:
ls
You should see package.json and server.js in the output.
Don't see both files?
- Make sure you're in the cicd-pipeline-app folder. Run pwd to check your current directory.
- If a file is missing, re-create it in the correct folder. Files must be at the top level of cicd-pipeline-app, not inside a subfolder.
Ask for help: My ls output doesn't show package.json and server.js in the cicd-pipeline-app folder. How do I fix this?
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
{
"name": "cicd-pipeline-app",
"version": "1.0.0",
"description": "A simple app deployed via CI/CD pipeline to ECS Fargate",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "4.21.2"
}
}
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.json({
app: 'cicd-pipeline-app',
version: '1.0.0',
timestamp: new Date().toISOString(),
message: 'Deployed via GitHub Actions CI/CD pipeline!'
});
});
app.listen(port, '0.0.0.0', () => {
console.log(`Server running on port ${port}`);
});
Write the Dockerfile
A Dockerfile is a set of instructions that tells Docker how to build your application into a container image. It defines the base operating system, copies your code in, installs dependencies, and specifies how to start the app.
- Create a new file called Dockerfile (no file extension) in the root of your cicd-pipeline-app folder and add the following content:
# Use a lightweight Node.js base image
FROM node:20-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy package.json first and install dependencies
# This layer is cached unless package.json changes
COPY package.json ./
RUN npm install --production
# Copy the application code
COPY server.js ./
# Document which port the app listens on
EXPOSE 3000
# Start the server when the container runs
CMD ["node", "server.js"]
What does each instruction do?
- FROM node:20-alpine starts from a minimal Node.js 20 image based on Alpine Linux. This keeps the final image small and fast to push.
- COPY package.json is done separately from the app code so Docker can cache the npm install layer. If only server.js changes, Docker skips reinstalling dependencies.
- RUN npm install --production installs only the dependencies your app needs to run, keeping the image lean.
- CMD ["node", "server.js"] tells Docker what command to run when the container starts.
- Save the Dockerfile.
- Confirm all three project files exist by running:
ls
You should see Dockerfile, package.json, and server.js listed.
Don't see the Dockerfile?
- Make sure the file is named exactly Dockerfile with a capital D and no file extension. It should NOT be Dockerfile.txt or dockerfile.
- Confirm you saved it inside the cicd-pipeline-app folder, not a subfolder or the Desktop.
Ask for help: My Dockerfile isn't showing up when I run ls in my project folder. How do I fix this?
✔️ Awesome, I've got everything!
Great. Double check you've saved the Dockerfile before moving on.
ⓧ I'd like to double check the full code
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
Build and test the container locally
Before deploying to AWS, you'll build the Docker image on your machine and run it locally. This catches errors early, before they reach the pipeline.
- Build the Docker image by running this command from your cicd-pipeline-app folder:
docker build -t cicd-pipeline-app .
What does this command do?
- docker build reads your Dockerfile and executes each instruction to create an image.
- -t cicd-pipeline-app tags (names) the image so you can reference it easily.
- The . at the end tells Docker to use the current directory as the build context (where it looks for your files).
The build takes a moment as Docker downloads the node:20-alpine base image and installs your dependencies. You should see each Dockerfile step executing in the terminal output.
Seeing a build error?
- Make sure Docker is running. You can check by running docker --version in your terminal.
- Confirm you're in the cicd-pipeline-app folder where the Dockerfile lives. Run pwd to check.
- If you see a JSON parse error, double check your package.json has valid JSON (matching braces, no trailing commas).
Ask for help: My docker build command is failing. Help me troubleshoot based on the error message I'm seeing.
- Run your container by executing this command:
docker run -p 3000:3000 cicd-pipeline-app
What does this command do?
-p 3000:3000 maps port 3000 on your machine to port 3000 inside the container. This lets you access the app at http://localhost:3000 on your host machine.
The container runs in the foreground, so your terminal will show the server's startup log. Leave it running and use a separate browser window to test.
You should see this message in your terminal:
Server running on port 3000
- Open your browser and navigate to http://localhost:3000.
You should see a JSON response containing your app name, version 1.0.0, a timestamp, and the message Deployed via GitHub Actions CI/CD pipeline!.
- Stop the container by pressing Ctrl+C in the terminal where it's running.
Not seeing the JSON response?
- Make sure the container is still running. If you pressed Ctrl+C already, run the docker run command again.
- Check that nothing else is using port 3000. You can check with lsof -i :3000 (macOS/Linux) to see if another process is blocking the port.
- Verify the URL is exactly http://localhost:3000 (not https).
Ask for help: I can't see the JSON response at localhost:3000. Help me figure out why my container isn't serving the app.
Your containerized app is running locally. Next up, you'll create the ECS task definition that tells AWS how to run this same container in the cloud.
Create the ECS Task Definition and Service
Your Docker image builds and runs locally. Now you need to tell ECS exactly how to run your container in the cloud.
A task definition is the blueprint that describes your container's resource requirements, networking, and image location. Once registered, you can launch an ECS service that keeps your container running and accessible to the internet.
In this step, get ready to:
- Create and register a Fargate task definition.
- Create a security group to allow traffic on port 3000.
- Launch an ECS service with a public IP.
Create the task definition file
ECS Fargate needs a JSON file that describes your container. This file specifies how much CPU and memory to allocate, which Docker image to pull, and which port to expose.
- Create a new file called task-definition.json in the root of your cicd-pipeline-app project folder.
- Paste the following content into task-definition.json:
{
"family": "cicd-pipeline-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"containerDefinitions": [
{
"name": "cicd-pipeline-app",
"image": "ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/cicd-pipeline-app:latest",
"portMappings": [
{
"containerPort": 3000,
"protocol": "tcp"
}
],
"essential": true
}
]
}
What does this task definition do?
- family names the task definition so you can reference it later.
- networkMode: "awsvpc" gives the task its own network interface. This is required for Fargate.
- cpu: "256" and memory: "512" allocate 0.25 vCPU and 512 MB of memory. These are the smallest Fargate resources available.
- containerDefinitions points to your ECR image and maps port 3000 so traffic can reach your Express app.
- Replace ACCOUNT_ID in the image field with your actual AWS account ID.
Find your AWS account ID
Run aws sts get-caller-identity --query Account --output text in your terminal to print your 12-digit account ID.
- Save task-definition.json.
Not sure about the image URI?
The image field follows the pattern <account-id>.dkr.ecr.<region>.amazonaws.com/<repo-name>:<tag>. Make sure you use your own 12-digit account ID, the region us-east-1, the repository name cicd-pipeline-app, and a tag like latest.
How do I find my ECR image URI?
Register the task definition and create a security group
With the JSON file saved, you need to register it with ECS so AWS knows about your task blueprint. You also need a security group that allows inbound traffic on port 3000.
- Register your task definition with ECS by running this command:
aws ecs register-task-definition --cli-input-json file://task-definition.json
What does this command do?
This uploads your task definition JSON to ECS. AWS assigns it a revision number (starting at 1). You can now reference it as cicd-pipeline-app:1 when creating a service.
- Confirm the registration succeeded by checking the output. You should see a JSON response containing "status": "ACTIVE" in the taskDefinition section.
Next, create a security group to control what traffic can reach your container.
- Get your default VPC ID by running:
aws ec2 describe-vpcs --filters "Name=is-default,Values=true" --query "Vpcs[0].VpcId" --output text
- Copy the VPC ID from the output (it looks like vpc-0abc123def456).
- Create a security group in your default VPC by running this command:
aws ec2 create-security-group --group-name cicd-pipeline-sg --description "Allow traffic on port 3000" --vpc-id [[VPC_ID="your default VPC ID e.g. vpc-0abc123def456"]]
- Copy the GroupId from the output (it looks like sg-0abc123def456).
- Add an inbound rule allowing TCP traffic on port 3000 from anywhere by running:
aws ec2 authorize-security-group-ingress --group-id [[SG_ID="your security group ID e.g. sg-0abc123def456"]] --protocol tcp --port 3000 --cidr 0.0.0.0/0
What does this security group do?
A security group acts as a firewall for your ECS task. The rule you just added allows any IP address (0.0.0.0/0) to send TCP traffic to port 3000.
Without this rule, your container would be running but unreachable from the internet.
Getting a VpcId error?
Make sure you included the --vpc-id flag with your actual VPC ID when creating the security group. Run the describe-vpcs command again to confirm the value.
I'm getting an error when creating my security group
Create the ECS service
The service is what actually runs your container and keeps it alive. It references the task definition, places the task in your VPC subnets, and assigns a public IP so you can access the app.
- Get a subnet ID from your default VPC by running:
aws ec2 describe-subnets --filters "Name=vpc-id,Values=[[VPC_ID="your VPC ID"]]" --query "Subnets[0].SubnetId" --output text
- Copy the subnet ID from the output (it looks like subnet-0abc123def456).
- Create the ECS service by running the following command:
aws ecs create-service --cluster cicd-pipeline-cluster --service-name cicd-pipeline-service --task-definition cicd-pipeline-app --desired-count 1 --launch-type "FARGATE" --network-configuration "awsvpcConfiguration={subnets=[[[SUBNET_ID="your subnet ID e.g. subnet-0abc123"]]],securityGroups=[[[SG_ID="your security group ID e.g. sg-0abc123"]]],assignPublicIp=ENABLED}"
What does this command do?
- --cluster cicd-pipeline-cluster places the service in the cluster you created earlier.
- --service-name cicd-pipeline-service gives the service an identifiable name.
- --desired-count 1 tells ECS to keep exactly one task running at all times.
- --launch-type "FARGATE" means AWS manages the underlying servers. You do not provision EC2 instances.
- assignPublicIp=ENABLED gives your task a public IP address so it is reachable from the internet.
- Confirm the service is active by running:
aws ecs describe-services --cluster cicd-pipeline-cluster --services cicd-pipeline-service --query "services[0].status"
- You should see "ACTIVE" in the output.
Wait a minute or two for the task to start, then find its public IP.
- List the running tasks to get the task ARN:
aws ecs list-tasks --cluster cicd-pipeline-cluster --service-name cicd-pipeline-service --query "taskArns[0]" --output text
- Describe the task to find its network interface:
aws ecs describe-tasks --cluster cicd-pipeline-cluster --tasks [[TASK_ARN="your task ARN from the previous command"]] --query "tasks[0].attachments[0].details[?name=='networkInterfaceId'].value" --output text
- Get the public IP from the network interface:
aws ec2 describe-network-interfaces --network-interface-ids [[ENI_ID="your ENI ID e.g. eni-0abc123"]] --query "NetworkInterfaces[0].Association.PublicIp" --output text
- Open your browser and navigate to http://<public-ip>:3000. You should see your JSON response with the app name, version, and timestamp.
Can't reach the app at the public IP?
Give the task 2-3 minutes to reach a RUNNING state. Check the task status with aws ecs describe-tasks and look for "lastStatus": "RUNNING".
If the status is STOPPED, check that your task definition's image URI uses the correct account ID and that you pushed an image to ECR in the previous step.
Also verify your security group allows inbound TCP on port 3000.
My ECS task is not reachable at its public IP on port 3000
✔️ Awesome, I've got everything!
Great. Make sure you have saved task-definition.json and noted your security group ID and subnet ID for the next step.
ⓧ I'd like to double check the full code
Here is the complete task-definition.json file:
{
"family": "cicd-pipeline-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"containerDefinitions": [
{
"name": "cicd-pipeline-app",
"image": "ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/cicd-pipeline-app:latest",
"portMappings": [
{
"containerPort": 3000,
"protocol": "tcp"
}
],
"essential": true
}
]
}
Your container is now running on AWS with a public IP. Next up, you will create a GitHub Actions workflow that automatically builds, pushes, and deploys new images every time you push code.
Create the GitHub Actions Workflow
Your containerized app is live on ECS Fargate with a public IP. Right now, every time you change your code, you would need to manually rebuild the Docker image, push it to ECR, and update the ECS service.
In this step, you will create a GitHub Actions workflow that automates this entire process. Every push to your main branch will trigger a pipeline that builds, pushes, and deploys your container with zero manual intervention.
In this step, get ready to:
- Create the workflow file with a trigger and environment variables.
- Add build steps that push a Docker image to ECR.
- Add deployment steps that update the ECS service automatically.
Create the workflow file with trigger and environment variables
GitHub Actions workflows live in a special directory inside your repository. The file defines when the workflow runs and what resources it targets.
- Create the workflow directory structure by running this command:
mkdir -p .github/workflows
- Confirm the directory exists by running:
ls .github/workflows
You should see the empty workflows directory listed with no errors.
- Create and open the file .github/workflows/deploy.yml in your code editor.
- Paste the following workflow configuration into the file:
name: Deploy to Amazon ECS
on:
push:
branches:
- main
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: cicd-pipeline-app
ECS_SERVICE: cicd-pipeline-service
ECS_CLUSTER: cicd-pipeline-cluster
ECS_TASK_DEFINITION: task-definition.json
CONTAINER_NAME: cicd-pipeline-app
permissions:
contents: read
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
environment: production
steps:
What does this configuration do?
- on: push: branches: - main triggers the workflow every time code is pushed to the main branch.
- The env block defines reusable variables so you do not hardcode resource names throughout the workflow.
- permissions: contents: read limits the workflow to read-only access to your repository code. AWS credentials come from your GitHub secrets.
- runs-on: ubuntu-latest tells GitHub to run your workflow on a fresh Ubuntu virtual machine.
You will not see a change yet. The next chunk adds the build steps that make this workflow functional.
Add the build steps to the workflow
The build steps handle checking out your code, authenticating with AWS, and pushing a new Docker image to your ECR repository.
- In .github/workflows/deploy.yml, add the following steps directly below the steps: line:
- 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
What do these build steps do?
- actions/checkout@v4 clones your repository onto the runner so Docker can access your Dockerfile and source code.
- aws-actions/configure-aws-credentials@v4 reads the secrets you stored earlier and authenticates the runner with AWS.
- aws-actions/amazon-ecr-login@v2 logs Docker into your private ECR registry so it can push images.
- The build-image step tags the Docker image with the commit SHA (${{ github.sha }}), giving every build a unique, traceable tag.
- echo "image=..." >> $GITHUB_OUTPUT passes the full image URI to later steps so they know which image to deploy.
You will not see a change yet. The next chunk adds the deployment steps that complete the pipeline.
Add the deployment steps and push to trigger the workflow
The deployment steps take the newly built image and roll it into your running ECS service. This is the final piece that closes the CI/CD loop.
- Still in .github/workflows/deploy.yml, add these deployment steps below the build-image step:
- 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 do these deployment steps do?
- aws-actions/amazon-ecs-render-task-definition@v1 reads your task-definition.json file and injects the new image URI into it. This avoids editing the JSON file manually on every deploy.
- aws-actions/amazon-ecs-deploy-task-definition@v2 registers the updated task definition with ECS and triggers a rolling deployment of your service.
- wait-for-service-stability: true tells the workflow to wait until ECS confirms the new task is running healthy before marking the job as successful.
- Save .github/workflows/deploy.yml.
- Commit and push the workflow file to your main branch by running:
git add .github/workflows/deploy.yml
git commit -m "Add GitHub Actions deploy workflow"
git push origin main
- Open your repository on GitHub.
- Click the Actions tab at the top of the repository.
- You should see a workflow run in progress named Deploy to Amazon ECS. Wait for it to complete with a green checkmark.
How long does the workflow take?
The full workflow typically takes 3-5 minutes. The longest step is wait-for-service-stability, which waits for ECS to confirm your new container is running before marking the job as successful.
Workflow failed with a red X?
- Click on the failed run to see which step errored. The step name and error message tell you exactly where it broke.
- If the AWS credentials step fails, double check that your GitHub secrets AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set correctly in your repository settings.
- If the YAML fails to parse, check that your indentation uses spaces (not tabs) and each level is indented by exactly 2 spaces.
Help me debug my GitHub Actions workflow failure.
✔️ Awesome, I've got everything!
Great. Double check you have saved .github/workflows/deploy.yml and your workflow run shows a green checkmark in the Actions tab.
ⓧ I'd like to double check the full code
Here is the complete .github/workflows/deploy.yml file:
name: Deploy to Amazon ECS
on:
push:
branches:
- main
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: cicd-pipeline-app
ECS_SERVICE: cicd-pipeline-service
ECS_CLUSTER: cicd-pipeline-cluster
ECS_TASK_DEFINITION: task-definition.json
CONTAINER_NAME: cicd-pipeline-app
permissions:
contents: read
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
environment: production
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
Your CI/CD pipeline is live. Every push to main now triggers a full build-and-deploy cycle automatically. Next up, you will test this end-to-end by making a visible change to your app and watching it flow through the pipeline to production.
Test the Full CI/CD Pipeline
In the last step, you deployed your app to ECS with a fully automated GitHub Actions workflow. Now comes the real test: can you make a code change and watch it appear in production without touching a single server?
The whole point of a CI/CD pipeline is that deployments happen automatically. In this step, you'll make a visible change to your app and prove the full loop works end-to-end.
In this step, get ready to:
- Update the app version in server.js and push to main.
- Monitor the automated workflow in GitHub Actions.
- Confirm the updated app is live at your ECS task's public IP.
Make a visible change to the app
To prove the pipeline works, you need a change that's immediately visible in the response. Updating the version string in server.js is the simplest way to confirm that new code made it all the way to production.
- In server.js, find this line inside the res.json block:
version: '1.0.0',
- Replace '1.0.0' with '2.0.0', so the line now looks like this:
version: '2.0.0',
- Save server.js.
- Commit and push the change to main by running these commands:
git add server.js
git commit -m "Update version to 2.0.0"
git push origin main
What does this trigger?
Your deploy.yml workflow is configured to trigger on every push to the main branch. The moment you push, GitHub Actions automatically starts the build-push-deploy cycle.
Push rejected or authentication error?
Make sure you're on the main branch. Run git branch to confirm.
If you see an authentication error, check that your GitHub credentials (SSH key or personal access token) are correctly configured.
Help me troubleshoot my git push error
Monitor the GitHub Actions workflow
With the push complete, your workflow is already running. You can watch each step in real time from the GitHub Actions tab.
- Open your repository on GitHub in your browser.
- Click the Actions tab near the top of the repository page.
- Click the most recent workflow run (it should say "Update version to 2.0.0" or your commit message).
- Click the Deploy job to expand the step-by-step log.
Watch each step execute in sequence. You should see green checkmarks appear beside each one as they complete.
What is each step doing?
- Checkout pulls your latest code (with the version change) into the runner.
- Configure AWS credentials authenticates the runner to your AWS account using the secrets you stored.
- Login to Amazon ECR gets a Docker login token so the runner can push images to your registry.
- Build, tag, and push image builds a fresh Docker image from your updated code, tags it with the commit SHA, and pushes it to ECR.
- Fill in the new image ID updates your task definition with the new image reference.
- Deploy Amazon ECS task definition registers the updated task definition and tells ECS to roll out the new container. It waits for the service to stabilize before marking success.
Seeing a red X on any step?
Click the failed step to expand its log. The error message tells you exactly what went wrong.
If the AWS credentials step fails, double-check that your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets are correctly set in your repository's Settings > Secrets and variables > Actions.
If the deploy step fails with a timeout, your ECS service may not be able to start the new task. Check that the security group still allows inbound traffic on port 3000.
Help me debug my failed GitHub Actions workflow
Confirm the updated app is live
Once the workflow shows a green checkmark on every step, your new version is deployed. Time to confirm it with your own eyes.
- Get the public IP of your running task by running this command:
aws ecs describe-tasks --cluster cicd-pipeline-cluster --tasks $(aws ecs list-tasks --cluster cicd-pipeline-cluster --service-name cicd-pipeline-service --query 'taskArns[0]' --output text) --query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' --output text | xargs -I {} aws ec2 describe-network-interfaces --network-interface-ids {} --query 'NetworkInterfaces[0].Association.PublicIp' --output text
Reuse your previous IP
If you still have the public IP from the previous step, try that first. Note that ECS may assign a new IP when it replaces the task during deployment, so the IP might have changed.
- Access your app using the public IP by running:
curl http://[[ECS_PUBLIC_IP="your ECS task public IP"]]:3000
You should see a JSON response with "version": "2.0.0" in the output. This proves your code went from a local commit all the way to a live production container without any manual intervention.
Why does this matter?
You just proved the full CI/CD loop. Every future code change follows the same path: commit, push, automatic build, automatic deploy. No SSH into servers, no manual Docker commands, no downtime.
This is exactly what interviewers mean when they ask "describe your deployment process." You can walk them through every step from commit to production with concrete evidence.
Still seeing version 1.0.0?
The ECS deployment takes a few minutes to stabilize. If the workflow completed with a green checkmark but the old version still shows, wait 2-3 minutes and try the curl command again.
If the IP changed, re-run the describe-tasks command above to get the new public IP for the replacement task.
Help me troubleshoot my ECS deployment not showing the updated version
✔️ Awesome, I've got everything!
Great. Double check you've saved server.js and confirmed the new version is live.
ⓧ I'd like to double check the full code
Here's what your server.js should look like now:
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.json({
app: 'cicd-pipeline-app',
version: '2.0.0',
timestamp: new Date().toISOString(),
message: 'Deployed via GitHub Actions CI/CD pipeline!'
});
});
app.listen(port, '0.0.0.0', () => {
console.log(`Server running on port ${port}`);
});
Secret mission
Add Health Checks for Automated Recovery
In production, containers crash. Memory leaks, unresponsive threads, and network blips can leave a container running but unable to serve traffic. ECS health checks let you detect these failures automatically and replace broken containers without human intervention. This is a key topic in DevOps interviews.
Clean Up Your Resources
Clean Up Your Resources
This project uses AWS resources that are covered by the Free Tier while active. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- ECR repository (cicd-pipeline-app storing Docker images).
- ECS cluster (cicd-pipeline-cluster) and Fargate task running your container.
- Security group allowing inbound traffic on port 3000.
- IAM user with ECR/ECS permissions and access keys stored as GitHub secrets.
- Local project files on your Desktop.
- GitHub repository with the workflow and source code.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the pipeline.
- The Fargate task consumes Free Tier credits while running. Monitor usage in the AWS Billing dashboard.
- The ECR repository stores your Docker images at no cost within Free Tier storage limits.
- GitHub Actions minutes remain active for future pushes to main.
Pause - I'll come back to this later
Shut down the running Fargate task to stop compute usage while keeping all resources in place.
- Scale your ECS service down to zero tasks by running:
aws ecs update-service --cluster cicd-pipeline-cluster --service cicd-pipeline-service --desired-count 0
- This stops the running container but keeps the service, cluster, and ECR images intact.
- When you are ready to resume, set --desired-count 1 to bring the task back up.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps in order.
- Scale the ECS service to zero and then delete it by running:
aws ecs update-service --cluster cicd-pipeline-cluster --service cicd-pipeline-service --desired-count 0
aws ecs delete-service --cluster cicd-pipeline-cluster --service cicd-pipeline-service --force
- Deregister the task definition by running:
aws ecs deregister-task-definition --task-definition cicd-pipeline-app:1
What about later revisions?
If you registered multiple revisions during the project, repeat the command for each revision number (e.g. cicd-pipeline-app:2, cicd-pipeline-app:3).
- Delete the ECR repository and all its images by running:
aws ecr delete-repository --repository-name cicd-pipeline-app --force
- Delete the ECS cluster by running:
aws ecs delete-cluster --cluster cicd-pipeline-cluster
- Delete the security group. First find the group ID, then delete it by running:
aws ec2 delete-security-group --group-id [[SG_ID="your security group ID"]]
- Delete the IAM user's access keys and then the user itself. Replace the user name with the one you created:
aws iam list-access-keys --user-name [[IAM_USERNAME="your IAM username"]]
aws iam delete-access-key --user-name [[IAM_USERNAME="your IAM username"]] --access-key-id [[ACCESS_KEY_ID="the access key ID from the list command"]]
aws iam delete-user --user-name [[IAM_USERNAME="your IAM username"]]
What if delete-user fails?
You must remove all attached policies before deleting the user. Use aws iam detach-user-policy for each managed policy and aws iam delete-user-policy for inline policies before retrying.
- Delete the GitHub secrets from your repository. In your repository on GitHub, go to Settings, then Secrets and variables, then Actions. Delete AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
- Delete the local project files by running:
cd ~/Desktop
rm -rf cicd-pipeline-app
- Optionally delete the GitHub repository if you no longer need it. In your repository on GitHub, go to Settings, scroll to the bottom, and click Delete this repository.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade CI/CD pipeline that automatically deploys a containerized application to AWS every time you push code.
You've learned how to:
- Built and pushed a Docker container image to Amazon ECR for centralized storage.
- Deployed a containerized app to ECS Fargate with a public IP accessible from anywhere.
- Created a GitHub Actions workflow that automates the full build, push, and deploy cycle on every commit to main.
- Secret Mission: Added health checks for automated container recovery so ECS can detect and restart unhealthy tasks.
Ready to quiz yourself?