CI/CD Pipeline with GitHub Actions
Build a production CI/CD pipeline that pushes containers to Amazon ECR.
Introduction
30 Second Summary
Every time you update code at work, someone has to make sure it still works, package it up, and ship it to users. Production teams automate this entire process so that code goes from a developer's laptop to a live server without a single manual step.
In this project, you will build a multi-stage CI/CD pipeline using GitHub Actions and Amazon ECR that automatically lints, tests, builds, and publishes a containerized application every time code is merged to main.
What You'll Build
A production-grade CI/CD pipeline that enforces a PR-based workflow where nothing reaches production without passing automated checks and a human approval gate.
By the end of this project, you'll have:
- A GitHub Actions workflow with three chained jobs that lint, test, build, and push a container image to a private registry.
- A secure OIDC federation between GitHub and AWS that authenticates your pipeline with zero stored secrets.
- A deployment gate using GitHub Environments that requires manual approval before the publish job runs.
- Secret Mission: Add matrix testing across Node.js versions and dependency caching to speed up your pipeline.
Are there any prerequisites?
You'll need a GitHub account and an AWS account. No prior experience with GitHub Actions or AWS IAM is required.
Before We Start
A CI/CD pipeline automates the journey from code to production. For your pipeline to build containers and push them to AWS, you need two foundational tools on your machine: a JavaScript runtime to build and test the app, and the AWS CLI to manage cloud resources from your terminal.
In this step, you will install Node.js and the AWS CLI, then create a GitHub repository that will host your pipeline code.
In this step, get ready to:
- Install Node.js v24 LTS and verify it works.
- Install and configure the AWS CLI v2.
- Create a GitHub repository and initialize a Node.js project.
Install Node.js v24 LTS
Node.js is the JavaScript runtime that will power your Express API and run your tests locally.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or cmd (Windows) and press Enter to open it.
- Check whether Node.js is already installed by running this command:
node --version
✔️ I see version 24 or higher
You are all set. Move on to the next substep.
ⓧ I see an older version
You have Node.js installed, but this project requires version 24 or higher.
- Visit nodejs.org/en/download and download the v24 LTS installer for your operating system.
- Run the installer and follow the prompts to complete the upgrade.
- Close and reopen your terminal, then run node --version again to confirm it shows v24 or higher.
macOS users with an older system Node
If your terminal still shows an older version after installing, the old binary may be shadowing the new one. Run which node to check the path. The new installer typically places Node at /usr/local/bin/node.
ⓧ Command not found
Node.js is not installed yet. Let's fix that.
- Visit nodejs.org/en/download and download the v24 LTS installer for your operating system (macOS or Windows).
- Run the installer and accept the default settings.
- Close and reopen your terminal, then run node --version to confirm it shows v24 or higher.
Still seeing command not found?
On Windows, you may need to close and reopen your Command Prompt or PowerShell window after installation for the PATH to update. On macOS, open a new Terminal tab.
help me troubleshoot why node --version is not found after I installed Node.js
Install and configure the AWS CLI v2
The AWS CLI lets you manage cloud resources directly from your terminal. You will use it later to create an ECR repository and manage IAM resources.
- Check whether the AWS CLI is already installed by running:
aws --version
✔️ I see aws-cli/2.x.x
Your AWS CLI v2 is ready. Skip ahead to the configuration step below.
ⓧ I see aws-cli/1.x.x or an older v2
You have an older version. Follow the install instructions in the next tab to upgrade to the latest v2.
ⓧ Command not found
The AWS CLI is not installed yet. Install it using the instructions for your operating system.
macOS
- Download and install the AWS CLI v2 package by running these commands:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
What do these commands do?
The first command downloads the official AWS CLI installer package from Amazon. The second command runs the macOS package installer with admin privileges, installing the CLI system-wide.
Windows
- Download and run the AWS CLI v2 MSI installer by running this command:
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2.msi
What does this command do?
This uses the Windows Installer tool to download and install the official AWS CLI v2 package. You can also download the installer directly from https://awscli.amazonaws.com/AWSCLIV2.msi and double-click it.
- Close and reopen your terminal, then confirm the installation by running:
aws --version
You should see output starting with aws-cli/2. followed by a version number.
Command still not found after install?
Make sure you fully closed and reopened your terminal window. On Windows, search for cmd in the Start menu and open a fresh Command Prompt.
help me troubleshoot why aws --version is not found after I installed the AWS CLI v2
Now configure the CLI with your AWS credentials and default region.
- Run the configuration wizard by entering:
aws configure
What will aws configure ask me?
The wizard prompts you for four values:
- AWS Access Key ID and AWS Secret Access Key from your IAM user. If you do not have these yet, create an IAM user in the AWS Console with programmatic access and copy the keys.
- Default region name enter us-east-1 (this project uses us-east-1 for ECR).
- Default output format enter json.
- Enter your AWS Access Key ID when prompted.
- Enter your AWS Secret Access Key when prompted.
- Enter us-east-1 for the default region.
- Enter json for the default output format.
Don't have an AWS Access Key?
In the AWS Console, go to IAM, select Users, choose your user, then open the Security credentials tab. Click Create access key and copy both values before closing the dialog.
help me create an IAM user access key in the AWS Console so I can configure the AWS CLI
Create a GitHub repository and initialize the project
Your GitHub Actions pipeline lives inside a GitHub repository. The repo needs to be public so you can use GitHub Environments on a free plan.
- Open github.com/new in your browser.
- Enter ci-cd-pipeline-app as the repository name.
- Select Public for visibility.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
Why does the repo need to be public?
GitHub Environments with protection rules (like manual approval gates) are only available for public repositories on the GitHub Free plan. Making the repo public ensures you can use all the pipeline features in this project at no cost.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your new repository and move into it by running these commands:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/ci-cd-pipeline-app.git
cd ci-cd-pipeline-app
What does git clone do?
This downloads a copy of your GitHub repository to your computer. The cd command then moves your terminal inside the new folder so all future commands run in the right place.
- Initialize a Node.js project by running:
npm init -y
What does npm init -y do?
This creates a package.json file with default values. The -y flag skips all the interactive prompts and accepts defaults. The package name is automatically set to the folder name (ci-cd-pipeline-app).
- Confirm the file was created by running:
ls
You should see package.json listed in the output.
Git clone failed?
Make sure you replaced the username in the clone URL with your actual GitHub username. Also confirm you are signed into git on your machine. If prompted for credentials, use a personal access token instead of your password.
help me fix a git clone authentication error for my GitHub repository
✔️ Awesome, I've got everything!
Great. Double check you have saved your work and your terminal is inside the ci-cd-pipeline-app folder.
ⓧ I'd like to double check the full code
Here is what your package.json should look like after running npm init -y:
{
"name": "ci-cd-pipeline-app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
Your development environment is ready. Next up, you will build an Express API with automated tests that your pipeline will run on every push.
Build the Express API with Tests
Your development environment is ready. Now it's time to build the application that your CI/CD pipeline will test, build, and deploy.
Every production pipeline needs something to pipeline. You'll create a minimal Express API with a health check endpoint, then add automated tests and linting to ensure code quality before it ever reaches production.
In this step, get ready to:
- Install Express, Jest, and ESLint as project dependencies.
- Create a health check API with a test that verifies it works.
- Configure linting to catch code quality issues automatically.
Install dependencies and create the Express API
Your API needs a web framework and a testing library. Express handles HTTP requests. Jest runs your tests automatically.
- Install Express as a production dependency and Jest as a dev dependency by running these commands:
npm install express
npm install --save-dev jest
What do these commands do?
`npm install express` adds Express to your `dependencies` in `package.json`. Your app needs it at runtime.
`npm install --save-dev jest` adds Jest to `devDependencies`. It's only needed during development and CI, not in the production container.
- Confirm the installation succeeded by running:
ls node_modules | head -5
You should see a list of package folder names. A node_modules folder and a package-lock.json file now exist in your project.
Seeing permission errors?
Make sure you're running the commands inside your cloned repository folder. If you see EACCES errors, avoid using sudo. Reinstall Node.js with the official installer instead.
Help me fix npm permission errors on my machine.
- Create a src folder for your application code by running:
mkdir src
- In VS Code's **Explorer** sidebar, right-click the src folder and select **New File**.
- Name the file app.js and paste the following code:
const express = require('express');
const app = express();
// Health check endpoint - returns JSON status
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
module.exports = app;
What does this code do?
`app.get('/health', ...)` registers a GET route at /health. When someone hits this endpoint, the server responds with a JSON object containing a status string and the current timestamp.
`module.exports = app` exports the Express app without starting it. This separation lets your tests import the app and spin up a server on a random port, avoiding conflicts.
- Save src/app.js.
You won't see a change yet. The next file starts the server so you can test this endpoint.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before continuing.
ⓧ I'd like to double check the full code
const express = require('express');
const app = express();
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
module.exports = app;
- Right-click the src folder again in the **Explorer** sidebar and select **New File**.
- Name the file server.js and paste the following code:
const app = require('./app');
const PORT = process.env.PORT || 3000;
// Start the Express server
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
What does this code do?
`process.env.PORT || 3000` reads the port from an environment variable, falling back to 3000 for local development. This pattern lets the same code run in different environments without code changes.
`app.listen(PORT, ...)` starts the HTTP server. The callback logs a confirmation message so you know it's running.
- Save src/server.js.
- Test that the server starts by running:
node src/server.js
You should see Server running on port 3000 in your terminal.
- Press **Ctrl+C** to stop the server.
Seeing 'Cannot find module' error?
Confirm that app.js is inside the src folder and that the filename matches exactly. The require('./app') in server.js looks for src/app.js relative to itself.
Help me fix this module not found error.
✔️ Awesome, I've got everything!
Great. Make sure you stopped the server with **Ctrl+C** before continuing.
ⓧ I'd like to double check the full code
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
- Open package.json in VS Code.
- Find "description": "" and change it to "description": "Production CI/CD pipeline demo app".
- Find "main": "index.js" and change it to "main": "src/server.js".
- In the "scripts" object, add a start script above the existing test line: "start": "node src/server.js",.
- Remove the "keywords", "author", and "license" fields. They're not needed for this project.
- Save package.json.
- Verify the start script works by running:
npm start
You should see Server running on port 3000 again. Press **Ctrl+C** to stop it.
Write unit tests for the health endpoint
A CI pipeline is only useful if it can verify your code works. You'll write a test that starts the server, hits the health endpoint, and checks the response.
- Create a tests folder by running:
mkdir tests
- In VS Code's **Explorer** sidebar, right-click the tests folder and select **New File**.
- Name the file app.test.js and paste the following code:
const http = require('http');
const app = require('../src/app');
let server;
// Start a test server on a random available port
beforeAll((done) => {
server = http.createServer(app);
server.listen(0, done);
});
// Shut down the test server after all tests finish
afterAll((done) => {
server.close(done);
});
test('GET /health returns 200 with status healthy', async () => {
const address = server.address();
const url = `http://localhost:${address.port}/health`;
const response = await fetch(url);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.status).toBe('healthy');
expect(data.timestamp).toBeDefined();
});
What does this test do?
`server.listen(0, done)` starts the Express app on a random available port. Using port 0 avoids conflicts with anything else running on your machine.
The test uses fetch (built into Node.js 24) to make a real HTTP request to the running server. It checks three things: the response code is 200, the status field is 'healthy', and a timestamp exists.
- Save tests/app.test.js.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before continuing.
ⓧ I'd like to double check the full code
const http = require('http');
const app = require('../src/app');
let server;
beforeAll((done) => {
server = http.createServer(app);
server.listen(0, done);
});
afterAll((done) => {
server.close(done);
});
test('GET /health returns 200 with status healthy', async () => {
const address = server.address();
const url = `http://localhost:${address.port}/health`;
const response = await fetch(url);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.status).toBe('healthy');
expect(data.timestamp).toBeDefined();
});
Now you need to tell npm how to run your tests. Jest looks for files matching *.test.js by default. You just need to point the test script at Jest.
- In package.json, find this line in the "scripts" section:
"test": "echo \"Error: no test specified\" && exit 1"
- Replace it with:
"test": "jest"
- Save package.json.
- Run the tests by executing:
npm test
You should see output ending with Tests: 1 passed, 1 total and a green PASS indicator.
Tests failing or not found?
Make sure your test file is named app.test.js (not app.test or apptest.js). Jest discovers tests by the .test.js suffix.
If you see Cannot find module '../src/app', confirm that your test file is inside the tests folder (not src).
Help me debug my failing Jest test.
Add ESLint for code quality checks
Linting catches common bugs like undefined variables before your code ever runs. Your CI pipeline will run the linter on every push, so issues get flagged immediately.
- Install ESLint as a dev dependency by running:
npm install --save-dev eslint
Why ESLint as a dev dependency?
Linters only run during development and in CI. They never ship to production. Installing as a dev dependency keeps your production container smaller and your dependency tree focused.
- Create a file called eslint.config.js in your project root (the same level as package.json). Paste the following code:
module.exports = [
{
files: ['**/*.js'],
rules: {
'no-unused-vars': 'error',
'no-undef': 'error'
},
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
require: 'readonly',
module: 'readonly',
exports: 'readonly',
process: 'readonly',
console: 'readonly',
__dirname: 'readonly',
__filename: 'readonly'
}
}
},
{
files: ['tests/**/*.js'],
languageOptions: {
globals: {
test: 'readonly',
expect: 'readonly',
describe: 'readonly',
it: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
fetch: 'readonly'
}
}
}
];
What does this config do?
The first object applies to all .js files. It enables two rules: no-unused-vars (catches dead code) and no-undef (catches typos in variable names). The globals section tells ESLint that Node.js built-ins like require and process are valid.
The second object adds Jest-specific globals (test, expect, beforeAll, etc.) so ESLint won't flag them as undefined in your test files.
- Save eslint.config.js.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before continuing.
ⓧ I'd like to double check the full code
module.exports = [
{
files: ['**/*.js'],
rules: {
'no-unused-vars': 'error',
'no-undef': 'error'
},
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
require: 'readonly',
module: 'readonly',
exports: 'readonly',
process: 'readonly',
console: 'readonly',
__dirname: 'readonly',
__filename: 'readonly'
}
}
},
{
files: ['tests/**/*.js'],
languageOptions: {
globals: {
test: 'readonly',
expect: 'readonly',
describe: 'readonly',
it: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
fetch: 'readonly'
}
}
}
];
Now add a lint script so you can run ESLint from npm.
- In package.json, find the "scripts" section and add a lint script after the test line:
"lint": "eslint src/ tests/"
- Save package.json.
- Verify linting passes by running:
npm run lint
If ESLint finds no issues, the command exits silently with no output. That silence means success.
Seeing linting errors?
If ESLint reports 'require' is not defined, your eslint.config.js is missing the globals section. Double check that sourceType: 'commonjs' and the Node.js globals are present.
Help me fix ESLint configuration errors in my project.
✔️ Awesome, I've got everything!
Great. Your package.json should now have start, test, and lint scripts all working.
ⓧ I'd like to double check the full code
{
"name": "ci-cd-pipeline-app",
"version": "1.0.0",
"description": "Production CI/CD pipeline demo app",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test": "jest",
"lint": "eslint src/ tests/"
},
"dependencies": {
"express": "^4.21.0"
},
"devDependencies": {
"eslint": "^9.0.0",
"jest": "^29.7.0"
}
}
Your Express API is built, tested, and linted. Next up, you'll create the GitHub Actions workflow that runs these checks automatically on every push.
Create the CI Workflow
Your app passes lint and tests locally. But production teams don't trust local results alone.
They enforce these checks automatically on every code change using a CI/CD pipeline. In this step, you'll create a GitHub Actions workflow that runs your linter and tests on every push and pull request to main.
In this step, get ready to:
- Create a GitHub Actions workflow file.
- Push the workflow on a feature branch and open a pull request.
- Merge the PR after CI passes.
Create the workflow file
GitHub Actions looks for workflow files inside a special .github/workflows directory in your repository. Any .yml file you place there becomes a workflow that GitHub can discover and run.
- Create the required directory structure by running this command:
mkdir -p .github/workflows
- Confirm the directory exists by running:
ls .github
You should see workflows in the output.
- Create the workflow file by running:
touch .github/workflows/ci.yml
- Open .github/workflows/ci.yml in VS Code and paste the following code:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write
contents: read
packages: write
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
What does this workflow do?
- on: defines the triggers. This workflow runs on every push to main and every pull_request targeting main.
- permissions: declares what the workflow is allowed to do. The id-token: write permission is needed later for OIDC authentication with AWS.
- runs-on: ubuntu-latest tells GitHub to run this job on a fresh Ubuntu virtual machine.
- actions/checkout@v6 clones your repository code into the runner.
- actions/setup-node@v4 installs Node.js 24 on the runner so your scripts can execute.
- npm ci installs dependencies from the lockfile for reproducible builds. Then npm run lint and npm test run the same checks you ran locally.
Why npm ci instead of npm install?
In CI environments, npm ci is preferred because it installs exact versions from package-lock.json without modifying the lockfile. This ensures the runner uses the same dependency versions you tested locally.
- Save .github/workflows/ci.yml.
Not sure the file is in the right place?
The directory structure must be exactly .github/workflows/ci.yml from your project root. If the file is anywhere else, GitHub will not discover it.
Run ls .github/workflows/ and confirm you see ci.yml in the output.
Help me check my workflow file path
✔️ 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
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write
contents: read
packages: write
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
Push the workflow and open a pull request
Production teams never push directly to main. They use feature branches and pull requests so that every change gets reviewed and tested before it merges.
- Create a new feature branch by running:
git checkout -b add-ci-workflow
- Stage and commit all your project files by running:
git add .
git commit -m "Add CI workflow and application code"
- Push the branch to GitHub by running:
git push -u origin add-ci-workflow
Git will print a URL to create a pull request. You can use that link or follow the steps below.
- Open your repository on GitHub in your browser.
- Click the green Compare & pull request button that appears at the top of the repository page.
- Leave the title as-is and click Create pull request.
Your workflow triggers immediately because the pull_request event fired.
- Click the Actions tab at the top of your repository.
- Click the running workflow to see its progress.
You should see the ci job running through each step: checkout, setup Node.js, install dependencies, lint, and test. After a minute or so, all steps should show green checkmarks.
Seeing a red X on your workflow?
Click into the failed job and expand the step that failed. Common causes:
- YAML indentation error. Every level in the YAML file must use exactly 2 spaces. Tabs will cause a parse failure.
- Linting failure. If npm run lint fails on the runner, double-check that eslint.config.js was committed.
- Missing package-lock.json. The npm ci command requires a lockfile. Run npm install locally to generate it, commit, and push again.
Help me debug my failing CI workflow
Merge the PR to main
Once all checks pass, the PR shows a green "All checks have passed" banner. This is the gate that production teams rely on. No green checks, no merge.
- On your pull request page, scroll down to the merge section.
- Click Merge pull request.
- Click Confirm merge.
Your code is now on main. The push trigger fires again, running CI one more time to confirm the merged code is healthy.
Why does CI run twice?
The workflow triggers on both pull_request and push to main. The PR run verifies the branch before merging. The push run verifies the merged result on main itself.
This double-check catches rare merge conflicts where two individually passing PRs break when combined.
- Back in your terminal, switch to main and pull the merged code by running:
git checkout main
git pull origin main
Your CI pipeline is live. Every future push and PR will be automatically tested before anyone can merge. Next up, you'll connect GitHub Actions to AWS so the pipeline can push container images to a private registry.
Configure AWS OIDC and ECR
Your CI pipeline now runs lint and tests automatically. The next piece is connecting GitHub Actions to your AWS account so the pipeline can push container images without storing long-lived credentials.
Production teams solve this with OIDC federation. Instead of pasting AWS access keys into GitHub Secrets, your workflow requests a short-lived token scoped to a single job. If the token leaks, it expires in minutes. In this step, you will create the trust relationship between GitHub and AWS, then set up a private container registry to receive your images.
In this step, get ready to:
- Create an IAM OIDC identity provider that trusts GitHub Actions.
- Create an IAM role with least-privilege permissions for pushing container images.
- Create a private ECR repository to store your application images.
Create the IAM OIDC identity provider
An OIDC identity provider in IAM tells AWS to trust tokens issued by a specific external service. You will register GitHub's token endpoint so AWS can verify workflow tokens.
- Open the AWS Management Console in your browser and sign in to your account.
- Search for IAM in the top search bar and select the IAM service.
- In the left navigation pane, click Identity providers.
- Click Add provider.
- Under Configure provider, select OpenID Connect.
- In the Provider URL field, enter https://token.actions.githubusercontent.com.
- In the Audience field, enter sts.amazonaws.com.
- Click Add provider.
You should see a green success banner confirming the provider was created. The new provider now appears in your Identity providers list.
What is OIDC federation?
OIDC federation lets an external service (GitHub Actions) prove its identity to AWS using short-lived JSON Web Tokens. AWS validates the token signature against the provider URL you just registered.
The audience value sts.amazonaws.com tells AWS which service the token is intended for. This prevents tokens issued for other purposes from being used to assume your role.
Why OIDC instead of stored access keys?
AWS access keys stored as GitHub Secrets are long-lived. If a workflow is compromised, those keys can be exfiltrated and used until manually revoked.
OIDC tokens are generated per-job and expire in minutes. They are scoped to a specific repository and branch, so even if intercepted they cannot be reused.
Seeing an error about a duplicate provider?
If your account already has a provider registered for https://token.actions.githubusercontent.com, you cannot create a duplicate. Each provider URL must be unique within an AWS account. If it already exists, skip this substep and proceed to creating the role.
Help me troubleshoot my OIDC provider setup in AWS IAM.
Create the IAM role with a trust policy
The OIDC provider establishes trust, but you still need a role that GitHub Actions can assume. This role defines what permissions the workflow has in your account.
- In the IAM left navigation pane, click Roles.
- Click Create role.
- Select Web identity as the trusted entity type.
- Click Next.
- From the Identity provider dropdown, select token.actions.githubusercontent.com.
- In the Audience dropdown, select sts.amazonaws.com.
- In the GitHub organization field, enter your GitHub username (the owner of your repository).
- In the GitHub repository field, enter your repository name (without the username prefix).
- Leave the GitHub branch field empty to allow all branches.
- Click Next.
You are now on the permissions step. You need to attach a policy that grants permission to push images to ECR.
- In the search box, type AmazonEC2ContainerRegistryPowerUser.
- Check the box next to the AmazonEC2ContainerRegistryPowerUser managed policy.
- Click Next.
- In the Role name field, enter github-actions-ecr-role.
- Click Create role.
You should see a success banner confirming the role was created.
What does this trust policy do?
The role's trust policy allows the action sts:AssumeRoleWithWebIdentity from the OIDC provider. The condition key token.actions.githubusercontent.com:sub restricts which repository can assume the role.
By specifying your repository in the GitHub organization and repository fields, AWS automatically generates a trust policy that matches repo:YOUR-USERNAME/YOUR-REPO:*. Only workflows from your repository can request credentials.
What is least-privilege IAM?
The AmazonEC2ContainerRegistryPowerUser policy grants push and pull access to all ECR repositories in your account. For a production environment, you would scope this further to a single repository. For this project, it keeps the setup straightforward.
Now copy the role ARN for later use. You will need it when configuring the workflow.
- Click on the role name github-actions-ecr-role in the Roles list to open its detail page.
- Copy the ARN value displayed near the top of the page. It looks like arn:aws:iam::123456789012:role/github-actions-ecr-role.
- Save this ARN somewhere accessible. You will paste it into your workflow file in the next step.
Role creation failed with MalformedPolicyDocument?
AWS enforces identity-provider controls for shared OIDC providers like GitHub Actions. The trust policy must include a sub condition. Make sure you entered your GitHub organization (username) in the required field. Without it, AWS rejects the policy.
Help me fix a MalformedPolicyDocument error when creating my GitHub Actions OIDC role.
Create the private ECR repository
Amazon ECR (Elastic Container Registry) is a private container registry where your pipeline will push built Docker images. Each repository holds images for a single application.
- Switch back to your terminal and create the ECR repository by running this command:
aws ecr create-repository --repository-name ci-cd-pipeline-app --region us-east-1
What does this command do?
This creates a private repository named ci-cd-pipeline-app in the us-east-1 region. Private means only authenticated users and roles in your account can pull images from it.
You should see JSON output containing a repositoryUri field. It looks like 123456789012.dkr.ecr.us-east-1.amazonaws.com/ci-cd-pipeline-app.
- Note down the repositoryUri from the output. You will use it to verify image pushes later.
What about ECR costs?
ECR Private includes 500 MB per month of storage free for the first 12 months on new AWS accounts. The images you build in this project are only a few megabytes, so you will stay well within the free tier.
Getting an access denied or region error?
Make sure you ran aws configure in Step 1 and set your default region to us-east-1. If your credentials are expired, re-run aws configure to update them.
Help me fix an access denied error when creating an ECR repository.
Your AWS infrastructure is ready. You have an OIDC trust, a scoped IAM role, and a private registry waiting for images. Next up, you will write a Dockerfile and extend your workflow to build and push a container image on every merge to main.
Add Docker Build and Push to ECR
Your AWS infrastructure is ready. The OIDC provider trusts your GitHub repository, and the IAM role grants permission to push images to ECR.
Now you need something to push. In this step, you will containerize your Express app with a multi-stage Dockerfile, extend the workflow with a build-and-push job, and create a GitHub Environment that requires manual approval before the image reaches your registry.
In this step, get ready to:
- Write a multi-stage Dockerfile that produces a minimal production image.
- Add a build-and-push job to the workflow that authenticates via OIDC and publishes to ECR.
- Create a GitHub Environment with a manual approval gate so nothing deploys unreviewed.
Write the multi-stage Dockerfile
A multi-stage build separates the install step from the final runtime image. The first stage installs dependencies, and the second stage copies only what the app needs to run.
- Create a file called Dockerfile in the root of your project folder (the same level as package.json) by running:
touch Dockerfile
- Open Dockerfile in VS Code and paste the following:
# Build stage: install production dependencies
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Runtime stage: copy only what the app needs
FROM node:24-alpine
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY src/ ./src/
EXPOSE 3000
USER node
CMD ["node", "src/server.js"]
What does this Dockerfile do?
- The first FROM creates a build stage that installs only production dependencies (no dev tools like Jest or ESLint).
- The second FROM starts a fresh Alpine image and copies node_modules from the build stage, keeping the final image small.
- USER node runs the container as a non-root user, which is a security best practice.
- Save Dockerfile.
Why multi-stage builds?
Without multi-stage builds, your final image would include dev dependencies, build tools, and temporary files. Multi-stage builds let you throw away everything the app does not need at runtime, producing an image that is smaller and has a reduced attack surface.
Not sure where to put the Dockerfile?
The Dockerfile must live in the project root, next to package.json. If it is inside src/ or another subfolder, the Docker build context will not find your files.
Help me figure out where my Dockerfile should go.
Add the build-and-push job to your workflow
The new job depends on the CI job passing first. It only runs on pushes to main (not on pull requests), authenticates to AWS using your OIDC role, and pushes the container image to ECR.
- Open .github/workflows/ci.yml in VS Code.
- Find the end of the ci job (after the run: npm test line).
- Add the following build-and-push job below it, at the same indentation level as ci::
build-and-push:
needs: ci
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v6
- 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-ecr-role
aws-region: us-east-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ steps.login-ecr.outputs.registry }}/ci-cd-pipeline-app:${{ github.sha }}
${{ steps.login-ecr.outputs.registry }}/ci-cd-pipeline-app:latest
What does this job do?
- needs: ci forces this job to wait until linting and tests pass. If CI fails, the image never gets built.
- The if condition ensures container builds only happen on merges to main, not on pull requests.
- aws-actions/configure-aws-credentials@v6 uses your OIDC role to get short-lived AWS credentials with no stored secrets.
- aws-actions/amazon-ecr-login@v2 authenticates Docker to your private ECR registry.
- docker/build-push-action@v7 builds and pushes the image, tagging it with both the git commit SHA (for traceability) and latest.
Why tag with the git SHA?
Tagging images with the commit SHA lets you trace any running container back to the exact code that produced it. Production teams use this for rollbacks and debugging. The latest tag is a convenience pointer to the most recent build.
- Replace the AWS_ACCOUNT_ID placeholder with your actual 12-digit AWS account ID. You can find this in the AWS Console by clicking your username in the top-right corner.
- Save .github/workflows/ci.yml.
Not sure where to find your AWS account ID?
In the AWS Console, click your username or account alias in the top-right navigation bar. Your 12-digit account ID appears in the dropdown. Copy it without the hyphens.
Help me find my AWS account ID for the workflow file.
✔️ Awesome, I've got everything!
Great. Double check you have saved both Dockerfile and .github/workflows/ci.yml.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
FROM node:24-alpine
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY src/ ./src/
EXPOSE 3000
USER node
CMD ["node", "src/server.js"]
Here is the complete .github/workflows/ci.yml:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write
contents: read
packages: write
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
build-and-push:
needs: ci
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v6
- 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-ecr-role
aws-region: us-east-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ steps.login-ecr.outputs.registry }}/ci-cd-pipeline-app:${{ github.sha }}
${{ steps.login-ecr.outputs.registry }}/ci-cd-pipeline-app:latest
Create the GitHub Environment and trigger the pipeline
The workflow references environment: production, but that environment does not exist yet in GitHub. You need to create it and add a required reviewer so the build-and-push job pauses for human approval.
- Open your repository on GitHub.com.
- Click the Settings tab.
- In the left sidebar, click Environments.
- Click New environment.
- Enter production as the environment name.
- Click Configure environment.
You are now on the environment configuration page. Add a required reviewer so the job cannot run without your explicit approval.
- Check the Required reviewers checkbox.
- Type your own GitHub username and select it from the dropdown.
- Click Save protection rules.
Next, restrict which branches can trigger a deployment to this environment.
- Find the Deployment branches dropdown (below the protection rules section).
- Select Selected branches and tags.
- Click Add deployment branch or tag rule.
- Make sure Branch is selected in the Ref type dropdown.
- Enter main as the name pattern.
- Click Add rule.
What does the deployment branch restriction do?
This ensures only the main branch can deploy to the production environment. Even if someone modified the workflow to reference production from a feature branch, GitHub would block it.
Now push everything to main to trigger the full pipeline.
- Commit and push your changes by running these commands:
git add .
git commit -m "Add Dockerfile and build-and-push job"
git push origin main
- Open the Actions tab in your GitHub repository.
- Click on the latest workflow run.
You should see the ci job complete successfully. The build-and-push job shows a yellow "Waiting" badge because it requires your approval to proceed.
Why does the workflow pause?
The environment: production line in the workflow links the job to your GitHub Environment. Because you added yourself as a required reviewer, GitHub holds the job until you explicitly approve it. This is the deployment gate that production teams use to prevent accidental releases.
Build-and-push job not appearing?
The build-and-push job only runs on pushes to main. If you pushed to a different branch or opened a PR, only the ci job will appear. Make sure you pushed directly to main.
Also check that the YAML indentation is correct. The build-and-push: key must be at the same level as ci: inside the jobs: block.
Help me debug why my build-and-push job is not showing up in GitHub Actions.
- Click Review deployments on the waiting job.
- Check the production environment checkbox.
- Click Approve and deploy.
The build-and-push job now runs. It authenticates to AWS, builds your Docker image, and pushes it to your ECR repository. When it finishes with a green checkmark, your container image is live in ECR.
Seeing an OIDC authentication error?
If the Configure AWS credentials step fails, check that your IAM role's trust policy condition matches your repository path exactly. The sub claim format is repo:YOUR-USERNAME/YOUR-REPO:environment:production when the job uses a GitHub Environment. Make sure your trust policy's condition allows this pattern.
Also confirm that the role-to-assume ARN in your workflow matches the role name exactly: github-actions-ecr-role.
Help me fix my OIDC authentication error in GitHub Actions.
Secret mission
Add Matrix Testing and Dependency Caching
Your pipeline tests on a single Node.js version right now. Production teams test across multiple versions to catch compatibility issues early. You will add matrix testing for Node.js 22 and 24, plus npm dependency caching to dramatically speed up every workflow run.
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 IAM OIDC provider and IAM role cost nothing. Your ECR repository stores a few megabytes of container images, well within the 500 MB free tier for the first 12 months.
Cost warning
After 12 months, ECR Private storage costs $0.10/GB/month. Your images are only a few MB, so the charge would be minimal. If you want to avoid any future cost, delete the ECR repository in the Delete tab below.
Resources you used:
- Amazon ECR Private Repository (ci-cd-pipeline-app).
- IAM Role (github-actions-ecr-role).
- IAM OIDC Identity Provider (token.actions.githubusercontent.com).
- GitHub Environment (production).
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you plan to keep iterating on the pipeline or want to reference it later.
- The IAM OIDC provider and IAM role are free. They only generate temporary credentials when a workflow runs.
- ECR images are a few MB total, well within the 500 MB free tier for 12 months.
- The GitHub Environment is free and stays configured in your repository settings.
- Your local project files remain on your Desktop.
Pause - I'll come back to this later
Nothing to pause. All resources in this project are only active when the pipeline runs. They do not consume compute or incur costs while idle.
- The OIDC provider and role sit dormant until a workflow triggers.
- ECR images are stored but not running. Storage stays within free tier for 12 months.
- No running servers or containers to stop.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps in order.
Delete the ECR repository and all its images by running this command:
aws ecr delete-repository --repository-name ci-cd-pipeline-app --region us-east-1 --force
Delete the IAM role:
- Open the IAM console at https://console.aws.amazon.com/iam/.
- In the navigation pane, choose Roles.
- Select the checkbox next to github-actions-ecr-role.
- Choose Delete at the top of the page.
- Type the role name to confirm, then choose Delete.
Delete the IAM OIDC identity provider:
- In the IAM console navigation pane, choose Identity providers.
- Select the checkbox next to the token.actions.githubusercontent.com provider.
- A confirmation window opens. Type delete in the field to confirm, then choose Delete.
Delete the GitHub Environment:
- In your GitHub repository, go to Settings.
- In the left sidebar, select Environments.
- Click the production environment.
- Scroll to the bottom and click Delete environment. Confirm the deletion.
Delete your local project files:
macOS
cd ~/Desktop
rm -rf ci-cd-pipeline-app
Windows
cd ~\Desktop
rmdir /s /q ci-cd-pipeline-app
Optionally, delete the GitHub repository itself from your GitHub account under Settings > General > scroll to the Danger Zone section and click Delete this repository.
Nice Work!
Nice Work!
Nice work! You just built a production-grade CI/CD pipeline that automatically lints, tests, containerizes, and publishes your application to Amazon ECR on every merge to main.
You've learned how to:
- Create a multi-stage CI/CD pipeline with chained jobs that enforce a test-before-build order, and gate production deployments behind manual approval using GitHub Environments.
- Configure OIDC federation between GitHub Actions and AWS so your pipeline authenticates with short-lived tokens instead of stored credentials.
- Containerize a Node.js application with a multi-stage Dockerfile and push SHA-tagged images to a private Amazon ECR repository.
- Secret Mission: Added matrix testing across Node.js versions and dependency caching to speed up workflow runs.
Ready to quiz yourself?