Containerize and Ship with Docker + CI/CD
Dockerize an Express.js API and automate builds with GitHub Actions.
Introduction
30 Second Summary
Every developer has heard "it works on my machine." Containers solve this by packaging your app with everything it needs to run identically anywhere.
In this project, you will containerize an Express.js API with Docker and build a CI/CD pipeline using GitHub Actions that automatically tests, builds, and publishes your container image every time you push code.
What You'll Build
A production-ready containerized REST API paired with a fully automated pipeline that pushes your Docker image to GitHub Container Registry on every commit.
By the end of this project, you'll have:
- A multi-stage Dockerfile that produces a lean, secure container image with layer caching and a non-root user.
- A GitHub Actions workflow that automatically runs tests, builds your image, and pushes it on every push to main.
- A container image live on ghcr.io that anyone can pull and run without needing your source code.
- Secret Mission: Add a Docker Compose setup for local development with hot-reloading and a separate production configuration.
Are there any prerequisites?
You should have basic Git knowledge and a GitHub account. You also need OrbStack installed on macOS, which provides the docker and docker compose commands you'll use throughout the project.
Set Up the Express.js API
Before you can containerize anything, you need an application to package. In this step, you'll build a small but production-ready REST API that serves as the foundation for everything else in this project.
You'll create an Express.js API with two endpoints, then write tests to verify they work. Having tests matters because your CI/CD pipeline will run them automatically on every push later.
In this step, get ready to:
- Initialize the project and install Express.js.
- Create the API with health and greeting endpoints.
- Write tests and verify everything works locally.
Create the project and install dependencies
Your devops-api directory already exists from the previous step. Now you need to turn it into a proper Node.js project with a package.json that defines your app's metadata and dependencies.
- Create a new file called package.json inside your devops-api folder with the following content:
{
"name": "devops-api",
"version": "1.0.0",
"description": "A containerized Express.js API with CI/CD pipeline",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"test": "node src/index.test.js"
},
"dependencies": {
"express": "^4.22.2"
}
}
What does this file do?
The package.json file is the manifest for any Node.js project. It tells npm what packages to install, how to run your app, and how to run your tests.
- The scripts section defines shortcuts. npm start runs the app, and npm test runs the test file.
- The dependencies section lists Express.js v4.22.2 as the only runtime dependency.
- Install the project dependencies by running:
npm install
You should see output confirming that Express.js was installed, and a new node_modules folder appears in your directory.
- Confirm the install worked by running:
ls
You should see node_modules, package.json, and package-lock.json in the output.
Not seeing node_modules?
Make sure your package.json file is saved and that you're running npm install from inside the devops-api directory. Check that the JSON is valid with no trailing commas or missing brackets.
Help me debug why npm install isn't working
Now you need a place for your application code.
- Create the src directory by running:
mkdir src
This is where your Express.js application and test files will live.
- Create a new file called src/index.js inside the src folder, and add the following code:
const express = require("express");
const app = express();
const PORT = process.env.PORT || 3000;
app.get("/health", (req, res) => {
res.status(200).json({ status: "healthy", timestamp: new Date().toISOString() });
});
app.get("/api/hello/:name", (req, res) => {
const { name } = req.params;
res.status(200).json({ message: `Hello, ${name}! Welcome to DevOps.` });
});
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
module.exports = { app, server };
What does this code do?
This file defines your entire Express.js API. It creates an app with two endpoints:
- GET /health returns a JSON response with status: "healthy" and the current timestamp. This is a standard pattern for monitoring whether a service is running.
- GET /api/hello/:name takes a name from the URL path and returns a personalized greeting. The :name part is a route parameter.
- The PORT variable reads from an environment variable first, falling back to 3000. This makes the app configurable when running inside a container.
- The last line exports both app and server so the test file can import them.
- Save src/index.js.
You won't see a visible change yet. The next substep writes tests that exercise both endpoints.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/index.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/index.js file:
const express = require("express");
const app = express();
const PORT = process.env.PORT || 3000;
app.get("/health", (req, res) => {
res.status(200).json({ status: "healthy", timestamp: new Date().toISOString() });
});
app.get("/api/hello/:name", (req, res) => {
const { name } = req.params;
res.status(200).json({ message: `Hello, ${name}! Welcome to DevOps.` });
});
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
module.exports = { app, server };
Write a basic test
Your CI/CD pipeline will need a way to verify your code works before building a container image. Instead of adding a heavy testing framework, you'll write a lightweight test runner using Node.js's built-in http module.
- Create a new file called src/index.test.js inside the src folder, and start with the test infrastructure:
const http = require("http");
const { app, server } = require("./index");
const BASE_URL = "http://localhost:3000";
function fetch(url) {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
let data = "";
res.on("data", (chunk) => { data += chunk; });
res.on("end", () => {
resolve({ status: res.statusCode, body: JSON.parse(data) });
});
}).on("error", reject);
});
}
What does this code do?
This section sets up the test infrastructure. It imports the app and server from index.js so the server starts automatically when the test file runs.
The fetch function is a tiny HTTP client built on Node.js's built-in http module. It makes a GET request and returns the status code and parsed JSON body. No external dependencies needed.
- Below the fetch function, add the test runner that exercises both endpoints:
async function runTests() {
let passed = 0;
let failed = 0;
// Test 1: Health endpoint
try {
const res = await fetch(`${BASE_URL}/health`);
if (res.status === 200 && res.body.status === "healthy") {
console.log("PASS: GET /health returns 200 with healthy status");
passed++;
} else {
console.log("FAIL: GET /health unexpected response");
failed++;
}
} catch (err) {
console.log("FAIL: GET /health error:", err.message);
failed++;
}
// Test 2: Hello endpoint
try {
const res = await fetch(`${BASE_URL}/api/hello/World`);
if (res.status === 200 && res.body.message === "Hello, World! Welcome to DevOps.") {
console.log("PASS: GET /api/hello/World returns correct message");
passed++;
} else {
console.log("FAIL: GET /api/hello/World unexpected response");
failed++;
}
} catch (err) {
console.log("FAIL: GET /api/hello/World error:", err.message);
failed++;
}
console.log(`\nResults: ${passed} passed, ${failed} failed`);
server.close();
process.exit(failed > 0 ? 1 : 0);
}
runTests();
What does this code do?
The runTests function sends real HTTP requests to your running server and checks the responses:
- Test 1 hits GET /health and verifies it returns status 200 with "healthy" in the body.
- Test 2 hits GET /api/hello/World and checks the greeting message matches exactly.
- After both tests, it prints a summary, closes the server, and exits with code 1 if any test failed. CI/CD pipelines use this exit code to detect failures.
- Save src/index.test.js.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/index.test.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/index.test.js file:
const http = require("http");
const { app, server } = require("./index");
const BASE_URL = "http://localhost:3000";
function fetch(url) {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
let data = "";
res.on("data", (chunk) => { data += chunk; });
res.on("end", () => {
resolve({ status: res.statusCode, body: JSON.parse(data) });
});
}).on("error", reject);
});
}
async function runTests() {
let passed = 0;
let failed = 0;
// Test 1: Health endpoint
try {
const res = await fetch(`${BASE_URL}/health`);
if (res.status === 200 && res.body.status === "healthy") {
console.log("PASS: GET /health returns 200 with healthy status");
passed++;
} else {
console.log("FAIL: GET /health unexpected response");
failed++;
}
} catch (err) {
console.log("FAIL: GET /health error:", err.message);
failed++;
}
// Test 2: Hello endpoint
try {
const res = await fetch(`${BASE_URL}/api/hello/World`);
if (res.status === 200 && res.body.message === "Hello, World! Welcome to DevOps.") {
console.log("PASS: GET /api/hello/World returns correct message");
passed++;
} else {
console.log("FAIL: GET /api/hello/World unexpected response");
failed++;
}
} catch (err) {
console.log("FAIL: GET /api/hello/World error:", err.message);
failed++;
}
console.log(`\nResults: ${passed} passed, ${failed} failed`);
server.close();
process.exit(failed > 0 ? 1 : 0);
}
runTests();
Verify locally
Your API and tests are written. Time to confirm everything works before you move on to containerization.
- Start the server by running:
node src/index.js
You should see Server running on port 3000 in your terminal.
- Open a second terminal tab or window.
- Test the health endpoint by running:
curl http://localhost:3000/health
You should see a JSON response with "status":"healthy" and a timestamp.
- Test the greeting endpoint by running:
curl http://localhost:3000/api/hello/World
You should see {"message":"Hello, World! Welcome to DevOps."} in the response.
- Go back to the first terminal and stop the server by pressing Ctrl+C.
- Run the automated tests by running:
npm test
You should see both tests pass with the output Results: 2 passed, 0 failed.
Tests failing or connection refused?
If you see a connection error, make sure you stopped the previous node src/index.js process before running npm test. The test file starts its own server, so port 3000 must be free.
If you see a syntax error, double check that your src/index.js and src/index.test.js files match the code above exactly.
Help me debug why my tests are failing
Your API is tested and working locally. Next up, you'll package it into a Docker container so it runs identically on any machine.
Containerize with Docker
Your API works locally and tests pass. But right now, it only runs on your machine with your specific Node.js version and OS.
Docker solves this by packaging your app into a container that runs identically everywhere. In this step, you'll write a production-quality Dockerfile using a multi-stage build pattern.
In this step, get ready to:
- Create a .dockerignore file to keep your Docker image lean.
- Write a multi-stage Dockerfile with layer caching and a non-root user.
- Build and run the container locally to verify everything works.
Create the .dockerignore file
When Docker builds an image, it sends everything in your project directory to the Docker engine as the "build context". Without a .dockerignore, that includes node_modules, .git history, and other files your container doesn't need.
- Create a file called .dockerignore in your devops-api folder, then add the following content:
node_modules
.git
.github
.gitignore
*.md
docker-compose.yml
.env
What does this file do?
- node_modules is excluded because you'll install fresh dependencies inside the container.
- .git and .github contain version history and workflow files that aren't needed at runtime.
- *.md catches any README or documentation files.
- Excluding these files makes your build context smaller, which speeds up builds and reduces your final image size.
- Save .dockerignore.
- Confirm the file exists by running:
ls -a
You should see .dockerignore listed alongside your other project files.
Don't see .dockerignore in the output?
Files starting with a dot are hidden by default. Make sure you used ls -a (with the -a flag) to show hidden files.
Also confirm you created the file in the devops-api folder, not inside src/.
help me find my .dockerignore file
Write a multi-stage Dockerfile
A multi-stage Dockerfile splits your build into separate stages. Each stage can have its own base image and instructions. Only the final stage becomes your production image.
Why multi-stage builds?
A single-stage Dockerfile would install both development and production dependencies in the same image. Multi-stage builds let you install dependencies in one stage, then copy only the production files into a smaller final image.
This reduces your image size and removes tools an attacker could exploit if they gained access to the container.
- Create a file called Dockerfile (no extension) in your devops-api folder, then add Stage 1:
# Stage 1: Install production dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
What does Stage 1 do?
- FROM node:22-alpine AS deps starts from a lightweight Alpine Linux image with Node.js 22 and names this stage deps.
- COPY package.json package-lock.json ./ copies only your dependency manifests first. This creates a cached layer that Docker reuses when only your source code changes.
- npm ci --omit=dev installs production dependencies only, using the exact versions from your lock file.
You won't see a change yet. Stage 2 completes the Dockerfile.
- Below Stage 1, add the production stage:
# Stage 2: Production image
FROM node:22-alpine AS production
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 -G nodejs
# Copy production dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy application source
COPY src ./src
COPY package.json ./
# Set ownership and switch to non-root user
RUN chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3000
CMD ["node", "src/index.js"]
What does Stage 2 do?
- It starts a fresh node:22-alpine image. Nothing from Stage 1 carries over unless you explicitly copy it.
- addgroup and adduser create a non-root user called nodejs. Running as non-root limits what an attacker can do if they compromise the container.
- COPY --from=deps pulls the node_modules folder from Stage 1 into this clean image.
- USER nodejs switches to the non-root user before the container starts.
- CMD ["node", "src/index.js"] defines the command that runs when the container starts.
- Save Dockerfile.
Seeing a syntax error in your editor?
Make sure the file is named exactly Dockerfile with a capital D and no file extension. Some editors try to add .txt automatically.
Also check that the backslash (\) at the end of the addgroup line has no spaces after it.
help me fix my Dockerfile syntax
✔️ Awesome, I've got everything!
Great. Make sure you've saved your Dockerfile before moving on to the build step.
ⓧ I'd like to double check the full code
# Stage 1: Install production dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Stage 2: Production image
FROM node:22-alpine AS production
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 -G nodejs
# Copy production dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy application source
COPY src ./src
COPY package.json ./
# Set ownership and switch to non-root user
RUN chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3000
CMD ["node", "src/index.js"]
Build and run the container locally
Your Dockerfile is ready. Time to build it into an image and run it as a container to confirm everything works end to end.
- Build the Docker image by running this command:
docker build -t devops-api .
What does this command do?
- docker build reads your Dockerfile and executes each instruction to produce an image.
- -t devops-api tags (names) the image so you can reference it later.
- . tells Docker to use the current directory as the build context.
The build takes a moment as Docker downloads the node:22-alpine base image and installs your dependencies. It completes when you see your terminal prompt return with no errors.
Build failing?
If you see a "Cannot connect to the Docker daemon" error, make sure OrbStack is running. Open it from your Applications folder or search for it with Cmd+Space.
If the error mentions package-lock.json not found, run npm install in your project directory first. This generates the lock file that npm ci requires.
help me fix my Docker build error
- Run the container by running this command:
docker run -p 3000:3000 devops-api
What does -p 3000:3000 mean?
The -p flag maps a port on your machine (left side) to a port inside the container (right side). Your app listens on port 3000 inside the container, and this flag makes it accessible at localhost:3000 on your machine.
Your terminal shows Server running on port 3000 and stays open. The container is now running in the foreground.
- Open a new terminal tab by pressing Cmd+T (macOS).
- Test the container's health endpoint by running:
curl http://localhost:3000/health
You should see a JSON response like {"status":"healthy","timestamp":"2026-..."}. This confirms your containerized API is serving requests.
- Switch back to your original terminal tab and press Ctrl+C to stop the container.
Seeing "port is already allocated"?
This means another process is already using port 3000. If you left node src/index.js running from the previous step, stop it first with Ctrl+C in that terminal, then retry the docker run command.
help me fix the port already allocated error
Your API is now containerized and running in Docker. Next up, you'll create a CI/CD pipeline with GitHub Actions that automatically tests, builds, and publishes this image every time you push code.
Set Up the GitHub Actions CI/CD Pipeline
Your Docker image is built and running locally. The problem is that every time you change your code, you'd have to manually rebuild the image and push it to a registry.
GitHub Actions lets you define a workflow that runs automatically whenever you push code. You'll create a CI/CD pipeline that tests your code first, then builds and pushes the Docker image to GitHub Container Registry.
In this step, get ready to:
- Create the workflow directory and configuration file.
- Define a test job that runs your tests automatically.
- Define a build-and-push job that publishes your Docker image to ghcr.io.
Create the workflow file
GitHub Actions looks for workflow files inside a .github/workflows directory at the root of your repository. Each .yml file in that folder defines a separate automated pipeline.
- Create the workflow directory by running this command:
mkdir -p .github/workflows
- Confirm the directory was created by running:
ls .github/workflows
You should see an empty directory with no errors.
- Create a new file at .github/workflows/ci.yml in your text editor and paste the following workflow header:
name: CI/CD Pipeline
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
What does this code do?
- name: gives the workflow a human-readable label that appears in the GitHub Actions UI.
- on: push: branches: [main] tells GitHub to run this workflow every time code is pushed to the main branch.
- The env block defines two variables that both jobs will reference. REGISTRY points to ghcr.io (GitHub's free container registry) and IMAGE_NAME automatically resolves to your GitHub username and repo name.
Why GitHub Container Registry over Docker Hub?
ghcr.io is free, requires no additional account (it uses your existing GitHub account), and integrates natively with GITHUB_TOKEN for authentication. Docker Hub would require creating a separate account and managing separate credentials.
Add the test job
A CI/CD pipeline typically runs tests before building anything. If the tests fail, the pipeline stops and you get notified before a broken image is ever published.
- In ci.yml, add the following test job directly below the jobs: line:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
What does this job do?
- runs-on: ubuntu-latest spins up a fresh Linux virtual machine for the job. GitHub provides this for free.
- actions/checkout@v6 clones your repository into the VM so the job can access your code.
- actions/setup-node@v4 installs Node.js 22 to match what your Dockerfile uses.
- npm ci installs dependencies from the lockfile, then npm test runs your test suite.
You won't see a change yet. The next substep adds the second job that depends on this one.
Add the build-and-push job
The second job only runs if the tests pass. It logs into ghcr.io, builds the Docker image, and pushes it to the registry so anyone can pull it.
- In ci.yml, add the build-and-push job directly below the last line of the test job:
build-and-push:
name: Build and Push Image
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Log in to Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
What does this job do?
- needs: test makes this job wait until the test job passes. If tests fail, this job never runs.
- permissions grants the job read access to your code and write access to GitHub Packages (where ghcr.io stores images).
- docker/login-action authenticates with ghcr.io using the built-in GITHUB_TOKEN. No extra secrets to configure.
- docker/metadata-action generates image tags (like main) and labels based on Git metadata.
- docker/build-push-action builds your Dockerfile and pushes the resulting image to ghcr.io in one step.
Why are the actions pinned to commit SHAs?
The long hashes after docker/login-action@65b78e... are commit SHAs. Pinning to a specific commit ensures your pipeline always uses the exact version you tested with. A tag like @v3 could silently update to new code. GitHub recommends pinning third-party actions to SHAs for security.
- Save .github/workflows/ci.yml.
Seeing YAML formatting issues?
YAML is very sensitive to indentation. Each level uses exactly 2 spaces. Make sure the test: and build-and-push: keys are indented 2 spaces under jobs:. Do not use tabs.
Help me check my ci.yml indentation and formatting.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete ci.yml file. Make sure yours matches exactly, including indentation:
name: CI/CD Pipeline
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
build-and-push:
name: Build and Push Image
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Log in to Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
Your CI/CD pipeline is defined and ready to go. Next up, you'll push everything to GitHub and watch the pipeline run for real.
Push to GitHub and Watch the Pipeline Run
Your Dockerfile is tested, your workflow file is ready, and your app runs inside a container. Now it's time to connect everything by pushing your code to GitHub.
When you push to the main branch, GitHub Actions kicks off automatically. It runs your tests, builds the Docker image, and publishes it to GitHub Container Registry.
In this step, get ready to:
- Create a GitHub repository and push your code.
- Monitor the CI/CD pipeline running in GitHub Actions.
- Pull and run the published container image from ghcr.io.
Create a GitHub repository and push
Your CI/CD workflow needs a GitHub repo to live in. When code lands on the main branch, it triggers the pipeline you defined in ci.yml.
- Open your browser and navigate to github.com/new.
- Enter devops-api in the Repository name field.
- Select Public for the repository visibility.
- Leave all checkboxes unchecked (no README, no .gitignore, no license).
- Click Create repository.
Why leave it empty?
You already have code locally. If GitHub adds a README or .gitignore on its side, your first push will hit a merge conflict. Starting with an empty repo avoids that.
GitHub shows a quick-start page with commands you can use. You already have files ready, so follow the steps below instead.
- Return to your terminal inside the devops-api folder.
- Initialize Git, commit your files, 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"]]/devops-api.git
git push -u origin main
What do these commands do?
- git init turns the folder into a Git repository.
- git add . stages every file for the first commit.
- git branch -M main renames the default branch to main (matching the trigger in your workflow).
- git remote add origin links your local repo to the one you just created on GitHub.
- git push -u origin main uploads your code and sets main as the default upstream branch.
- Refresh your GitHub repository page in the browser. You should see all your project files listed.
Push rejected or authentication error?
- If you see a permission denied error, make sure you're authenticated with GitHub. Run gh auth login if you have the GitHub CLI, or set up an SSH key or personal access token.
- If the push is rejected because the remote contains work you don't have locally, you likely initialized the repo with a README. Delete the repo on GitHub, recreate it without a README, and push again.
Still stuck? Help me push my code to GitHub. I'm getting an authentication or rejection error.
Monitor the pipeline
The moment your code hit the main branch, GitHub Actions detected the workflow file and started running it. Let's watch it work.
- On your repository page, click the Actions tab at the top.
- You should see a workflow run called Initial commit (matching your commit message). Click on it.
- Click the Run Tests job to expand its logs. Watch it check out your code, install dependencies, and run your tests.
- Once the test job shows a green checkmark, click the Build and Push Image job to see it log in to ghcr.io, build your Docker image, and push it.
What should I see?
Each job has a series of steps with expandable logs. Green checkmarks mean success. The full pipeline takes 1-3 minutes depending on GitHub's runner availability.
The Build and Push Image job only starts after Run Tests passes. This is because of the needs: test line in your workflow. If tests fail, the image never gets built or published.
- Wait for both jobs to complete with green checkmarks.
- Return to your repository's main page by clicking the repository name at the top.
- In the right sidebar, find the Packages section. Click on your devops-api package to see it listed.
Pipeline failed?
- Click on the failed job to expand its logs. The error message tells you which step broke.
- If the test job fails, check that src/index.test.js matches the code from the previous step. A typo in the expected output string causes assertion failures.
- If the build-and-push job fails with a permissions error, make sure your workflow file includes the permissions block with packages: write.
Need help? My GitHub Actions pipeline is failing. Help me read the error logs and fix it.
Now let's make the package publicly pullable so anyone (including your local Docker without special authentication) can pull it.
- On the package page, click Package settings in the right sidebar.
- Scroll down to the Danger Zone section.
- Click Change visibility, select Public, type the package name to confirm, and save.
Why change visibility?
Packages on ghcr.io default to private, even when the repository is public. Making the package public means anyone can pull it with a simple docker pull command. No login required.
Pull and run the published image
Your image is now live on ghcr.io. Let's close the loop by pulling it fresh and running it locally. This proves the entire pipeline works end to end.
- Pull your published image by running this command:
docker pull ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/devops-api:main
What does this command do?
This downloads the exact image that GitHub Actions built and pushed. The :main tag was applied by the metadata-action based on the branch name.
- Run the pulled image by running this command:
docker run -p 3000:3000 ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/devops-api:main
- Open a new terminal tab or window.
- Test the running container by running this command:
curl http://localhost:3000/health
- You should see a JSON response with "status":"healthy". This confirms the full loop works: push code, tests run, image builds, image publishes, image is pullable and runnable.
- Stop the container by pressing Ctrl+C in the terminal where it's running.
Port 3000 already in use?
- If you see an error about port 3000 being in use, a container from a previous step may still be running.
- Run docker ps to list running containers, then docker stop <container-id> to stop the old one.
Still having issues? Help me fix a port conflict when running my Docker container on port 3000.
You just completed the full DevOps loop. Every push to main now automatically tests your code, builds a fresh Docker image, and publishes it for anyone to pull. Next up: a secret mission that adds Docker Compose for seamless local development with hot-reloading.
Secret mission
Add Docker Compose for Local Development
Your CI/CD pipeline handles production builds automatically. But what about local development? Every code change still requires restarting the server manually. Add Docker Compose to get instant hot-reloading in development and a containerized test runner.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses free services, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local project folder devops-api on your Desktop.
- GitHub repository devops-api.
- Container image on ghcr.io.
- Local Docker images and containers built during the project.
Keep everything running
No action needed. Choose this if you're still actively building or want to revisit the project soon.
- Your devops-api folder stays on your Desktop.
- Your GitHub repository remains available for future pushes.
- Your container image stays on ghcr.io and is publicly pullable.
- Local Docker images remain cached on disk.
Pause - I'll come back to this later
Stop any running containers to free up memory, but keep all files and remote resources intact.
- Stop any running containers by running:
- Your project folder, GitHub repo, and ghcr.io image all remain untouched.
docker compose down
When you return, you can start fresh with docker compose up dev from inside your devops-api folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below.
- Stop and remove all project containers and images by running:
docker compose down --rmi all
- Remove the locally built devops-api image by running:
docker rmi devops-api
- Delete the project folder from your Desktop by running:
cd ~/Desktop
rm -rf devops-api
- Delete the container image from ghcr.io:
- Go to your devops-api repository on GitHub.
- In the right sidebar, click Packages.
- Click the package name.
- Click View and manage all versions.
- Click the delete option next to the version, then confirm by typing the package name.
- Delete the GitHub repository:
- Go to your devops-api repository on GitHub.
- Click Settings.
- Scroll down to the Danger Zone section.
- Click Delete this repository.
- Click I want to delete this repository.
- Click I have read and understand these effects.
- Type the repository name to confirm, then click Delete this repository.
That's a Wrap!
That's a Wrap!
Nice work! You just built a professional DevOps pipeline from scratch.
You've learned how to:
- Containerize an Express.js API with a production-quality multi-stage Dockerfile that uses layer caching and a non-root user.
- Create a GitHub Actions CI/CD workflow that automatically tests, builds, and pushes your container image on every push to main.
- Publish your container image to GitHub Container Registry (ghcr.io), publicly pullable by anyone without authentication.
- Secret Mission: Added Docker Compose for seamless local development with hot-reloading and a containerized test service.
Ready to quiz yourself?