CI/CD Pipeline to Deploy on ECS Fargate
Build a GitHub Actions pipeline that auto-deploys containers to ECS Fargate.
Introduction
30 Second Summary
Every time a developer finishes writing code, they face the same question: how do I get this running in production? Manually building, uploading, and restarting servers is slow and error-prone.
In this project, you will build a fully automated CI/CD pipeline using GitHub Actions that deploys a containerized application to AWS ECS Fargate every time you push code to GitHub.
What You'll Build
A GitHub Actions workflow that builds a Docker image, pushes it to Amazon ECR, and deploys it to a running ECS Fargate service with zero-downtime rolling updates.
By the end of this project, you'll have:
- A containerized Node.js application running on AWS ECS Fargate, accessible via a public IP.
- A GitHub Actions workflow that automatically builds, pushes, and deploys your app on every push to main.
- Secure OIDC authentication between GitHub and AWS with no long-lived secrets or static credentials.
- Secret Mission: Add a staging environment that deploys on pull requests so you can preview changes before they hit production.
Are there any prerequisites?
Basic familiarity with Docker and command-line tools is helpful. You also need an AWS account (free tier eligible is fine) and a GitHub account. We will walk you through all tool installation in Step 1.
Create the Application and Dockerfile
Your tools are installed and your repository is connected to GitHub. Now it's time to build the application that your pipeline will deploy.
You'll create a simple Node.js web server that responds with a JSON health check. This is the kind of lightweight service that's perfect for containers. Once the app works locally in Docker, you'll know it's ready for ECS Fargate.
In this step, get ready to:
- Create a Node.js/Express application with health check endpoints.
- Write a production-ready Dockerfile for the application.
- Build and test the container locally.
Create the Node.js application files
Every Node.js project starts with a package.json file that declares dependencies and scripts. You'll create this first, then write the application code.
- In VS Code, create a new file called package.json in the root of your project folder.
- Paste the following code into package.json:
{
"name": "my-ecs-app",
"version": "1.0.0",
"description": "A simple Node.js app deployed to ECS Fargate",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "4.21.2"
}
}
What does this file do?
This file tells Node.js about your project. The dependencies section declares that your app needs Express version 4.21.2 to run.
The start script defines how to launch your app, which Docker will use later.
- Save package.json.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
{
"name": "my-ecs-app",
"version": "1.0.0",
"description": "A simple Node.js app deployed to ECS Fargate",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "4.21.2"
}
}
Now create the application itself.
- Create a new file called app.js in the same folder as package.json.
- Paste the following code into app.js:
const express = require('express');
const app = express();
const port = process.env.PORT || 80;
// Root endpoint returns a JSON health check with service info
app.get('/', (req, res) => {
res.json({
status: 'healthy',
service: 'my-ecs-app',
version: '1.0.0',
timestamp: new Date().toISOString()
});
});
// Dedicated health endpoint for ECS health checks
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Start the server on the configured port
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
What does this code do?
This creates a minimal Express web server with two endpoints. The root / endpoint returns JSON showing the service name, version, and current timestamp.
The /health endpoint returns a simple status response. ECS uses this to check whether your container is still alive.
The server listens on port 80 by default, which is what your container will expose.
- Save app.js.
- Confirm both files exist by running:
ls
You should see app.js and package.json in the output.
Don't see both files?
Make sure you created both files in the same directory. Check that your terminal is pointing to the project folder. If your terminal is in a different location, use cd to navigate to the folder where your files live.
help me find my project files
✔️ 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
const express = require('express');
const app = express();
const port = process.env.PORT || 80;
app.get('/', (req, res) => {
res.json({
status: 'healthy',
service: 'my-ecs-app',
version: '1.0.0',
timestamp: new Date().toISOString()
});
});
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
Write the Dockerfile
A Dockerfile is a set of instructions that tells Docker how to package your application into a container image. You'll build one that creates a small, production-ready image.
- Create a new file called Dockerfile (no file extension) in the same folder as your other files.
- Paste the following code into Dockerfile:
# Use a lightweight Node.js base image
FROM node:20-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy package.json first to leverage Docker layer caching
COPY package.json ./
RUN npm install --production
# Copy the application code
COPY app.js ./
# Document the port the app listens on
EXPOSE 80
# Start the application
CMD ["node", "app.js"]
What does this Dockerfile do?
Each line is a build instruction. FROM node:20-alpine starts from a minimal Node.js image (about 50 MB instead of 900+ MB for a full OS image).
The Dockerfile copies package.json separately before app.js. This is a caching optimization. Docker caches each layer, so if only your application code changes, Docker skips reinstalling dependencies on the next build.
EXPOSE 80 documents that the container listens on port 80. CMD tells Docker what command to run when the container starts.
- Save Dockerfile.
VS Code adding a file extension?
If VS Code added .txt or another extension to your file, rename it to just Dockerfile with no extension. Docker looks for this exact filename.
help me rename my Dockerfile correctly
✔️ Awesome, I've got everything!
Great. Double check you've saved the file 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 app.js ./
EXPOSE 80
CMD ["node", "app.js"]
Build and test the container locally
Before pushing anything to the cloud, you'll verify that the container builds and runs correctly on your machine.
- Make sure Docker Desktop is running on your machine.
- Build your Docker image by running this command in your terminal (from the folder containing your Dockerfile):
docker build -t my-ecs-app .
What does this command do?
The -t my-ecs-app flag tags (names) the resulting image so you can reference it later. The . at the end tells Docker to use the current directory as the build context.
The build takes a few seconds. You should see output ending with FINISHED in the first line, which means Docker successfully built your image.
Build failed?
If you see an error about the Dockerfile not being found, check that your terminal is in the same folder as Dockerfile. Run ls to confirm you can see Dockerfile, app.js, and package.json in the output.
If you see a Docker daemon error, make sure Docker Desktop is running.
help me fix my Docker build error
- Run the container in detached mode by running this command:
docker run -d -p 8080:80 --name my-ecs-test my-ecs-app
What does this command do?
The -d flag runs the container in the background so you keep your terminal. The -p 8080:80 flag maps port 8080 on your machine to port 80 inside the container.
The --name my-ecs-test gives the container a friendly name so you can stop it easily later.
Docker prints the container ID, which confirms the container started successfully.
- Test that your application is responding by running:
curl http://localhost:8080
You should see a JSON response similar to this:
{
"status": "healthy",
"service": "my-ecs-app",
"version": "1.0.0",
"timestamp": "2026-05-29T10:30:00.000Z"
}
Not getting a response?
Run docker ps to check if your container is still running. If the STATUS column shows it exited, run docker logs my-ecs-test to see what went wrong.
If port 8080 is already in use on your machine, stop the conflicting process or use a different host port (e.g., -p 9090:80).
help me debug my container
- Stop and remove the test container by running:
docker stop my-ecs-test && docker rm my-ecs-test
This cleans up the local container so it doesn't conflict with future runs.
Your containerized application is working locally. Next, you'll set up the AWS infrastructure to host it in the cloud.
Set Up AWS Infrastructure (ECR + ECS Cluster)
Your application runs locally, but you need somewhere in the cloud to store your Docker image and run your container.
In this step, you'll create the AWS infrastructure that powers the deployment side of your pipeline. This includes a container registry to store images, a cluster to run containers, and a task definition that tells AWS how to launch your app.
In this step, get ready to:
- Create an ECR repository to store your Docker images.
- Create an ECS cluster with Fargate to run containers.
- Create an ECS task definition that describes how your app should run.
Create an ECR repository
Amazon ECR (Elastic Container Registry) is a private Docker registry in AWS. Your CI/CD pipeline will push built images here, and ECS will pull from it when deploying.
- Open your browser and navigate to the AWS Management Console at https://console.aws.amazon.com.
- Sign in with your AWS account credentials.
- In the top navigation bar, confirm your region is set to US East (N. Virginia) us-east-1. If not, click the region dropdown and select it.
- In the search bar at the top, type ECR and select Elastic Container Registry.
- In the left navigation pane, click Private repositories.
- Click Create repository.
- For Repository name, enter my-ecs-app.
- Leave all other settings at their defaults (Private visibility, Mutable tags, AES-256 encryption).
- Click Create.
You should see a success banner and your my-ecs-app repository listed in the table.
What is ECR?
ECR is a fully managed container registry. It stores your Docker images close to where they run (ECS), which means fast pulls during deployment.
Each image version gets a unique tag. Your pipeline will tag images with the Git commit SHA so you can always trace a running container back to the exact code that built it.
Don't see the Create repository button?
Make sure you're on the Private repositories tab, not the public registry. Also confirm your region is set to us-east-1 in the navigation bar.
If you see a permissions error, your IAM user may not have ECR access. Why can't I create an ECR repository?
Create an ECS cluster with Fargate
An ECS cluster is a logical grouping of tasks and services. With Fargate as the capacity provider, AWS manages the underlying servers for you. You just define what to run.
- In the AWS Console search bar, type ECS and select Elastic Container Service.
- In the left navigation pane, click Clusters.
- Click Create cluster.
- Under Cluster configuration, for Cluster name, enter my-ecs-cluster.
- Leave the Infrastructure section at its default. The console automatically selects AWS Fargate (serverless) and associates both Fargate and Fargate Spot capacity providers.
- Leave all other settings (Monitoring, Tags, Encryption) at their defaults.
- Click Create.
After a few seconds, you should see a success banner. Your cluster status will show Active.
Why Fargate instead of EC2?
Fargate is serverless container compute. You don't provision, scale, or manage EC2 instances. You define what your container needs (CPU, memory), and Fargate handles the rest.
This keeps the project focused on CI/CD automation rather than infrastructure management. You pay per-second for the resources your tasks use.
Cluster creation failed?
If you see an error about CloudFormation, wait a minute and try again. AWS creates a CloudFormation stack behind the scenes, and occasional throttling can cause transient failures.
If you see a permissions error, your IAM user needs ECS and CloudFormation permissions. Why is my ECS cluster creation failing?
Create an ECS task definition
A task definition is the blueprint for your container. It tells ECS which image to run, how much CPU and memory to allocate, which ports to expose, and where to send logs.
You'll create this task definition using a JSON file. This same file will live in your Git repository so the CI/CD pipeline can update it on every deployment.
- In the ECS console, click Task definitions in the left navigation pane.
- Click Create new Task Definition, then select Create new revision with JSON.
Why use the JSON editor?
The JSON editor lets you paste an exact task definition that matches the file you'll store in your repository. This keeps your infrastructure-as-code in sync between the console and your CI/CD pipeline.
- First, find your AWS account ID. Switch back to your terminal and run this command:
aws sts get-caller-identity --query Account --output text
- Copy the 12-digit account ID from the output. You'll need it in the next step.
- Back in VS Code, create a new file called task-definition.json in your project root folder.
- Paste the following JSON into task-definition.json, replacing YOUR_ACCOUNT_ID with your actual 12-digit AWS account ID (it appears in two places):
{
"family": "my-ecs-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::[[AWS_ACCOUNT_ID=\"your 12-digit AWS account ID\"]]:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "my-ecs-app",
"image": "[[AWS_ACCOUNT_ID=\"your 12-digit AWS account ID\"]].dkr.ecr.us-east-1.amazonaws.com/my-ecs-app:latest",
"portMappings": [
{
"containerPort": 80,
"protocol": "tcp"
}
],
"essential": true,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/my-ecs-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
What does this task definition do?
- cpu: "256" and memory: "512" allocate 0.25 vCPU and 0.5 GB of memory. This is the smallest Fargate configuration, keeping costs minimal.
- networkMode: "awsvpc" gives the task its own elastic network interface with a private (or public) IP address. This is required for Fargate.
- executionRoleArn points to the ecsTaskExecutionRole IAM role. This allows ECS to pull your image from ECR and write logs to CloudWatch on your behalf.
- logConfiguration sends container output to a CloudWatch Logs group at /ecs/my-ecs-app. This is where you'll check logs if something goes wrong.
- Save task-definition.json.
- Now copy the full contents of your task-definition.json file (with your real account ID substituted in).
- Switch back to the ECS console JSON editor.
- Clear the pre-populated JSON and paste your task definition.
- Click Create.
You should see a success message and your my-ecs-app task definition listed with revision 1.
Seeing a validation error when creating the task definition?
The most common cause is an incorrect account ID in the executionRoleArn or image fields. Double-check that your 12-digit account ID is correct and has no extra spaces.
If you see an error about ecsTaskExecutionRole not existing, the ECS console usually creates it automatically the first time you create a Fargate task definition. If it doesn't, you may need to create it manually in IAM. How do I create the ecsTaskExecutionRole?
✔️ Awesome, I've got everything!
Great. Double check you've saved task-definition.json in your project folder. This file is critical for the CI/CD pipeline in the next steps.
ⓧ I'd like to double check the full code
Here's what task-definition.json should look like (with your own account ID replacing the placeholder):
{
"family": "my-ecs-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "my-ecs-app",
"image": "YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/my-ecs-app:latest",
"portMappings": [
{
"containerPort": 80,
"protocol": "tcp"
}
],
"essential": true,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/my-ecs-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
Your AWS infrastructure is ready. You have a container registry, a Fargate cluster, and a task definition that tells ECS exactly how to run your app. Next up, you'll create an ECS service that keeps your container running and accessible via a public IP.
Create the ECS Service
You have an ECS cluster and a registered task definition. The task definition is like a blueprint, but nothing is actually running yet.
To get your container running and keep it running, you need an ECS service. A service launches tasks from your task definition and automatically replaces them if they stop. In this step, you will create a service that runs your container on Fargate and makes it accessible via a public IP address.
In this step, get ready to:
- Create an ECS service in your cluster using the task definition.
- Configure the service for public IP access.
- Verify the service is running and accessible via the task's public IP.
Create the ECS service in the console
An ECS service ensures your task is always running. If the task crashes or stops, the service automatically starts a new one to maintain the desired count.
- Return to the AWS Management Console with the ECS service open.
- In the navigation pane, click Clusters.
- Click my-ecs-cluster to open your cluster.
- Select the Services tab.
- Click Create.
What is an ECS service?
A task definition tells ECS what to run. A service tells ECS to keep it running. The service monitors your tasks and replaces any that fail, maintaining your desired number of running containers at all times.
Configure service details and networking
On the Create service page, you will configure what to run, how many copies, and how the task connects to the internet.
- Under Compute configuration, select Launch type.
- For Launch type, choose FARGATE.
- Under Deployment configuration, for Task definition family, choose my-ecs-app.
- For Service name, enter my-ecs-app-service.
- For Desired tasks, enter 1.
Now configure networking so your task gets a public IP and allows inbound traffic on port 80.
- Expand the Networking section.
- For VPC, leave the default VPC selected.
- For Subnets, leave the default subnets selected.
- For Security group, choose Create a new security group.
- Enter ecs-my-app-sg as the security group name.
- Enter Allow HTTP traffic on port 80 as the description.
- Add an inbound rule with Type HTTP, Protocol TCP, Port range 80, and Source Anywhere (0.0.0.0/0).
- For Public IP, make sure it is turned on.
- Click Create at the bottom of the page.
Why does the task need a public IP?
Fargate tasks run in your VPC with their own network interface. Without a public IP, the task is only reachable from within the VPC. Enabling the public IP lets you access the container directly from your browser.
The security group acts as a firewall. By allowing inbound traffic on port 80, you let HTTP requests reach your container.
Service fails to create?
If you see an error about the task execution role, make sure the ecsTaskExecutionRole IAM role exists in your account. AWS usually creates this automatically the first time you use ECS. If it is missing, go to the IAM console and create it with the AmazonECSTaskExecutionRolePolicy managed policy attached.
Help me fix my ECS service creation error.
Verify the service is running and accessible
After a minute or two, ECS will launch your task. You can confirm it is running and then access it through the task's public IP.
- On the my-ecs-app-service page, click the Tasks tab.
- Wait until the task status shows Running. This may take 1-2 minutes.
- Click the task ID to open the task details.
- In the Configuration section, find the Public IP field.
- Click Open address next to the public IP (or copy the IP and paste it into your browser).
You should see a JSON response like this in your browser:
{
"status": "healthy",
"service": "my-ecs-app",
"version": "1.0.0",
"timestamp": "2026-05-29T12:00:00.000Z"
}
What should I see?
The JSON response confirms your Node.js app is running inside a container on AWS Fargate. The status field shows healthy, and the timestamp updates each time you refresh. This is your app running live on the internet.
Can't reach the public IP?
Make sure the task status is Running (not Provisioning or Stopped). If it stopped, check the Logs tab on the task page for error messages.
Verify your security group allows inbound HTTP on port 80 from 0.0.0.0/0. If the security group only allows traffic from a specific source, your browser won't be able to reach the task.
Also confirm that Public IP was turned on when creating the service. Without it, the task has no routable address.
Help me troubleshoot my ECS Fargate task that isn't reachable.
Your container is now running live on AWS Fargate and accessible to the world. Next up, you will set up secure OIDC authentication so GitHub Actions can deploy to this service automatically without any static credentials.
Configure OIDC Authentication
Your ECS service is live, but right now the only way to update it is manually. For a true CI/CD pipeline, GitHub Actions needs permission to push images to ECR and deploy to ECS on your behalf.
Instead of storing long-lived AWS access keys as secrets, you will use OIDC (OpenID Connect) to let GitHub Actions assume an IAM role directly. This means zero static credentials to rotate or leak.
In this step, get ready to:
- Create an IAM OIDC Identity Provider for GitHub.
- Create an IAM Role with a trust policy scoped to your repository.
- Attach ECR and ECS permissions policies to the role.
Create the OIDC Identity Provider
An IAM Identity Provider tells AWS to trust tokens issued by an external service. Once you register GitHub's OIDC endpoint, AWS can verify that incoming requests genuinely come from GitHub Actions.
- Return to the AWS Management Console.
- Search for IAM in the top search bar and open the IAM service.
- In the left navigation pane, click Identity providers.
- Click Add provider.
- For Configure provider, choose OpenID Connect.
- In the Provider URL field, enter:
- https://token.actions.githubusercontent.com
- In the Audience field, enter:
- sts.amazonaws.com
- Click Add provider.
What is an OIDC Identity Provider?
OIDC lets an external service (GitHub) prove its identity to AWS using short-lived tokens instead of static credentials. When your GitHub Actions workflow runs, GitHub signs a JWT token. AWS verifies that token against the provider you just registered and issues temporary credentials.
The audience value sts.amazonaws.com tells AWS that the tokens are intended for the AWS Security Token Service.
- Confirm you see token.actions.githubusercontent.com listed on the Identity providers page.
Don't see the provider listed?
Make sure you included https:// at the start of the Provider URL. AWS requires the full URL with the protocol prefix.
If you see an error that the provider already exists, another setup in your account may have registered it. You can skip ahead to creating the role.
Help me troubleshoot my OIDC provider setup in IAM.
Create the IAM role for GitHub Actions
Now you will create an IAM role that GitHub Actions can assume. The trust policy scopes access to your specific repository, so only workflows from your repo can use this role.
- In the IAM left navigation pane, click Roles.
- Click Create role.
- For Trusted entity type, select Web identity.
- For Identity provider, select token.actions.githubusercontent.com.
- For Audience, select sts.amazonaws.com.
- For GitHub organization, enter your GitHub username or organization name.
- For GitHub repository, enter the name of the repository you created for this project.
- For GitHub branch, enter main.
- Click Next.
Why scope the trust policy to your repository?
The trust policy uses a sub claim condition in the format repo:<GITHUB_ORG>/<GITHUB_REPOSITORY>:ref:refs/heads/<GITHUB_BRANCH>. This ensures only workflows triggered from your specific repository and branch can assume this role.
Without this restriction, any GitHub repository could potentially use your role to access your AWS resources.
Now you will attach permission policies that let the role push container images and deploy ECS services.
- On the Add permissions page, search for AmazonEC2ContainerRegistryPowerUser.
- Check the box next to AmazonEC2ContainerRegistryPowerUser.
- Clear the search box and search for AmazonECS_FullAccess.
- Check the box next to AmazonECS_FullAccess.
- Click Next.
- For Role name, enter github-actions-ecs-deploy.
- Click Create role.
What do these policies allow?
AmazonEC2ContainerRegistryPowerUser grants read and write access to ECR repositories (push and pull images) without allowing repository deletion.
AmazonECS_FullAccess grants administrative access to ECS resources, including updating services and registering new task definitions.
Avoid naming the role GitHubActions
Naming the role GitHubActions has been reported to cause issues with the aws-actions/configure-aws-credentials action. The name github-actions-ecs-deploy avoids this known problem.
Verify the role configuration
- On the IAM Roles page, search for github-actions-ecs-deploy.
- Click the role name to open its details.
- Click the Trust relationships tab.
- Confirm the trust policy shows token.actions.githubusercontent.com as the federated principal and sts:AssumeRoleWithWebIdentity as the action.
- Click the Permissions tab and confirm both AmazonEC2ContainerRegistryPowerUser and AmazonECS_FullAccess are listed.
Trust policy missing your repository?
If the Condition block does not reference your repository name, edit the trust policy directly. The sub condition must include your GitHub username and repository name. A wildcard-only sub claim will be rejected by AWS.
Help me fix my IAM role trust policy for GitHub OIDC authentication.
Your authentication bridge is complete. GitHub Actions can now securely assume an AWS role without any stored secrets. Next up, you will write the workflow file that ties everything together into an automated deployment pipeline.
Create the GitHub Actions Workflow
You've connected GitHub to AWS with secure OIDC authentication. Now it's time to write the actual pipeline that automates your entire deployment.
A GitHub Actions workflow is a YAML file that lives inside your repository. Every time you push code, GitHub reads this file and executes each step you define. Your workflow will build a Docker image, push it to ECR, and deploy the updated container to ECS.
In this step, get ready to:
- Create the .github/workflows/deploy.yml file in your repository.
- Configure the workflow to build, push to ECR, and deploy to ECS.
- Push to main and watch the pipeline deploy automatically.
Create the workflow file
GitHub Actions looks for workflow files in a specific directory structure. You need to create the .github/workflows/ folder hierarchy inside your project.
- Create the workflow directory by running this command:
mkdir -p .github/workflows
- Create the deploy.yml file and open it in VS Code by running:
touch .github/workflows/deploy.yml
code .github/workflows/deploy.yml
- In deploy.yml, paste the workflow header that defines when the pipeline triggers and what permissions it needs:
# Workflow name shown in the GitHub Actions UI
name: Deploy to ECS Fargate
# Trigger: run this pipeline on every push to the main branch
on:
push:
branches: [main]
# OIDC requires id-token write permission to request a JWT
permissions:
id-token: write
contents: read
# Shared environment variables used across all steps
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: my-ecs-app
ECS_SERVICE: my-ecs-app-service
ECS_CLUSTER: my-ecs-cluster
ECS_TASK_DEFINITION: task-definition.json
CONTAINER_NAME: my-ecs-app
What does this code do?
This header section controls the workflow's identity and triggers.
- on: push: branches: [main] tells GitHub to run this workflow only when code is pushed to the main branch.
- permissions: id-token: write allows the workflow to request an OIDC token from GitHub, which is exchanged for temporary AWS credentials.
- The env block defines reusable variables so you only need to set resource names once.
Configure the deployment job steps
The job section defines what actually happens when the workflow runs. Each step handles one piece of the build-push-deploy pipeline.
- Below the CONTAINER_NAME: my-ecs-app line in deploy.yml, paste the jobs section:
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
# Pull the latest code from your repository
- name: Checkout
uses: actions/checkout@v4
# Authenticate with AWS using the OIDC role you created
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]:role/github-actions-ecs-deploy
aws-region: ${{ env.AWS_REGION }}
# Log in to ECR so Docker can push images
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
# Build the Docker image and push it to ECR with the commit SHA as the tag
- 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
# Insert the new image URI into the task definition JSON
- 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 }}
# Deploy the updated task definition to the ECS service
- 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 pipeline do step by step?
- Checkout pulls your code into the runner so it can access the Dockerfile and task definition.
- Configure AWS credentials exchanges the GitHub OIDC token for short-lived AWS credentials using the role you created in the previous step.
- Login to Amazon ECR authenticates Docker with your private registry so it can push images.
- Build, tag, and push creates the Docker image and tags it with the git commit SHA. This makes every deployment traceable to an exact commit.
- Render task definition swaps the placeholder image URI in your task-definition.json with the freshly pushed image.
- Deploy to ECS registers the new task definition and triggers a rolling update on your service. The wait-for-service-stability: true flag makes the step wait until ECS confirms the new task is healthy.
Why use the commit SHA as the image tag?
Tagging images with latest makes it impossible to know which version is running. Using the commit SHA creates an immutable deployment. If something breaks, you can roll back to any previous commit's image instantly.
- Save deploy.yml.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before continuing.
ⓧ I'd like to double check the full code
name: Deploy to ECS Fargate
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: my-ecs-app
ECS_SERVICE: my-ecs-app-service
ECS_CLUSTER: my-ecs-cluster
ECS_TASK_DEFINITION: task-definition.json
CONTAINER_NAME: my-ecs-app
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@v6
with:
role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/github-actions-ecs-deploy
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
Push to main and trigger the pipeline
Your workflow triggers on pushes to main. Committing and pushing your code will kick off the first automated deployment.
- Stage all your project files by running:
git add .
- Create a commit with a descriptive message by running:
git commit -m "Add CI/CD workflow for ECS Fargate deployment"
- Push to the main branch by running:
git push origin main
- Open your repository on GitHub in your browser.
- Click the Actions tab (located under your repository name in the top navigation bar).
- Click the workflow run that just appeared (it will be named after your commit message).
- Click the Deploy job in the left sidebar to watch each step execute in real time.
The pipeline takes 2-4 minutes to complete. It will build your Docker image, push it to ECR, update the task definition, and deploy to your ECS service.
- Wait for all steps to show green checkmarks. The final Deploy Amazon ECS task definition step waits until ECS confirms the new task is stable.
Pipeline failing?
- If the Configure AWS credentials step fails with a "Not authorized" error, double check that your IAM role's trust policy has the correct GitHub repository name and branch. The sub claim in the trust policy must exactly match your repo.
- If the Build, tag, and push step fails, verify that your IAM role has the AmazonEC2ContainerRegistryPowerUser policy attached.
- If the Deploy step times out waiting for stability, check that your ECS task execution role has permission to pull from ECR and that the CloudWatch log group /ecs/my-ecs-app exists.
Click into the failing step to read the full error message. Help me debug my GitHub Actions ECS deployment failure
Your CI/CD pipeline is live. Every push to main now automatically builds, pushes, and deploys your application. Next, you'll verify this end-to-end by making a code change and watching it appear on the live service.
Verify the Deployment Pipeline
In the previous step, you created the GitHub Actions workflow and watched it deploy your application for the first time. Now it's time to prove the pipeline truly automates everything.
You'll make a code change, push it to main, and watch your update go live on ECS Fargate without touching the AWS Console.
In this step, get ready to:
- Update the application code with a visible change.
- Push to main and watch GitHub Actions deploy automatically.
- Confirm the updated response is live on ECS Fargate via the task's public IP.
Make a code change to your application
The best way to prove a CI/CD pipeline works end-to-end is to make a visible change to your code and see it appear live. You'll update the version number in your health check response so the difference is obvious.
- In VS Code, open app.js.
- Find this line inside the / route handler:
version: '1.0.0',
- Change '1.0.0' to '1.0.1' so you can see the difference once the pipeline deploys your update.
- Save app.js.
Why change the version string?
The version field is part of the JSON response your app returns. When the pipeline finishes deploying, you can hit the public IP and immediately see whether the response shows the new value. This gives you a clear, observable confirmation that the entire build-push-deploy cycle completed.
Push the change and watch the pipeline run
With your code change saved, pushing to main will automatically trigger the GitHub Actions workflow you created in the previous step.
- Commit and push your change by running these commands in your terminal:
git add .
git commit -m "Update version to 1.0.1"
git push origin main
What does this trigger?
Your deploy.yml workflow listens for pushes to main. The moment GitHub receives your push, it spins up a runner that builds a new Docker image, tags it with the commit SHA, pushes it to ECR, and deploys it to your ECS service.
- Open your browser and go to your GitHub repository.
- 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 version to 1.0.1".
- Click on the workflow run to watch each step execute in real time.
The workflow takes a few minutes to complete. Watch for the green checkmark on the final step, Deploy Amazon ECS task definition. This means ECS has started a new task with your updated image and confirmed the service is stable.
Workflow failed?
Click the failed step to expand its logs. Common issues at this stage include:
- An OIDC credentials error means the trust policy on your IAM role doesn't match your repository name or branch. Double-check the sub claim in your trust policy exactly matches your GitHub org, repo name, and refs/heads/main.
- A timeout on the "wait-for-service-stability" step usually means the new task is failing its health check. Check that your app is listening on port 80.
Help me debug my failed GitHub Actions workflow for ECS deployment
Confirm the updated application is live
Once the workflow completes with a green checkmark, your updated container is live on ECS Fargate. You need to grab the new task's public IP to confirm the change.
- Return to the AWS Console in your browser.
- Navigate to ECS (search for it in the top search bar).
- Click on your cluster my-ecs-cluster.
- Click on the service my-ecs-app-service.
- Click the Tasks tab.
- Click on the running task (it should show status RUNNING).
- In the Configuration section, find the Public IP value and copy it.
Why might the public IP be different?
Each time ECS launches a new task, it gets a fresh public IP address. Rolling deployments stop the old task and start a new one, so the IP changes with each deployment. In production, you'd put a load balancer in front to provide a stable URL.
- Open a new browser tab and navigate to your task's public IP address (paste it directly into the URL bar).
You should see a JSON response with "version": "1.0.1" in it. This confirms your code change went from your local editor, through GitHub, into a new Docker image on ECR, and out to a running container on ECS Fargate. All automatically.
Still seeing the old version?
- Make sure you copied the public IP from the NEW task (the one launched after your latest deployment), not the old one that might still be draining.
- Wait 1-2 minutes for the rolling deployment to finish replacing the old task.
- Check that the GitHub Actions workflow completed successfully (green checkmark on all steps).
Help me troubleshoot why my ECS Fargate task is showing the old version after deployment
- Switch back to the GitHub Actions tab and click on the Build, tag, and push image to Amazon ECR step to expand it.
- Notice the image tag is your commit SHA. This means every deployment is uniquely identified and you can roll back to any previous version by redeploying its SHA.
Why commit SHA over latest?
Tagging images with the commit SHA creates an immutable link between your code and the deployed container. If something breaks, you know exactly which commit caused it and can deploy the previous SHA's image to roll back instantly.
Secret mission
Add a Staging Environment
Real teams don't deploy directly to production without a preview. In this secret mission, you'll create a staging environment that automatically deploys when you open a pull request. This lets you verify changes before they hit 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. Your ECS Fargate tasks incur charges per second while running, so pausing or deleting is recommended if you are done experimenting.
Cost warning
Each running Fargate task (0.25 vCPU + 0.5 GB) costs approximately $0.01/hour. With both production and staging services running, that is roughly $0.02/hour. If you leave them running overnight or over a weekend, charges will accumulate.
If you are done with this project, choose Pause or Delete below to stop all charges.
Resources you used:
- ECR repository my-ecs-app (stores container images).
- ECS cluster my-ecs-cluster with two services: my-ecs-app-service (production) and my-app-staging (staging).
- IAM Role github-actions-ecs-deploy with attached policies.
- IAM OIDC Identity Provider for token.actions.githubusercontent.com.
- GitHub repository with CI/CD workflow.
- Local project folder (my-ecs-app) on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or testing your pipeline.
- Your Fargate tasks will continue running and incurring charges (~$0.01/hour per service).
- Your ECR repository will keep storing images (500 MB free for 12 months on new accounts).
- Your GitHub Actions workflow will deploy on every push to main and on pull requests.
Pause - I'll come back to this later
Stop all running tasks to eliminate Fargate charges while keeping your infrastructure in place. You can resume later by setting the desired count back to 1.
- Scale both ECS services to zero by running these commands:
aws ecs update-service --cluster my-ecs-cluster --service my-ecs-app-service --desired-count 0
aws ecs update-service --cluster my-ecs-cluster --service my-app-staging --desired-count 0
With zero running tasks, you will not be charged for Fargate compute. Your cluster, services, ECR images, and IAM configuration all remain intact for when you return.
- To resume later, run the same commands with --desired-count 1 to bring your services back up.
Delete - I don't want to use this again
Remove all project resources completely. This stops all charges and cannot be undone.
- Delete both ECS services by running:
aws ecs delete-service --cluster my-ecs-cluster --service my-ecs-app-service --force
aws ecs delete-service --cluster my-ecs-cluster --service my-app-staging --force
- Delete the ECS cluster by running:
aws ecs delete-cluster --cluster my-ecs-cluster
- Delete the ECR repository and all stored images by running:
aws ecr delete-repository --repository-name my-ecs-app --force
- Detach the managed policies from the IAM role, then delete the role by running:
aws iam detach-role-policy --role-name github-actions-ecs-deploy --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser
aws iam detach-role-policy --role-name github-actions-ecs-deploy --policy-arn arn:aws:iam::aws:policy/AmazonECS_FullAccess
aws iam delete-role --role-name github-actions-ecs-deploy
- Delete the OIDC Identity Provider. First, find the provider ARN by running:
aws iam list-open-id-connect-providers
- Copy the ARN from the output that contains token.actions.githubusercontent.com, then delete it by running:
aws iam delete-open-id-connect-provider --open-id-connect-provider-arn [[OIDC_PROVIDER_ARN="arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"]]
- Delete your local project folder by running:
cd ~/Desktop
rm -rf my-ecs-app
- Optionally, delete your GitHub repository from the repository's Settings page. Scroll to the bottom and click Delete this repository.
Nice Work!
Nice Work!
You just built a production-grade CI/CD pipeline from scratch. Every push to main now triggers a fully automated build, push, and deployment to AWS ECS Fargate.
You've learned how to:
- Containerized a Node.js application with Docker and deployed it to AWS ECS Fargate infrastructure including an ECR repository, cluster, task definition, and service.
- Configured secure, keyless OIDC authentication between GitHub Actions and AWS so your pipeline never stores long-lived credentials.
- Created a GitHub Actions workflow that automatically builds, tags with the commit SHA, pushes to ECR, and deploys to ECS on every push to main.
- Added a staging environment that deploys on pull requests, letting you preview changes before they reach production.
Ready to quiz yourself?