Deploy a Dockerized Node.js App to EC2
Build a CI/CD pipeline that auto-deploys a Node.js app to AWS EC2.
Introduction
30 Second Summary
Every time a developer finishes writing code, someone has to get that update onto a live server. Without automation, this means manually connecting, rebuilding, and restarting. One missed step and the whole deployment breaks.
In this project, you will build a fully automated pipeline that tests, builds, and deploys a Node.js app to a live cloud server every time you push code.
What You'll Build
A complete CI/CD pipeline using GitHub Actions, Docker, and AWS EC2 that takes a Node.js Express API from local development to automated cloud deployment.
By the end of this project, you'll have:
- A Dockerized Node.js API running on a live EC2 instance accessible via a public URL.
- A GitHub Actions workflow that automatically tests, builds, and deploys your app on every push to main.
- GitHub Secrets configured for secure SSH access to your server.
- A staging environment with branch-based deployments (Secret Mission).
Are there any prerequisites?
You'll need basic Node.js experience and familiarity with Git and GitHub. You'll also need an AWS account with free tier access.
Set Up Your Local Tools
Your CI/CD pipeline depends on a few tools working together on your machine. In this step, you'll make sure each one is installed and ready.
Docker packages your app into a container that runs the same way everywhere. Git connects your project to GitHub, where your automation pipeline lives.
In this step, get ready to:
- Install Docker Desktop on your machine.
- Verify that Node.js and Git are working.
- Create the project folder and install Express.
Install Docker Desktop
Docker packages your application along with all its dependencies into a single container. That container runs identically on your laptop, a teammate's machine, or an AWS server.
macOS
- Open the Docker Desktop for Mac download page.
- Click the download button that matches your Mac's chip (Apple Silicon or Intel).
- Open the downloaded .dmg file.
- Drag the Docker icon into your **Applications** folder.
- Press Cmd+Space to open Spotlight.
- Type Docker and press Enter to open Docker Desktop.
- Wait for Docker Desktop to finish starting. You'll see the Docker whale icon in your menu bar when it's ready.
Windows
- Open the Docker Desktop for Windows download page.
- Click Download Docker Desktop for Windows.
- Run the downloaded installer.
- Make sure the Use WSL 2 instead of Hyper-V checkbox is enabled.
- Click OK and wait for the installation to complete.
- Restart your computer if the installer asks you to.
- Press the Windows key to open your search bar.
- Type Docker Desktop and press Enter to open it.
- Wait for Docker Desktop to finish starting. You'll see the Docker whale icon in your system tray when it's ready.
What is WSL 2?
WSL 2 (Windows Subsystem for Linux) lets Docker run Linux containers natively on Windows. Docker Desktop uses it as its default backend.
- Verify Docker is installed by running this command:
docker --version
I see a version number
Docker is installed and ready. You can move on to the next substep.
Command not found
Docker Desktop needs to be fully running before the command works. Look for the whale icon in your menu bar (macOS) or system tray (Windows).
If the icon isn't there, open Docker Desktop and wait for it to finish loading. Then try the command again.
What is a container?
A container is a lightweight, standalone package that bundles your code, its runtime, and all its dependencies together. Unlike a virtual machine, containers share the host operating system's kernel. This makes them fast to start and efficient with resources.
Verify Node.js and Git
Your project needs Node.js to run JavaScript and Git to push code to GitHub. Let's confirm both are installed.
- Check your Node.js installation by running this command:
node --version
I see a version number
Node.js is ready. Move on to checking Git.
Command not found
- Download and install Node.js from the official website.
- Choose the LTS version.
- After installing, close and reopen your terminal, then run node --version again.
- Check your Git installation by running this command:
git --version
I see a version number
Git is ready. You're all set for version control.
It's not installed yet
- Download and install Git from the official website.
- Follow the installer prompts with the default settings.
- After installing, close and reopen your terminal, then run git --version again.
You also need a GitHub account for this project. If you don't have one yet, create a free account at github.com.
Create the project
With all three tools confirmed, it's time to create the project folder and install your first dependency.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir node-cicd-pipeline
cd node-cicd-pipeline
- Confirm the folder was created by running:
ls
The folder will be empty for now. You should see no output, which means you're inside a brand-new directory.
- Initialize the project by running this command:
npm init -y
What does npm init -y do?
This command creates a package.json file with default values. The -y flag skips all the interactive prompts and accepts the defaults automatically.
- Install Express as your project's first dependency by running this command:
npm install express@5
Why Express 5?
Express 5 is the current stable version of the Express framework. It has cleaner error handling and drops legacy middleware that older versions carried.
- Confirm everything was created by running:
ls
You should see node_modules, package.json, and package-lock.json in the output.
npm install failed?
- Check your internet connection. npm install downloads packages from the npm registry, so it needs network access.
- Make sure you're inside the node-cicd-pipeline folder. Run pwd to check your current directory.
- If you see a permissions error, try running the command with sudo on macOS/Linux, or open your terminal as Administrator on Windows.
Still stuck? Help me troubleshoot my npm install error.
Your local environment is ready to go. In the next step, you'll build the Express API that your pipeline will test and deploy.
Build the Express API and Tests
Your local environment is set up with Node.js, Docker, and a fresh project folder. Now it's time to write some code.
Every CI/CD pipeline needs something to test and deploy. In this step, you'll build a minimal Express API with two endpoints and a test suite that verifies they work.
In this step, get ready to:
- Create an Express API with a root endpoint and a health check endpoint.
- Write automated tests using the Node.js built-in test runner.
- Verify everything works locally before containerizing the app.
Create the Express server
Your server needs two endpoints. A root endpoint (GET /) returns a JSON message proving the app is alive. A health check endpoint (GET /health) returns a simple status that automated monitoring tools can ping.
- Create a new file called server.js in your node-cicd-pipeline folder, then paste in the following code:
const express = require("express");
const app = express();
const PORT = process.env.PORT || 3000;
// Root endpoint - returns a welcome message with version info
app.get("/", (req, res) => {
res.json({
message: "Hello from the CI/CD pipeline!",
version: "1.0.0",
timestamp: new Date().toISOString()
});
});
// Health check endpoint - returns a simple status for monitoring
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
What does this code do?
- express() creates your application instance. PORT defaults to 3000 but can be overridden by an environment variable.
- The root endpoint (/) returns a JSON object with a message, a version number, and a timestamp. You'll use this later to confirm deployments are working.
- The health check endpoint (/health) returns { status: "ok" }. This is a standard pattern for checking whether a server is responsive.
- At the bottom of server.js, add the following code below the health check endpoint:
// Only start the server when this file is run directly
// This lets the test file import the app without starting a server
if (require.main === module) {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
}
module.exports = app;
Why wrap app.listen() in a condition?
require.main === module checks if this file was run directly (like node server.js). If another file imports it (like your test file), the server won't auto-start.
module.exports = app exports the app so your tests can import it and start it on their own port. Without this pattern, your tests would collide with a running server.
- Save server.js.
You won't see a change yet. The next substep creates the tests that use this server.
Awesome, I've got everything!
Double check you've saved server.js before moving on.
I'd like to double check the full code
const express = require("express");
const app = express();
const PORT = process.env.PORT || 3000;
app.get("/", (req, res) => {
res.json({
message: "Hello from the CI/CD pipeline!",
version: "1.0.0",
timestamp: new Date().toISOString()
});
});
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
if (require.main === module) {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
}
module.exports = app;
Add automated tests
Node.js ships with a built-in test runner, so you don't need to install Jest or Mocha. Your tests will use three built-in modules: node:test for test structure, node:assert for assertions, and node:http for making HTTP requests to your server.
- Create a new folder called test inside your node-cicd-pipeline directory.
- Create a new file called app.test.js inside the test folder, then paste in the following code:
const { describe, it, after } = require("node:test");
const assert = require("node:assert");
const http = require("node:http");
const app = require("../server");
let server;
describe("API Endpoints", () => {
// Close the server after all tests finish
after(() => {
if (server) server.close();
});
it("GET / returns a JSON message", (_, done) => {
// Start the app on a random available port
server = app.listen(0, () => {
const port = server.address().port;
http.get(`http://localhost:${port}/`, (res) => {
let data = "";
res.on("data", (chunk) => { data += chunk; });
res.on("end", () => {
const body = JSON.parse(data);
assert.strictEqual(res.statusCode, 200);
assert.ok(body.message);
assert.strictEqual(body.version, "1.0.0");
done();
});
});
});
});
it("GET /health returns status ok", (_, done) => {
server = app.listen(0, () => {
const port = server.address().port;
http.get(`http://localhost:${port}/health`, (res) => {
let data = "";
res.on("data", (chunk) => { data += chunk; });
res.on("end", () => {
const body = JSON.parse(data);
assert.strictEqual(res.statusCode, 200);
assert.strictEqual(body.status, "ok");
done();
});
});
});
});
});
What does this test file do?
- require("../server") imports your app without starting it. This is why you added the require.main === module guard.
- app.listen(0) starts the server on a random available port. This avoids conflicts if port 3000 is already in use.
- Each test makes an HTTP request, collects the response data, parses it as JSON, and asserts specific values. The first test checks that / returns a message and version. The second checks that /health returns { status: "ok" }.
- after() shuts down the server once all tests finish so the process exits cleanly.
- Save test/app.test.js.
You won't see a change yet. The next substep adds the npm scripts you need to run these tests.
Seeing a red underline or error on the imports?
- Make sure the file is saved at test/app.test.js (inside a test folder), not directly in the project root. The ../server path goes up one level to find server.js.
- Confirm that server.js exists in the root of your node-cicd-pipeline folder. If it's missing or named differently, the import will fail.
Why is my test file showing import errors?
Add npm scripts and verify locally
Your CI/CD pipeline will use npm test and npm start to run your app. These commands only work if the matching scripts are defined in package.json.
- Open package.json in your code editor.
- Find the "scripts" section. It currently looks like this:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
}
- Replace the entire "scripts" block with the following:
"scripts": {
"start": "node server.js",
"test": "node --test test/"
}
What do these scripts do?
- "start": "node server.js" runs your Express server when you type npm start.
- "test": "node --test test/" uses the Node.js built-in test runner to find and run every test file inside the test/ directory.
- Save package.json.
Awesome, I've got everything!
Double check you've saved package.json before running the tests.
I'd like to double check the full code
{
"name": "node-cicd-pipeline",
"version": "1.0.0",
"description": "A Node.js app with CI/CD pipeline to AWS EC2",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "node --test test/"
},
"dependencies": {
"express": "^5.1.0"
}
}
- Run your test suite by running this command in your terminal:
npm test
You should see output showing both tests passing. Look for two checkmarks next to GET / returns a JSON message and GET /health returns status ok, with a summary of pass 2 and fail 0.
Tests failing or not running?
- If you see Error: no test specified, you still have the default test script in package.json. Make sure you replaced the entire "scripts" block and saved the file.
- If you see MODULE_NOT_FOUND for ../server, check that server.js is in the root of your node-cicd-pipeline folder (not inside the test folder).
- If you see Cannot find module 'express', run npm install to install the dependencies, then try npm test again.
Help me debug my failing tests.
- Start the server locally by running:
npm start
You should see Server running on port 3000 in your terminal.
- Open your browser and navigate to http://localhost:3000/health.
You should see {"status":"ok"} displayed in the browser. Navigate to http://localhost:3000/ to see the full JSON response with your message, version, and timestamp.
- Go back to your terminal and press Ctrl+C to stop the server.
Browser showing an error or refusing to connect?
- Check that your terminal still shows Server running on port 3000. If the server crashed, look at the error message in the terminal output.
- Make sure you're using http:// (not https://) in the URL. Your local server doesn't have SSL configured.
- If port 3000 is already in use by another process, you'll see an EADDRINUSE error. Close other apps using that port, or stop previous Node.js processes.
My Express server won't start or the browser can't connect.
Your API is built, tested, and verified locally. Next up, you'll package it into a Docker container so it runs the same way everywhere.
Dockerize the Application
Your Express API is built and both tests pass. The next step is to package it so it runs the same way everywhere.
Docker lets you bundle your app, its dependencies, and its runtime into a single container image. That image runs identically on your laptop, a teammate's machine, and an EC2 server in the cloud.
In this step, get ready to:
- Create a production-ready Dockerfile.
- Add a .dockerignore file to keep the image lean.
- Build and run the Docker container locally to verify everything works.
Create a Dockerfile
A Dockerfile is a recipe that tells Docker how to build your application into a container image. Each line is an instruction that adds a layer to the image.
What is a container image?
A container image is a lightweight, standalone snapshot of your application that includes the code, runtime, and dependencies. When you run an image, it becomes a live container. Think of the image as a blueprint and the container as the running copy.
- In VS Code, create a new file called Dockerfile in the root of your node-cicd-pipeline folder (no file extension).
- Paste the following code into the file:
# Use a lightweight Node.js base image
FROM node:22-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy dependency files first for efficient layer caching
COPY package*.json ./
# Install production dependencies only
RUN npm ci --omit=dev
# Copy the rest of the application source code
COPY . .
# Document the port the app uses
EXPOSE 3000
# Define the command to start the app
CMD ["node", "server.js"]
What does this code do?
- FROM node:22-alpine starts from a minimal Linux image with Node.js 22 pre-installed. Alpine is a tiny Linux distribution that keeps your image small.
- WORKDIR /app creates and switches to a /app directory inside the container. All subsequent commands run from here.
- COPY package*.json ./ copies only the dependency files first. Docker caches each layer, so if your dependencies haven't changed, it skips re-installing them on the next build.
- npm ci --omit=dev installs dependencies from the lockfile exactly. The --omit=dev flag skips dev dependencies since the production container doesn't need them.
- CMD ["node", "server.js"] tells Docker what command to run when the container starts.
Why copy package.json separately?
Docker builds images in layers and caches each one. By copying package*.json and installing dependencies before copying the rest of your code, Docker can reuse the cached dependency layer when only your source code changes. This makes rebuilds much faster.
- Save Dockerfile.
You won't see a change yet. Docker uses this file in the next substep when you build the image.
Seeing a file extension on your Dockerfile?
The file must be named exactly Dockerfile with no extension. If VS Code added .txt or another extension, rename it by right-clicking the file in the Explorer sidebar and selecting Rename.
Help me fix my Dockerfile naming.
Awesome, I've got everything!
Double check you've saved your Dockerfile.
I'd like to double check the full code
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Add a .dockerignore file
When Docker builds an image, it sends everything in your project folder to the Docker engine as the "build context." Some of those files are unnecessary inside the container.
A .dockerignore file tells Docker which files to skip. This keeps the image smaller and prevents sensitive or irrelevant files from ending up in the container.
- In VS Code, create a new file called .dockerignore in the root of your node-cicd-pipeline folder.
- Paste the following into the file:
# Dependencies (rebuilt inside the container)
node_modules
# Version control history
.git
# Docker config (not needed inside the image)
Dockerfile
.dockerignore
# Test files (not needed in production)
test/
# CI/CD workflow files
.github/
# Documentation
README.md
# Environment variables
.env
Why exclude these files?
- node_modules is excluded because the Dockerfile runs npm ci to install dependencies fresh inside the container.
- test/ and .github/ are only needed during development and CI. The production container only runs the app.
- .env files can contain secrets. Excluding them prevents credentials from being baked into the image.
- Save .dockerignore.
You won't see a change yet. Docker reads this file automatically during the build in the next substep.
Awesome, I've got everything!
Double check you've saved your .dockerignore file.
I'd like to double check the full code
node_modules
.git
Dockerfile
.dockerignore
test/
.github/
README.md
.env
Build and test the container locally
With both Docker files in place, you can build your container image and run it locally to verify the app works inside a container.
- Make sure Docker Desktop is running. Press Cmd+Space (macOS) or the Windows key (Windows), type Docker Desktop, and press Enter to open it.
- Make sure nothing is still running on port 3000. If you left npm start running from the previous step, stop it with Ctrl+C in that terminal.
- Build the Docker image by running this command in your terminal from the node-cicd-pipeline folder:
docker build -t node-cicd-app .
What does this command do?
- docker build reads your Dockerfile and builds a container image from it.
- -t node-cicd-app tags (names) the image node-cicd-app so you can reference it later.
- . tells Docker to use the current directory as the build context (where it looks for the Dockerfile and source files).
You should see numbered steps like [1/5] FROM node:22-alpine as Docker processes each layer of your Dockerfile. The first build takes a minute or two because Docker downloads the base image.
Seeing "Cannot connect to the Docker daemon"?
This means Docker Desktop isn't running. Open it and wait for the status indicator to show "Engine running" before trying again.
Help me fix Docker daemon connection issues.
- Once the build finishes, run the container by running this command:
docker run -p 3000:3000 node-cicd-app
What does -p 3000:3000 mean?
The -p 3000:3000 flag maps port 3000 on your machine to port 3000 inside the container. Without this, the container's port would be isolated and unreachable from your browser.
You should see Server running on port 3000 in your terminal. The container is now running in the foreground.
- Open your browser and navigate to http://localhost:3000/health.
You should see {"status":"ok"} in the browser. Your Express API is running inside a Docker container.
- Go back to your terminal and press Ctrl+C to stop the container.
Not seeing the health check response?
- Check your terminal for error messages. If you see port is already allocated, another process is using port 3000. Stop it first (you may have npm start still running in another terminal).
- Make sure you're visiting http://localhost:3000/health (not https).
- If the build failed earlier, check that your Dockerfile is named exactly Dockerfile (capital D, no extension) and is in the root of your project folder.
Help me troubleshoot my Docker container.
Your app now runs inside a portable container that works the same way on any machine with Docker installed. Next up, you'll launch an EC2 instance in the cloud and prepare it to host this container.
Launch and Configure the EC2 Instance
Your Docker container ran perfectly on your machine. Now it needs a home in the cloud where anyone can access it.
EC2 (Elastic Compute Cloud) is AWS's service for renting virtual servers on demand. In this step, you'll launch one, open the right network ports, and install Docker on it.
In this step, get ready to:
- Launch an EC2 instance running Ubuntu Server.
- Configure security group rules for SSH and app traffic.
- Install Docker on your cloud server.
Launch a t2.micro instance
You'll launch a t2.micro instance, which is the smallest EC2 server type. It is free tier eligible and powerful enough for your containerized app.
- Log in to the AWS Management Console at console.aws.amazon.com.
- Type EC2 in the search bar at the top.
- Select EC2 from the results.
- Click Launch instance.
The launch wizard opens as a single-page form. You'll fill out each section from top to bottom.
- In the Name field, enter node-cicd-server.
- Under Application and OS Images (Amazon Machine Image), click Ubuntu.
- Select Ubuntu Server 24.04 LTS. Confirm it says Free tier eligible beneath the AMI.
- Under Instance type, confirm t2.micro is selected.
Which instance type is free?
If your AWS account was created before July 15, 2025, t2.micro is free for 750 hours per month for 12 months.
Accounts created after that date receive free credits that cover t3.micro or t3.small usage instead. Either option works for this project.
- Under Key pair (login), click Create new key pair.
- Enter node-cicd-key as the Key pair name.
- Select RSA as the Key pair type.
- Select .pem as the Private key file format.
- Click Create key pair.
The node-cicd-key.pem file downloads to your computer automatically. This file is your private key for connecting to the server.
Keep your .pem file safe
Move the downloaded node-cicd-key.pem file to a folder you can find easily. You will need it every time you connect to your EC2 instance. If you lose this file, you cannot recover it.
Configure the security group
A security group acts as a firewall for your EC2 instance. It controls which types of network traffic can reach your server.
- Scroll down to the Network settings section.
- Click Edit.
- Under Firewall (security group), select Create security group if it is not already selected.
An SSH rule allowing traffic on port 22 is already included. You need to add two more rules.
- Click Add security group rule.
- Set the Type to HTTP.
- Set the Source type to Anywhere.
- Click Add security group rule again.
- Set the Type to Custom TCP.
- Enter 3000 in the Port range field.
- Set the Source type to Anywhere.
Why three ports?
- Port 22 (SSH) lets you connect to the server's terminal remotely.
- Port 80 (HTTP) is the standard web traffic port.
- Port 3000 is where your Express app listens for requests.
- Scroll down and click Launch instance.
AWS shows a success page with your instance ID. Your instance is now starting up.
- Click View all instances.
- Wait for the Instance state column to show Running. This takes about 30 seconds.
- Select your node-cicd-server instance by clicking its checkbox.
- In the details panel at the bottom, find and copy the Public IPv4 address. Save it somewhere handy.
Pro tip: save the IP address
Paste your EC2 public IP address into a note or text file. You'll use it multiple times in the next steps for SSH connections and GitHub Secrets.
Install Docker on the EC2 instance
Your server is running, but it does not have Docker installed yet. You'll connect to it remotely using SSH, then install Docker from the command line.
Linux users can follow the same commands shown in the macOS tab.
macOS
- Open your terminal.
- Set restrictive permissions on your key file by running:
chmod 400 ~/Downloads/node-cicd-key.pem
- Connect to your EC2 instance by running:
ssh -i ~/Downloads/node-cicd-key.pem ubuntu@[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]
Your terminal may ask you to verify the server's fingerprint. Type yes and press Enter to continue.
Windows
- Open PowerShell or Git Bash.
- Connect to your EC2 instance by running:
ssh -i ~/Downloads/node-cicd-key.pem ubuntu@[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]
Your terminal may ask you to verify the server's fingerprint. Type yes and press Enter to continue.
Seeing a permissions error?
If PowerShell shows a "Permissions for .pem are too open" error, switch to Git Bash and set restrictive permissions on your key file:
chmod 400 ~/Downloads/node-cicd-key.pem
Then retry the SSH command from Git Bash.
You should see a prompt that looks like ubuntu@ip-xxx-xxx-xxx-xxx:~$. You are now inside your EC2 instance.
Connection timed out?
- Double check that your security group has an SSH rule for port 22 with source set to Anywhere.
- Confirm the IP address matches the Public IPv4 address in your EC2 instance details.
- Make sure the instance state is Running, not Pending or Stopped.
Still stuck? Help me debug my SSH connection to EC2.
- Update the server's package list and install Docker by running these commands:
sudo apt update
sudo apt install docker.io -y
sudo usermod -aG docker ubuntu
What do these commands do?
- sudo apt update refreshes the list of available packages so you install the latest version.
- sudo apt install docker.io -y installs Docker. The -y flag auto-confirms the installation prompt.
- sudo usermod -aG docker ubuntu adds the ubuntu user to the docker group. This lets you run Docker commands without sudo.
The group change does not take effect until you start a new session. You need to disconnect and reconnect.
- Disconnect from the server by running:
exit
- Reconnect to your EC2 instance by running the same SSH command:
ssh -i ~/Downloads/node-cicd-key.pem ubuntu@[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]
- Verify Docker is installed by running:
docker --version
You should see output like Docker version 24.x.x. The exact version number may differ, but any output confirms Docker is ready.
Seeing permission denied?
- If you see permission denied while trying to connect to the Docker daemon, you did not fully disconnect and reconnect after the usermod command. Run exit and SSH back in.
- If docker is not found at all, re-run sudo apt install docker.io -y and check for errors in the output.
Still stuck? Help me troubleshoot Docker on my EC2 instance.
Your EC2 instance is running Ubuntu with Docker installed and ready. Next up, you'll push your code to GitHub and connect it to your server using secrets.
Push to GitHub and Configure Secrets
Your EC2 instance has Docker installed and is ready to run containers. The next piece of the puzzle is connecting your local project to a place where your pipeline can watch for changes.
In this step, you'll push your code to GitHub and store your EC2 connection details as encrypted secrets. These secrets let GitHub Actions SSH into your server without exposing your credentials in code.
In this step, get ready to:
- Push your project to a GitHub repository.
- Store your EC2 connection details as GitHub Secrets.
- Clone the repository on your EC2 instance.
Create a GitHub repository
GitHub will be the central hub for your pipeline. Every time you push code here, GitHub Actions detects the change and kicks off your workflow.
- Open github.com in your browser.
- Click the + icon in the top-right corner.
- Click New repository.
- Enter node-cicd-pipeline as the Repository name.
- Select Public for repository visibility.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
Pro tip: public repos get unlimited Actions
Public repositories get unlimited GitHub Actions minutes. Private repositories are capped at 2,000 minutes per month on the free plan. For this project, a public repo keeps things simple.
GitHub shows you a quick setup page with instructions. You'll use your own terminal instead.
- In your terminal, make sure you are inside your node-cicd-pipeline folder.
- Create a .gitignore file so node_modules does not get pushed to GitHub by running:
echo "node_modules" > .gitignore
- Initialize your Git repository and push to GitHub by running these commands:
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin https://github.com/[[GITHUB_USERNAME="your GitHub username"]]/node-cicd-pipeline.git
git push -u origin main
What do these commands do?
- git init turns your folder into a Git repository.
- git add . stages all your files for commit.
- git branch -M main renames the default branch to main.
- git remote add origin links your local repo to the GitHub repository you just created.
- git push -u origin main uploads your code to GitHub and sets main as the default upstream branch.
- Refresh your repository page on GitHub.
- You should see all your project files listed, including server.js, Dockerfile, and package.json.
Push failed or asked for a password?
- GitHub no longer accepts passwords for Git operations. If prompted, set up a personal access token in GitHub under Settings > Developer settings > Personal access tokens and use it as your password.
- If you see fatal: remote origin already exists, run git remote set-url origin followed by your repository URL instead of git remote add origin.
- Double check that your repository URL matches the format https://github.com/your-username/node-cicd-pipeline.git with your actual GitHub username.
Help me troubleshoot my Git push to GitHub.
Add GitHub Secrets
Your pipeline needs three pieces of information to deploy to EC2: the server's IP address, the username to log in with, and the SSH private key. Hardcoding these in your workflow file would expose them to anyone who can read your code.
GitHub Secrets solve this by storing sensitive values as encrypted environment variables. Your workflow can reference them, but nobody can read the actual values.
- On your GitHub repository page, click the Settings tab.
- In the left sidebar, click Secrets and variables.
- Click Actions.
- Click New repository secret.
You'll add three secrets, one at a time. Start with the first one:
- Enter EC2_HOST in the Name field.
- Paste your EC2 instance's public IPv4 address in the Secret field.
- Click Add secret.
Where do I find my EC2 public IP?
Go to the AWS Console, open the EC2 dashboard, and click Instances. Select your node-cicd-server instance. The public IPv4 address is shown in the instance details panel.
Now add the second secret:
- Click New repository secret again.
- Enter EC2_USERNAME in the Name field.
- Enter ubuntu in the Secret field.
- Click Add secret.
Finally, add the SSH key secret. This is the private key from the .pem file you downloaded when you created your EC2 key pair.
- Click New repository secret again.
- Enter EC2_SSH_KEY in the Name field.
- Open your .pem file in a text editor.
- Copy the entire contents, including the -----BEGIN RSA PRIVATE KEY----- and -----END RSA PRIVATE KEY----- lines.
- Paste the full key into the Secret field.
- Click Add secret.
Never commit your .pem file to Git
Your .pem file is a private key that grants SSH access to your server. The .gitignore you created earlier does not cover it unless the file is inside your project folder. Keep this file stored safely outside your repository.
- Confirm all three secrets are saved by checking the Repository secrets list on the Actions secrets page.
- You should see EC2_HOST, EC2_USERNAME, and EC2_SSH_KEY listed with their values hidden.
Don't see all three secrets?
- Make sure you are in Settings > Secrets and variables > Actions (not the Codespaces or Dependabot sections).
- If you accidentally created a secret with the wrong name, click the secret, then click Update or delete it and create a new one.
- Secret names are case-sensitive. Make sure you typed EC2_HOST, not ec2_host.
Help me check my GitHub Secrets configuration.
Clone the repository on your EC2 instance
Your pipeline will pull the latest code from GitHub every time it deploys. For that to work, the EC2 instance needs a local copy of the repository to pull updates into.
- Connect to your EC2 instance via SSH by running:
ssh -i [[PEMPATH="path to your .pem file"]] ubuntu@[[EC2_HOST="your EC2 public IPv4 address"]]
- Clone your GitHub repository by running:
git clone https://github.com/[[GITHUB_USERNAME="your GitHub username"]]/node-cicd-pipeline.git
Why clone on EC2?
The deploy step in your pipeline will run git pull origin main to fetch the latest code. That command only works inside an existing Git repository. Cloning now creates that initial copy so future deploys can pull changes into it.
- Confirm the clone worked by running:
ls node-cicd-pipeline
You should see your project files listed, including server.js, Dockerfile, and package.json.
Clone failed or repository not found?
- Make sure you created the repository as Public. Private repositories require authentication to clone on EC2.
- Double check the URL matches your GitHub username and the repository name node-cicd-pipeline exactly.
- If you see Could not resolve host: github.com, your EC2 security group may be blocking outbound traffic. Check that your instance can reach the internet.
Help me troubleshoot cloning my repo on EC2.
Your code is on GitHub, your secrets are locked in, and your EC2 instance has a copy of the repository. Next up, you'll write the GitHub Actions workflow that ties everything together into an automated pipeline.
Create the GitHub Actions CI/CD Workflow
Your app is tested, Dockerized, live on EC2, and backed by GitHub Secrets for secure access.
The missing piece is the automation that ties everything together. In this step, you will create a GitHub Actions workflow that automatically tests your code and deploys it to your server every time you push to main.
In this step, get ready to:
- Create a GitHub Actions workflow file that triggers on pushes to main.
- Define a test job that installs dependencies and runs your test suite.
- Define a deploy job that SSHs into your EC2 instance, builds a Docker image, and launches the container.
Create the workflow file
GitHub Actions looks for workflow files inside a special .github/workflows/ directory in your repository. Each YAML file in that directory becomes a separate workflow.
- Create the workflow directory by running this command from inside your node-cicd-pipeline folder:
mkdir -p .github/workflows
- Confirm the directory was created by running:
ls .github/workflows
The output should show an empty directory with no errors. If you see No such file or directory, make sure you are inside your node-cicd-pipeline folder.
- Create a new file called deploy.yml inside .github/workflows/.
- Add the following workflow trigger configuration to deploy.yml:
# Name that appears in the GitHub Actions tab
name: CI/CD Pipeline
# Trigger this workflow on every push to the main branch
on:
push:
branches:
- main
What does this code do?
- name sets the display name you will see in the GitHub Actions tab.
- on: push: branches: - main tells GitHub to run this workflow every time code is pushed to the main branch. Pushes to other branches are ignored.
- Save deploy.yml.
Add the test job
A CI/CD pipeline typically has at least two stages. The first stage is Continuous Integration, which validates your code by running tests before anything gets deployed.
- In deploy.yml, add the following below the trigger configuration:
jobs:
test:
runs-on: ubuntu-latest
steps:
# Pull the latest code from the repository
- name: Checkout code
uses: actions/checkout@v4
# Install Node.js with npm caching for faster builds
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
# Install exact versions from package-lock.json
- name: Install dependencies
run: npm ci
# Run the test suite
- name: Run tests
run: npm test
What does this code do?
- jobs: is the top-level key that holds all the jobs in your workflow. You will add a second job under this same key next.
- runs-on: ubuntu-latest tells GitHub to spin up a fresh Ubuntu virtual machine to run this job.
- actions/checkout@v4 clones your repository into the runner so the next steps can access your files.
- actions/setup-node@v4 installs Node.js 22 and enables npm caching so future runs skip re-downloading packages.
- npm ci installs dependencies from your lockfile exactly, which is more reliable than npm install in automated environments.
- Save deploy.yml.
You will not see any output yet. This job runs on GitHub's servers after you push your code in the next step.
Seeing red squiggly lines in your editor?
YAML is very sensitive to indentation. Every level must use exactly two spaces, not tabs. Make sure jobs: has no leading spaces, test: is indented by 2 spaces, and steps: is indented by 4 spaces.
Help me fix YAML indentation errors in my deploy.yml
Add the deploy job
The second stage is Continuous Deployment. This job connects to your EC2 instance over SSH, pulls the latest code, and rebuilds the Docker container.
- In deploy.yml, add the following below the test job, at the same indentation level as test::
deploy:
# Only run after tests pass
needs: test
runs-on: ubuntu-latest
# Only deploy from the main branch
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to EC2
uses: appleboy/ssh-action@v1
with:
# Connection details from GitHub Secrets
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
# Commands to run on the EC2 instance
script: |
cd node-cicd-pipeline
git pull origin main
docker build -t node-cicd-app .
docker stop node-cicd-app || true
docker rm node-cicd-app || true
docker run -d --name node-cicd-app -p 3000:3000 node-cicd-app
What does this code do?
- needs: test creates a dependency chain. The deploy job only starts after the test job passes. If any test fails, deployment is blocked.
- if: github.ref == 'refs/heads/main' is an extra safety check. It ensures deployment only happens from the main branch.
- appleboy/ssh-action@v1 is a community action that opens an SSH connection to your EC2 instance using the secrets you configured.
- The script block runs six commands on your EC2 instance: navigate into the project, pull the latest code, build a fresh Docker image, stop and remove the old container (the || true prevents errors if no container exists yet), and start a new container on port 3000.
- Save deploy.yml.
Your workflow file is complete. The pipeline will not run until you push this file to GitHub in the next step.
Not sure if the indentation is right?
The deploy: key must be at the same level as test: (2 spaces in from jobs:). If deploy: is nested deeper, GitHub will not recognize it as a separate job.
Help me verify my deploy.yml indentation
Awesome, I've got everything!
Double check you have saved deploy.yml. Your workflow is ready to go.
I'd like to double check the full code
Here is the complete deploy.yml file. Compare it against yours to make sure everything matches.
name: CI/CD Pipeline
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to EC2
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
cd node-cicd-pipeline
git pull origin main
docker build -t node-cicd-app .
docker stop node-cicd-app || true
docker rm node-cicd-app || true
docker run -d --name node-cicd-app -p 3000:3000 node-cicd-app
Why SSH instead of AWS Systems Manager?
AWS Systems Manager (SSM) is a more secure way to connect to EC2 because it does not require an open SSH port or key management. However, SSM requires IAM role setup, OIDC configuration between GitHub and AWS, and SSM agent configuration.
SSH teaches the core deployment pattern with less overhead. Once you are comfortable with this flow, SSM is a great next step for production environments.
Your CI/CD workflow is locked and loaded. Next up, you will push this workflow to GitHub and watch it test, build, and deploy your app automatically.
Test the Full Pipeline
Your workflow file is committed and your secrets are in place. The entire pipeline is wired together. Now it's time to prove it works.
In this step, you'll push a code change and watch GitHub Actions automatically test, build, and deploy your updated app to EC2. Then you'll do it again to confirm the pipeline is reliable.
In this step, get ready to:
- Trigger your CI/CD pipeline by pushing a code change to GitHub.
- Verify the deployment is live on your EC2 instance.
- Run the full cycle a second time to confirm everything works end to end.
Trigger the pipeline
The best way to test a CI/CD pipeline is to push a real code change. You'll update your API's root endpoint message so you can confirm the new version is deployed.
- Open server.js in your editor.
- Find the root endpoint's response object. Look for this line:
message: "Hello from the CI/CD pipeline!",
- Change the message string to something different, like "Deployed automatically with GitHub Actions!". Pick anything you want so you can tell the old version from the new one.
- Save server.js.
Now commit and push the change to trigger the pipeline.
- Stage, commit, and push your change by running these commands:
git add .
git commit -m "Update root endpoint message"
git push origin main
What do these commands do?
- git add . stages all changed files for the commit.
- git commit -m saves a snapshot of the changes with a descriptive message.
- git push origin main sends the commit to GitHub. Because your workflow triggers on pushes to main, this automatically starts the pipeline.
- Open your GitHub repository in your browser.
- Click the Actions tab at the top of the repository page.
You should see a workflow run in progress with your commit message as the title. Click on it to watch both jobs execute in real time.
What should I see?
The workflow page shows two jobs: test and deploy. The test job runs first. Once it passes with a green checkmark, the deploy job kicks off automatically.
You can click on each job to expand the individual steps and see logs. The deploy job will SSH into your EC2 instance, pull the latest code, rebuild the Docker image, and restart the container.
Workflow failed?
- If the test job fails, click on it to read the error logs. A syntax error in server.js (like a missing comma or quote) is the most common cause.
- If the deploy job fails, check that your three GitHub Secrets (EC2_HOST, EC2_USERNAME, EC2_SSH_KEY) are correct. The SSH key must include the full content of the .pem file, including the header and footer lines.
- If you see a "permission denied" error in the deploy logs, make sure port 22 is open in your EC2 security group and that the EC2_HOST secret matches your instance's current public IP address.
Help me debug my failed GitHub Actions workflow.
Verify the deployment
Once both jobs show green checkmarks, your updated code is live on EC2. Time to confirm it with your own eyes.
- Open your browser and navigate to your EC2 instance's public IP on port 3000:
http://[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]:3000
You should see a JSON response containing the new message you just set. This is the proof that your pipeline automatically deployed the updated code.
- Also check the health endpoint by visiting:
http://[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]:3000/health
You should see { "status": "ok" } confirming the health check endpoint is working.
Page won't load?
- Make sure you're using http:// and not https://. Your EC2 instance doesn't have an SSL certificate.
- Check that port 3000 is open in your EC2 security group. Go to the EC2 console, select your instance, click Security, and verify the inbound rules include port 3000.
- If the deploy job succeeded but the page still won't load, SSH into your instance and run docker ps to check if the container is running.
My GitHub Actions deploy job passed but my app won't load in the browser.
Make another change and watch it deploy
One successful deployment could be a fluke. Doing it twice proves the pipeline is solid and repeatable.
- Open server.js again.
- Change the root endpoint's message string to something new. Try changing it back to the original value:
message: "Hello from the CI/CD pipeline!",
- Save server.js.
- Commit and push the change by running these commands:
git add .
git commit -m "Test pipeline with another update"
git push origin main
- Head back to the Actions tab in your GitHub repository.
- Watch the new workflow run appear and click into it.
This time, pay attention to the full cycle. Your push triggered the workflow. The test job checked out your code, installed dependencies, and ran your test suite. Once the tests passed, the deploy job connected to your EC2 instance over SSH, pulled the latest code, rebuilt the Docker image, and restarted the container.
- Once both jobs show green checkmarks, refresh your browser at:
http://[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]:3000
You should see the updated message in the JSON response, confirming your second deployment worked exactly like the first.
What just happened?
You pushed code, and without touching a single server, your app updated itself. That is the power of a CI/CD pipeline.
Every future push to main follows the same automated path: test, build, deploy. No manual SSH sessions. No copy-pasting commands. Just push and go.
Second deployment didn't update?
- Hard-refresh your browser with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to bypass the cache.
- Check the deploy job logs in the Actions tab. The git pull step should show files being updated. If it says "Already up to date", your push may not have gone through.
- Verify that you committed and pushed to the main branch. The workflow only triggers on pushes to main.
My CI/CD pipeline ran successfully but the deployed app still shows the old version.
Secret mission
Add a Staging Environment with Branch-Based Deployments
Real DevOps teams never deploy straight to production. They push changes to a staging server first, verify everything works, then promote to production. In this mission, you'll add a second EC2 instance and update your pipeline to deploy to staging or production based on which branch you push to.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses an AWS EC2 instance that consumes free tier hours while running.
Cost warning
Your EC2 t2.micro instance uses free tier hours (750 hours/month for accounts created before July 15, 2025). If this is your only running instance, it can run all month without charges. If you have other EC2 instances, the hours add up across all of them.
If you are not planning to use this instance soon, stop or terminate it to avoid consuming your free tier allowance.
Resources you used:
- EC2 instance (node-cicd-server).
- EC2 key pair (.pem file on your local machine).
- EC2 security group (SSH, HTTP, and port 3000 rules).
- GitHub repository (node-cicd-pipeline).
- GitHub Secrets (EC2_HOST, EC2_USERNAME, EC2_SSH_KEY).
- Local project files and Docker images.
- Staging EC2 instance (Secret Mission only).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your pipeline. Your t2.micro instance can run all month within the free tier as long as it is your only running instance.
Pause - I'll come back to this later
Stop your EC2 instance to free up compute hours while keeping your files and configuration intact. Stopped instances do not consume compute hours.
- In the AWS Console, go to EC2 and select Instances.
- Select your node-cicd-server instance.
- Click Instance state, then Stop instance.
- Your public IP address may change when you restart the instance. Update your EC2_HOST GitHub Secret with the new IP if it changes.
- If you completed the Secret Mission, also stop your node-cicd-staging instance using the same steps.
Delete - I don't want to use this again
Remove all project resources and start fresh. This completely removes your EC2 instance, key pair, and all associated data.
- In the AWS Console, go to EC2 and select Instances.
- Select your node-cicd-server instance, click Instance state, then Terminate instance.
- Go to EC2, then Key Pairs. Delete the key pair you created for this project.
- Delete the .pem file from your local machine.
- Optionally, delete the security group from EC2, then Security Groups. You can only delete it after the instance is fully terminated.
- Optionally, delete the GitHub repository and remove the GitHub Secrets.
- Optionally, delete the local node-cicd-pipeline project folder.
- If you completed the Secret Mission, also terminate your node-cicd-staging instance.
Mission accomplished
That's a Wrap!
Nice work! You just built a fully automated CI/CD pipeline that tests, builds, and deploys a Dockerized Node.js app to a live AWS EC2 instance on every push.
You learned how to:
- Dockerize a Node.js Express API with automated tests, a production-ready Dockerfile, and optimized layer caching.
- Launch and configure an AWS EC2 instance with Docker installed and secure SSH access managed through GitHub Secrets.
- Build a GitHub Actions CI/CD workflow with separate test and deploy stages that automatically deploys your code to a live server on every push to main.
- Add a staging environment with branch-based deployment workflows so you can test changes before promoting them to production.
Ready to quiz yourself?