CI/CD Pipeline with GitHub Actions
Build a pipeline that auto-deploys a Dockerized app to AWS EC2 on every push.
Introduction
30 Second Summary
Every time you update an app on your phone, a pipeline built by someone on the development team packaged that update and shipped it to you automatically. That system of automatic testing, building, and deploying is called CI/CD.
In this project, you will build a complete CI/CD pipeline that automatically tests your code, packages it into a Docker container, and deploys it to a live cloud server every time you push code to GitHub.
What You'll Build
A GitHub Actions workflow that builds a Docker container, pushes it to a container registry, and deploys it to an AWS EC2 instance via SSH.
By the end of this project, you'll have:
- A Dockerized Node.js application running on a live AWS EC2 instance accessible via a public URL.
- A GitHub Actions workflow that automatically runs tests, builds a container image, and deploys it on every push to main.
- A GitHub Container Registry storing your Docker images, integrated with your pipeline.
- Secret Mission: Add a staging environment with a separate branch trigger so pushes to develop deploy to staging and pushes to main deploy to production.
Are there any prerequisites?
You need a GitHub account and an AWS account (created within the last 6 months so you have free plan credits available). No prior Docker or CI/CD experience is required.
Build the Node.js Application
Your tools are ready. Now it is time to build the actual application that your pipeline will deploy.
Every CI/CD pipeline needs something to build, test, and ship. In this step, you will create a simple Node.js API with two endpoints and automated tests. This gives the pipeline real work to do on every push.
In this step, get ready to:
- Create a Node.js project with Express and a health check endpoint.
- Write automated tests using Node's built-in test runner.
- Run the app locally and verify all tests pass.
Create the project directory and Express server
This project uses Node.js 20 or higher for its built-in test runner. Let's confirm your version first.
- Check your Node.js version by running this command:
node --version
✔️ I see version 20 or higher
You are good to go. Move on to the next instruction below.
ⓧ I see an older version
You need Node.js 20 or higher for this project. The built-in test runner is only stable in version 20+.
- Visit the official Node.js download page at https://nodejs.org/en/download.
- Download and install a version 20 or higher for your operating system.
- Close and reopen your terminal, then run node --version again to confirm the update.
Version managers make this easier
If you use a version manager like nvm, you can install and switch versions without affecting your system install.
ⓧ Command not found
Node.js is not installed on your machine yet.
- Visit the official Node.js download page at https://nodejs.org/en/download.
- Download and install version 20 or higher for your operating system.
- Close and reopen your terminal, then run node --version to confirm the installation.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir devops-pipeline-app
cd devops-pipeline-app
- Initialize a new Node.js project by running:
npm init -y
What does this command do?
The npm init -y command creates a package.json file with default values. This file is the manifest for your project. It tracks your dependencies, scripts, and metadata.
The default package.json needs a few edits to match our project requirements. You will replace its contents entirely.
- Open your project in your code editor. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Antigravity and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the devops-pipeline-app folder, then click Open.
- Open package.json from the Explorer sidebar on the left.
- Select all the contents with Cmd+A (macOS) or Ctrl+A (Windows) and replace them with the following code:
{
"name": "devops-pipeline-app",
"version": "1.0.0",
"description": "A simple Node.js app for CI/CD pipeline demo",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "node --test"
},
"dependencies": {
"express": "4.21.2"
}
}
What does this file do?
The scripts section defines two shortcuts: npm start runs your server, and npm test runs your tests using Node's built-in test runner.
The dependencies section pins Express to version 4.21.2. Express is a lightweight web framework that handles HTTP requests and routing.
- Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).
- Install the Express dependency by running this command in your terminal:
npm install
- You should see output confirming that packages were added. A node_modules folder and a package-lock.json file now appear in your project.
Seeing permission errors?
If npm install fails with permission errors, make sure you are inside the devops-pipeline-app folder. Run pwd to check your current directory.
Help me fix npm install permission errors in my project.
Now create the Express server that the pipeline will build and deploy.
- Create a new file called server.js by right-clicking in the Explorer sidebar and selecting New File.
- Name the file server.js and paste the following 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: process.env.npm_package_version || '1.0.0',
timestamp: new Date().toISOString()
});
});
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy' });
});
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
module.exports = { app, server };
What does this server do?
The root endpoint (/) returns a JSON response with a welcome message, the app version, and the current timestamp. This gives you something visible to verify deployments worked.
The /health endpoint returns a simple status check. Health endpoints are standard in production apps. Monitoring tools and load balancers use them to verify your app is alive.
The module.exports at the bottom exports both app and server so the test file can import them directly.
- Save server.js.
File not appearing in the sidebar?
Make sure you created the file at the root of devops-pipeline-app, not inside node_modules. If it ended up in the wrong place, drag it to the top level in the Explorer sidebar.
Help me fix my file location in the project.
Write automated tests for your endpoints
A CI/CD pipeline should never deploy code that has not been tested. You will use Node's built-in test runner so there is no extra test framework to install.
- Create a new file called server.test.js in the root of your project (right-click in Explorer > New File).
- Paste the following code into server.test.js:
const { describe, it, after } = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const { app, server } = require('./server');
describe('API Endpoints', () => {
after(() => {
server.close();
});
it('GET / should return welcome message', (_, done) => {
http.get('http://localhost:3000/', (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.message, 'Hello from the CI/CD Pipeline!');
done();
});
});
});
it('GET /health should return healthy status', (_, done) => {
http.get('http://localhost:3000/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, 'healthy');
done();
});
});
});
});
What do these tests check?
The first test sends a GET request to / and verifies the response includes the correct welcome message. The second test hits /health and checks for a 200 status code with { status: 'healthy' } in the body.
The after() hook closes the server when all tests finish. Without it, the test process would hang because the server keeps listening.
- Save server.test.js.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the full content of each file for comparison.
package.json:
{
"name": "devops-pipeline-app",
"version": "1.0.0",
"description": "A simple Node.js app for CI/CD pipeline demo",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "node --test"
},
"dependencies": {
"express": "4.21.2"
}
}
server.js:
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: process.env.npm_package_version || '1.0.0',
timestamp: new Date().toISOString()
});
});
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy' });
});
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
module.exports = { app, server };
server.test.js:
const { describe, it, after } = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const { app, server } = require('./server');
describe('API Endpoints', () => {
after(() => {
server.close();
});
it('GET / should return welcome message', (_, done) => {
http.get('http://localhost:3000/', (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.message, 'Hello from the CI/CD Pipeline!');
done();
});
});
});
it('GET /health should return healthy status', (_, done) => {
http.get('http://localhost:3000/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, 'healthy');
done();
});
});
});
});
Run the app and tests locally
Before any pipeline touches this code, you need to verify it works on your own machine. This is the same two-step check the pipeline will automate later.
- Start your server by running:
node server.js
- You should see Server running on port 3000 in your terminal.
- Open your browser and visit http://localhost:3000.
You should see a JSON response with a message, version, and timestamp.
- Visit http://localhost:3000/health to confirm the health endpoint returns {"status":"healthy"}.
- Go back to your terminal and stop the server with Ctrl+C.
Seeing EADDRINUSE error?
This means port 3000 is already in use by another process. Stop whatever is using it, or close other terminal windows that may have a server running.
Help me free up port 3000 on my machine.
- Run your automated tests by running:
npm test
- You should see output showing both tests passing with green checkmarks.
What should I see?
The output will show each test name followed by a pass indicator. You should see two passing tests: one for the root endpoint and one for the health endpoint. The process exits cleanly after the tests complete.
Tests failing?
Make sure you stopped the server from the previous step with Ctrl+C before running npm test. The test file starts its own server instance, so having two servers on port 3000 causes failures.
Also confirm your server.js file has module.exports = { app, server } at the bottom. Without this export, the test file cannot import the server.
Help me debug my failing tests.
Your app is built and tested locally. Next up, you will containerize it with Docker so it can run identically on any machine.
Containerize with Docker
Your Node.js app is running and your tests are passing. Now it's time to package your application so it runs the same way everywhere.
Docker wraps your app and its dependencies into a container. A container is a lightweight, isolated environment that behaves identically whether it's running on your laptop or on a cloud server.
In this step, get ready to:
- Create a multi-stage Dockerfile that produces a lean production image.
- Add a .dockerignore file to keep the build context clean.
- Build and run your container locally to verify it works.
Create the Dockerfile
A Dockerfile is a recipe that tells Docker how to build your application into an image. You'll use a multi-stage build pattern, which separates the dependency installation step from the final production image.
Why a multi-stage build?
A single-stage build would include all intermediate files (like build caches and dev tooling) in the final image. Multi-stage builds let you install dependencies in one stage, then copy only what's needed into a second, minimal stage.
The result is a smaller, faster, and more secure image. This is the same pattern used in production at most companies.
- Create a new file called Dockerfile in the root of your devops-pipeline-app folder (no file extension).
- Paste the following code into the file:
# Stage 1: Install production dependencies
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install --production
# Stage 2: Create the lean production image
FROM node:20-alpine
WORKDIR /app
# Create a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Copy only the installed modules from the build stage
COPY --from=build /app/node_modules ./node_modules
COPY server.js ./
COPY package.json ./
# Switch to the non-root user
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
What does this Dockerfile do?
- Stage 1 (build): starts from the lightweight node:20-alpine base image, copies package.json, and runs npm install --production to install only production dependencies.
- Stage 2 (production): starts fresh from the same Alpine image, creates a non-root user called appuser for security, then copies only node_modules, server.js, and package.json from the build stage.
- USER appuser ensures the container runs as a non-root user. If an attacker breaks into the container, they have limited system access.
- EXPOSE 3000 documents that the app listens on port 3000. CMD defines what runs when the container starts.
- Save Dockerfile.
You won't see a change yet. You'll use this Dockerfile in the next substep to build your container.
✔️ Awesome, I've got everything!
Great. Double check you've saved Dockerfile before moving on.
ⓧ I'd like to double check the full code
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install --production
FROM node:20-alpine
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=build /app/node_modules ./node_modules
COPY server.js ./
COPY package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Create the .dockerignore file
When Docker builds your image, it sends all files in the current directory (the "build context") to the Docker engine. A .dockerignore file tells Docker which files to skip, keeping the build fast and the image clean.
- Create a new file called .dockerignore in the root of your devops-pipeline-app folder.
- Paste the following into the file:
node_modules
.git
.github
*.md
.dockerignore
What does this file do?
- node_modules is excluded because the Dockerfile installs dependencies fresh inside the container. Sending the local copy would waste time.
- .git and .github are version control directories that have no place in a production image.
- *.md excludes Markdown documentation files.
- Save .dockerignore.
You won't see a change yet. This file takes effect when you build the Docker image in the next substep.
✔️ Awesome, I've got everything!
Great. Double check you've saved .dockerignore before moving on.
ⓧ I'd like to double check the full code
node_modules
.git
.github
*.md
.dockerignore
Build and run the container locally
Now you'll turn your Dockerfile into a runnable image and start it as a container.
- Build your Docker image by running this command:
docker build -t devops-pipeline-app .
What does this command do?
- docker build reads your Dockerfile and executes each instruction to produce an image.
- -t devops-pipeline-app tags the image with a name so you can reference it easily.
- . tells Docker to use the current directory as the build context (the .dockerignore filters what gets sent).
- Wait for the build to finish. You should see output ending with a line that includes naming to docker.io/library/devops-pipeline-app or a similar success message.
Build failed?
- Make sure Docker Desktop is running. You can verify by running docker --version in your terminal.
- Check that your Dockerfile is named exactly Dockerfile (capital D, no extension). A file named dockerfile.txt or Dockerfile.dockerfile won't be found.
- Ensure you're running the command from inside the devops-pipeline-app folder where Dockerfile lives.
Help me debug my Docker build error.
- If you still have your Node.js server running locally from the previous step, stop it first by pressing Ctrl+C in the terminal where it's running.
- Run your container by executing this command:
docker run -p 3000:3000 devops-pipeline-app
What does this command do?
- docker run starts a new container from the image you just built.
- -p 3000:3000 maps port 3000 on your machine to port 3000 inside the container. Without this, the container's port is isolated and unreachable.
- Open your browser and visit http://localhost:3000.
- You should see the same JSON response you saw earlier: {"message":"Hello from the CI/CD Pipeline!",...}. This time it's coming from inside the Docker container.
Don't see a response at localhost:3000?
- Check your terminal. If you see Server running on port 3000 printed, the container is running correctly. The issue is likely your browser or a firewall.
- If you see a port conflict error, another process (maybe the local Node server) is already using port 3000. Stop it with Ctrl+C and try the docker run command again.
- If the container exited immediately, check the logs with docker logs followed by the container ID. Look for error messages about missing files or modules.
Help me fix my Docker container that won't start.
- Once you've confirmed the app works, stop the container by pressing Ctrl+C in the terminal where docker run is running.
Your app is now containerized and portable. Next up, you'll push this code to GitHub and store the container image in a registry so your CI/CD pipeline can access it.
Push to GitHub and Set Up the Container Registry
Your app is containerized and running locally. The next piece of the CI/CD puzzle is getting your code and container image into the cloud so the automated pipeline can access them.
In this step, you will push your project to GitHub and store your Docker image in GitHub Container Registry (GHCR). This gives your future pipeline a code source to watch and a container registry to pull from during deployment.
In this step, get ready to:
- Push your project to a public GitHub repository.
- Create a Personal Access Token for container registry access.
- Tag and push your Docker image to GitHub Container Registry.
Create a GitHub repository and push your code
Your CI/CD pipeline needs a source code repository to monitor for changes. Every time you push code to this repo, the pipeline will trigger automatically.
- Open your browser and go to github.com.
- Click the + icon in the top-right corner, then select New repository.
- Enter devops-pipeline-app as the repository name.
- Select Public for the repository visibility.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
Why a public repository?
GitHub Actions is free and unlimited on public repositories. Private repos on the free plan include 2,000 minutes per month, but a public repo means you never have to worry about minute limits while learning.
- Initialize Git, add your files, commit, and push to the new repository 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"]]/devops-pipeline-app.git
git push -u origin main
What do these commands do?
These five commands set up version control for your project and push it to GitHub.
- `git init` creates a new Git repository in your project folder.
- `git add .` stages all your files for the first commit.
- `git commit -m "Initial commit"` saves a snapshot of your code with a message.
- `git branch -M main` renames the default branch to main.
- `git remote add origin` links your local repository to the remote GitHub repository.
- `git push -u origin main` uploads your code to GitHub.
Git may prompt you to authenticate. Enter your GitHub username and your Personal Access Token (you will create one in the next substep) or use your browser-based credential flow.
- Refresh your repository page on GitHub. You should see all your project files (server.js, package.json, Dockerfile, etc.) listed on the main branch.
Push was rejected or authentication failed?
- Make sure you selected a completely empty repository (no README or .gitignore) when creating it. If you accidentally added a README, delete the repo and recreate it.
- If prompted for a password, GitHub no longer accepts account passwords for Git operations. Use a Personal Access Token instead (covered in the next substep).
Help me push my code to GitHub.
Create a GitHub Personal Access Token
To push Docker images to GitHub Container Registry from your terminal, you need a Personal Access Token (classic) with the right permissions. This token acts as your authentication credential for GHCR.
- On GitHub, click your profile picture in the upper-right corner, then click Settings.
- In the left sidebar, click Developer settings.
- In the left sidebar, under Personal access tokens, click Tokens (classic).
- Click Generate new token, then click Generate new token (classic).
- In the Note field, enter GHCR Pipeline Access.
- Set Expiration to 30 days (or your preferred duration).
- Under Select scopes, check the write:packages checkbox. The read:packages scope will be automatically selected as well.
- Click Generate token.
- Copy the token immediately and save it somewhere safe. You will not be able to see it again after leaving this page.
What are these scopes?
The write:packages scope allows you to upload and publish packages (including Docker images) to GitHub Packages. The read:packages scope allows downloading packages.
Together, these two scopes give your terminal the ability to push and pull container images from GHCR.
- Test that you can log in to GHCR by running this command (replace your-token-here with the token you just copied):
echo your-token-here | docker login ghcr.io -u [[GITHUB_USERNAME="your-github-username"]] --password-stdin
- You should see Login Succeeded in your terminal output.
Login failed or unauthorized?
- Double-check that you selected the write:packages scope when creating the token. Without it, GHCR will reject the login.
- Make sure your GitHub username is all lowercase in the command. GHCR requires lowercase usernames.
- Confirm you copied the full token with no extra spaces.
Help me log in to GitHub Container Registry.
Tag and push your Docker image to GHCR
Now that you are authenticated, you can push your locally built Docker image to GitHub Container Registry. This makes the image available for your EC2 server to pull later.
- Tag your local image with the GHCR naming convention by running this command:
docker tag devops-pipeline-app ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/devops-pipeline-app:latest
What does tagging do?
Docker tags are like aliases for your image. The ghcr.io/username/image-name:latest format tells Docker which registry to push to, who owns the image, and which version it is.
Your GitHub username must be lowercase in the tag. GHCR enforces this regardless of how your username appears on GitHub.
- Push the tagged image to GHCR by running:
docker push ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/devops-pipeline-app:latest
The push uploads all the image layers to GHCR. This may take a minute depending on your internet speed.
- Verify the image appears on GitHub by navigating to your GitHub profile.
- Click on your profile picture, then click Your profile.
- Click the Packages tab.
- You should see devops-pipeline-app listed as a container package.
Don't see the package listed?
- When first published, GHCR packages default to private visibility. You will still see them on your own profile, but make sure you are signed in and viewing your own Packages tab.
- If the push command failed, check that docker login ghcr.io succeeded first. You cannot push without a successful login.
- Ensure your GitHub username in the tag is all lowercase.
Help me push my Docker image to GHCR.
Your code is on GitHub and your container image is stored in GHCR. Next up, you will launch an AWS EC2 instance to serve as the deployment target for your pipeline.
Launch and Configure the AWS EC2 Instance
Your Docker image is safely stored in GitHub Container Registry. Now you need a server where that container can run publicly on the internet.
In this step, you will launch an AWS EC2 instance (a virtual server in the cloud), install Docker on it, and pull your app container from GHCR to run it live.
In this step, get ready to:
- Launch an EC2 instance with Amazon Linux 2023 and configure its security group.
- Connect via SSH and install Docker on the server.
- Pull and run your containerized app on the live server.
Launch an EC2 instance
EC2 (Elastic Compute Cloud) gives you virtual servers that you can configure however you want. You will launch a small instance running Amazon Linux 2023 and open the ports your app needs.
- Sign in to the AWS Management Console at https://console.aws.amazon.com.
- In the search bar at the top, type EC2 and select EC2 from the results.
- Click Launch instance from the dashboard.
- Under Name and tags, enter devops-pipeline-server as the instance name.
- Under Application and OS Images, choose Quick Start, then select Amazon Linux. Make sure Amazon Linux 2023 AMI is selected (it should be the default).
- Under Instance type, select t3.micro (free plan eligible).
- Under Key pair (login), click Create new key pair.
- Enter devops-pipeline-key as the key pair name.
- Leave the key pair type as RSA and the file format as .pem.
- Click Create key pair. Your browser will automatically download a file called devops-pipeline-key.pem. Save it somewhere you can find it.
Keep your .pem file safe
This file is the only way to SSH into your server. If you lose it, you will not be able to connect to your instance. Do not share it or commit it to Git.
Now configure the network settings for your instance:
- Under Network settings, click Edit.
- Under Firewall (security groups), select Create security group.
- You will see a default SSH rule already added (Type: SSH, Port: 22, Source: 0.0.0.0/0). Keep this rule.
- Click Add security group rule.
- Set Type to Custom TCP.
- Set Port range to 3000.
- Set Source type to Anywhere (0.0.0.0/0).
- Leave all other settings at their defaults and click Launch instance.
What is a security group?
A security group acts as a virtual firewall for your EC2 instance. It controls which traffic can reach your server. You opened port 22 for SSH access and port 3000 so the internet can reach your running app.
- Wait for the instance state to change to Running (this takes about 30 seconds).
- Select your instance and find the Public IPv4 address in the instance details panel. Copy this address and save it somewhere handy.
Note your public IP
You will use this IP address many times throughout the rest of the project. Keep it in a text file or sticky note for easy access.
Connect via SSH and install Docker
Your server is running but it is a blank slate. You need to connect to it and install Docker so it can run your container.
First, you need to set the correct permissions on your .pem file so SSH will accept it.
macOS
- Set the correct file permissions by running this command:
chmod 400 devops-pipeline-key.pem
What does chmod 400 do?
This restricts the file so only you can read it. SSH refuses to use key files that other users on your machine can access.
Windows
- Open PowerShell and navigate to the folder where your .pem file was downloaded.
- Set the correct file permissions by running these commands:
icacls.exe devops-pipeline-key.pem /reset
icacls.exe devops-pipeline-key.pem /grant:r "%username%:(R)"
icacls.exe devops-pipeline-key.pem /inheritance:r
What do these commands do?
These commands restrict the .pem file so only your Windows user can read it. SSH refuses to use key files that other users on your machine can access.
- Connect to your EC2 instance by running this command:
ssh -i [[PEMPATH="path/to/devops-pipeline-key.pem"]] ec2-user@[[EC2_IP="your EC2 public IPv4 address"]]
What does this command do?
The -i flag tells SSH which private key file to use for authentication. ec2-user is the default username on Amazon Linux instances.
- When prompted with "Are you sure you want to continue connecting?", type yes and press Enter.
You should see a welcome message from Amazon Linux and your prompt changes to show ec2-user. You are now inside your cloud server.
Getting a permission denied error?
Make sure you set the file permissions on your .pem file correctly (see the platform-specific step above). Also verify you are using the correct public IPv4 address from the EC2 console.
help me fix SSH permission denied when connecting to my EC2 instance
- Install Docker on the server by running this command:
sudo dnf install docker -y
- Start Docker and enable it to run on boot by running these commands:
sudo systemctl start docker
sudo systemctl enable docker
- Add ec2-user to the Docker group so you can run Docker without sudo:
sudo usermod -aG docker ec2-user
Why add ec2-user to the docker group?
By default, Docker requires root privileges. Adding your user to the docker group lets you run docker commands without prefixing every command with sudo.
- Log out of the SSH session by running:
exit
- Reconnect to the instance by running the same SSH command again:
ssh -i [[PEMPATH="path/to/devops-pipeline-key.pem"]] ec2-user@[[EC2_IP="your EC2 public IPv4 address"]]
The group change takes effect on your next login. You need to disconnect and reconnect for Docker to work without sudo.
Deploy your app container to the server
Docker is installed and your user has permission to use it. Time to pull your app image from GHCR and run it live.
- Verify Docker is working by running this test command:
docker run hello-world
You should see a message that says "Hello from Docker!" confirming Docker can pull and run containers.
Getting permission denied from Docker?
Make sure you logged out and logged back in after adding ec2-user to the docker group. The group change only takes effect after a fresh SSH session.
help me fix Docker permission denied on my EC2 instance
- Log in to GitHub Container Registry from your EC2 instance by running this command (replace your-github-username with your actual GitHub username, and your-personal-access-token with the PAT you created earlier):
echo your-personal-access-token | docker login ghcr.io -u [[GITHUB_USERNAME="your-github-username"]] --password-stdin
Why log in to GHCR on the server?
Your Docker image is stored in GitHub Container Registry. The EC2 instance needs permission to pull it, just like your local machine did. The PAT you created in the previous step grants that access.
- Pull your app image from GHCR by running:
docker pull ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/devops-pipeline-app:latest
- Run your app container in the background by running:
docker run -d -p 3000:3000 ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/devops-pipeline-app:latest
What do these flags do?
The -d flag runs the container in the background (detached mode) so it does not block your terminal. The -p 3000:3000 flag maps port 3000 on the server to port 3000 inside the container, making your app accessible from the internet.
- Open your browser and visit http:// followed by your EC2 public IP and :3000 (for example, http://54.123.45.67:3000).
You should see a JSON response with your "Hello from the CI/CD Pipeline!" message. Your app is live on the internet.
Not seeing your app in the browser?
Check that your security group allows inbound traffic on port 3000. In the EC2 Console, select your instance, click the Security tab, and verify the inbound rules include port 3000 from 0.0.0.0/0.
Also confirm the container is actually running by executing docker ps on the server. You should see your container listed.
help me troubleshoot why my app is not accessible on my EC2 public IP
Your app is running live on a cloud server. Next up, you will automate the entire build-test-deploy cycle with a GitHub Actions workflow so you never have to manually SSH and pull again.
Create the GitHub Actions CI/CD Pipeline
Your EC2 instance is live and serving your containerized app. You proved the entire manual deployment flow works.
Now it is time to automate everything. In this step, you will write a GitHub Actions workflow that triggers on every push to main. The workflow runs your tests, builds a fresh Docker image, pushes it to GitHub Container Registry, and deploys it to your EC2 server automatically.
In this step, get ready to:
- Store sensitive connection details as GitHub repository secrets.
- Write a multi-job GitHub Actions workflow YAML file.
- Verify end-to-end automated deployment by pushing a code change.
Add repository secrets to GitHub
Your workflow needs access to your EC2 server and container registry. GitHub encrypts these values and injects them into workflow runs at runtime so they never appear in your code.
- Open your repository on GitHub in your browser.
- Click Settings in the top navigation bar of your repository.
- In the left sidebar under the Security section, click Secrets and variables, then click Actions.
- Click the Secrets tab.
- Click New repository secret.
You will add four secrets, one at a time. For each secret, type the name in the Name field, paste the value in the Secret field, then click Add secret.
- Add a secret named EC2_HOST with your EC2 instance's public IPv4 address as the value.
- Add a secret named EC2_USERNAME with the value ec2-user.
- Add a secret named EC2_SSH_KEY with the entire contents of your .pem key file as the value (open the file in a text editor, select all, and copy).
- Add a secret named GHCR_TOKEN with your Personal Access Token as the value.
What are repository secrets?
Repository secrets are encrypted environment variables that GitHub injects into your workflow at runtime. They never appear in logs or code, which keeps sensitive values like SSH keys and tokens safe.
Your workflow references them with the ${{ secrets.SECRET_NAME }} syntax. GitHub masks the value in all log output automatically.
After adding all four, you should see EC2_HOST, EC2_USERNAME, EC2_SSH_KEY, and GHCR_TOKEN listed on the secrets page.
Don't see the Settings tab?
Only the repository owner has access to Settings. Make sure you are viewing your own repository (not a fork or someone else's). If the tab is hidden, click the overflow dropdown menu next to the tabs and select Settings.
Help me find the Settings tab for my repository.
Create the workflow file
GitHub Actions looks for workflow files in the .github/workflows/ directory of your repository. Each YAML file in that folder defines an automated pipeline.
- Create the directory structure by running these commands in your project folder:
mkdir -p .github/workflows
- Create the workflow file .github/workflows/deploy.yml and paste the following code:
name: CI/CD Pipeline
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Run tests
run: npm test
build-and-push:
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Build Docker image
run: docker build -t ghcr.io/${{ github.repository_owner }}/devops-pipeline-app:${{ github.sha }} .
- name: Push Docker image
run: docker push ghcr.io/${{ github.repository_owner }}/devops-pipeline-app:${{ github.sha }}
deploy:
runs-on: ubuntu-latest
needs: build-and-push
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: |
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
docker pull ghcr.io/${{ github.repository_owner }}/devops-pipeline-app:${{ github.sha }}
docker stop devops-app || true
docker rm devops-app || true
docker run -d --name devops-app -p 3000:3000 ghcr.io/${{ github.repository_owner }}/devops-pipeline-app:${{ github.sha }}
What does this workflow do?
This workflow defines three jobs that run sequentially:
- The test job checks out your code, installs Node.js 20, runs npm install, then runs npm test. If any test fails, the pipeline stops here.
- The build-and-push job uses GITHUB_TOKEN (provided automatically by GitHub) to log in to GHCR, build a Docker image tagged with the commit SHA, and push it to the registry.
- The deploy job uses appleboy/ssh-action@v1 to SSH into your EC2 instance, pull the freshly pushed image, stop the old container, and start a new one.
The needs keyword chains the jobs together. build-and-push only runs if test passes, and deploy only runs if build-and-push succeeds.
- Save .github/workflows/deploy.yml.
YAML indentation errors?
YAML is whitespace-sensitive. Make sure you use spaces (not tabs) and that each level of indentation is exactly two spaces. Most code editors highlight YAML formatting issues.
Help me fix a YAML indentation error in my workflow file.
✔️ Awesome, I've got everything!
Great. Make sure you have saved .github/workflows/deploy.yml before moving on.
ⓧ I'd like to double check the full code
Here is the complete deploy.yml workflow file:
name: CI/CD Pipeline
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Run tests
run: npm test
build-and-push:
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Build Docker image
run: docker build -t ghcr.io/${{ github.repository_owner }}/devops-pipeline-app:${{ github.sha }} .
- name: Push Docker image
run: docker push ghcr.io/${{ github.repository_owner }}/devops-pipeline-app:${{ github.sha }}
deploy:
runs-on: ubuntu-latest
needs: build-and-push
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: |
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
docker pull ghcr.io/${{ github.repository_owner }}/devops-pipeline-app:${{ github.sha }}
docker stop devops-app || true
docker rm devops-app || true
docker run -d --name devops-app -p 3000:3000 ghcr.io/${{ github.repository_owner }}/devops-pipeline-app:${{ github.sha }}
Push the workflow and verify automated deployment
The moment you push this file to main, GitHub Actions will detect the workflow and start running it. This is the real test of your entire pipeline.
- Stage, commit, and push the workflow file by running these commands:
git add .github/workflows/deploy.yml
git commit -m "Add CI/CD pipeline workflow"
git push origin main
- Open your repository on GitHub in your browser.
- Click the Actions tab at the top of your repository.
- You should see a workflow run in progress with a yellow dot. Click on it to watch the jobs execute in real time.
What should I see?
The workflow shows three jobs in sequence: test, build-and-push, and deploy. Each job shows a green checkmark when it completes successfully. The entire run takes 2-4 minutes.
Once all three jobs show green checkmarks, your app has been automatically deployed to EC2.
- After the workflow completes, open your browser and visit http:// followed by your EC2 public IP and :3000 to confirm the deployment worked.
Now verify that pushing a code change triggers an automatic redeployment.
- In server.js, find this line:
message: 'Hello from the CI/CD Pipeline!',
- Replace the message text so the line looks like this:
message: 'Hello from the automated CI/CD Pipeline!',
- Save server.js, then push the change by running:
git add server.js
git commit -m "Update welcome message"
git push origin main
- Switch back to the Actions tab. A new workflow run appears automatically.
- Wait for all three jobs to complete with green checkmarks.
- Refresh your browser at your EC2 public IP on port 3000. The JSON response now shows your updated message.
Workflow failed at the deploy step?
Check that the EC2_SSH_KEY secret contains the full contents of your .pem file, including the -----BEGIN RSA PRIVATE KEY----- and -----END RSA PRIVATE KEY----- lines.
Verify that the EC2_HOST secret matches your current EC2 public IP. EC2 public IPs can change if you stop and restart the instance.
Help me debug my failing GitHub Actions deploy step.
Your CI/CD pipeline is fully operational. Every push to main now triggers a complete test-build-deploy cycle with zero manual intervention. Next up, you will tackle a secret mission that adds environment separation to your pipeline.
Secret mission
Add a Staging Environment
In production, teams never deploy straight to the live server. They use a staging environment to test changes first. Your mission: add branch-based deployment so pushes to develop go to staging and pushes to main go to production.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Your EC2 instance is covered by AWS free plan credits, but it runs 24/7 and consumes credits if left on. GitHub Container Registry storage is currently free.
Cost warning
Your EC2 t3.micro instance consumes AWS free plan credits while running. You have up to $200 in credits and 6 months of free plan usage, but leaving the instance running continuously will deplete credits faster. If you are not actively using this project, stop or terminate the instance to preserve your credits for other projects.
Resources you used:
- AWS EC2 t3.micro instance (running Docker containers).
- Docker containers on EC2: devops-app-prod (port 3000) and devops-app-staging (port 3001).
- GitHub Container Registry images (ghcr.io/YOUR_USERNAME/devops-pipeline-app).
- GitHub Personal Access Token (classic).
- EC2 key pair (.pem file on your computer).
- Local project folder devops-pipeline-app on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want to show this pipeline to others.
- Your EC2 instance will continue using AWS free plan credits while running.
- Monitor your credit usage in the AWS Billing console to make sure you do not run out unexpectedly.
- Your GitHub Actions pipeline will continue to deploy automatically on every push to main or develop.
- Docker Desktop remains installed on your local machine.
Pause - I'll come back to this later
Stop your EC2 instance to pause compute charges while keeping your files and configuration intact.
- Open the AWS EC2 console at https://console.aws.amazon.com/ec2/.
- In the navigation pane, choose Instances.
- Select your instance, then choose Instance state, Stop instance.
- Confirm by choosing Stop.
- Your instance storage still uses minimal credits, but compute charges stop.
- To restart later, select the instance and choose Instance state, Start instance. Note that your public IP will change on restart, so you will need to update the EC2_HOST secret in your GitHub repository.
Delete - I don't want to use this again
Remove all project resources permanently. This cannot be undone.
Terminate the EC2 instance
- Open the AWS EC2 console at https://console.aws.amazon.com/ec2/.
- In the navigation pane, choose Instances.
- Select your instance, then choose Instance state, Terminate (delete) instance.
- Choose Terminate (delete) when prompted for confirmation.
Delete the EC2 key pair
- In the EC2 console navigation pane, choose Key Pairs.
- Select the key pair you created for this project and delete it.
- Delete the .pem file from your local computer as well.
Delete the GHCR container images
- Go to your GitHub profile and click Packages.
- Click the devops-pipeline-app package.
- Click Package settings on the right-hand side.
- At the bottom under "Danger Zone", click Delete this package and confirm.
Revoke your GitHub Personal Access Token
- Go to GitHub Settings (click your profile photo, then Settings).
- Navigate to Developer settings, then Personal access tokens, then Tokens (classic).
- Find the token you created for this project and click Delete.
Delete local project files
- Delete the local project folder by running this command:
macOS
cd ~/Desktop
rm -rf devops-pipeline-app
Windows
cd ~\Desktop
rmdir /s /q devops-pipeline-app
Delete the GitHub repository (optional)
- If you no longer need the repository, go to your repo on GitHub.
- Click Settings, scroll to the bottom under "Danger Zone", and click Delete this repository.
- Confirm the deletion by typing the repository name.
Nice Work!
Nice Work!
Nice work! You just built a production-grade CI/CD pipeline that automatically builds, tests, and deploys your containerized application to a live cloud server.
You've learned how to:
- Containerize a Node.js application with Docker and push images to GitHub Container Registry.
- Write a complete GitHub Actions workflow that runs automated tests, builds a Docker image, and deploys it on every push.
- Configure an AWS EC2 instance as a deployment target with SSH-based deployment and a live public URL.
- Added staging/production environment separation with branch-based deployment so pushes to develop deploy to staging and pushes to main deploy to production.
Ready to quiz yourself?