Containerize and Auto-Publish a Node.js API

Build a CI/CD pipeline that auto-publishes a Docker image to GitHub Container Registry.

Introduction

30 Second Summary

Every time a developer pushes code, they want it to reach users as quickly and reliably as possible. Container images make shipping software predictable, and automated pipelines eliminate the manual steps that slow teams down.

In this project, you will build a Node.js API, package it into a production-optimized Docker image, and create a GitHub Actions pipeline that automatically publishes the image to a container registry every time you push code.

What You'll Build

A Node.js Express API containerized with a multi-stage Dockerfile and connected to a GitHub Actions workflow that auto-publishes to GitHub Container Registry.

By the end of this project, you'll have:

  • A health endpoint API built with Node.js and Express that returns system information.
  • A multi-stage Dockerfile that produces a minimal, production-optimized container image.
  • A GitHub Actions workflow that automatically builds and pushes your image to GitHub Container Registry on every push to main.
  • Secret Mission: Add an automated health check job that verifies your container works before the pipeline passes.

Are there any prerequisites?

This project assumes basic familiarity with Docker (writing a Dockerfile, running docker build and docker run) and GitHub Actions (workflow YAML syntax, triggers). You do not need to be an expert in either.

Set Up the Project

Every CI/CD pipeline starts with a code repository. When you push code to this repository later, your GitHub Actions workflow will automatically build and publish a container image.

In this step, you'll create a public GitHub repository, clone it to your machine, and initialize it as a Node.js project with Express installed.

In this step, get ready to:
  • Create a public GitHub repository called health-api.
  • Clone the repository and open it in VS Code.
  • Initialize the Node.js project and install Express.
Create and clone the GitHub repository

Your pipeline needs a remote repository to watch for changes. You'll create a public repo on GitHub so that GitHub Actions can run for free with unlimited minutes.

  • Open your browser and go to github.com.
  • In the upper-right corner of any page, select the + icon, then click New repository.
  • Type health-api in the Repository name field.
  • Select Public for the repository visibility.
  • Toggle Add a README file to on.
  • Click Create repository.

Why a public repository?

GitHub Actions is free for public repositories with unlimited minutes. GitHub Container Registry is also free for public packages. Making the repo public means zero cost for this project.

Now you'll clone the repository to your local machine so you can work on it in VS Code.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) and typing Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone your repository and move into the project folder by running these commands:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/health-api.git
cd health-api

Where do I find my clone URL?

On your new repository page, click the green Code button. Copy the HTTPS URL shown there and use it in the clone command above.

  • Confirm the clone worked by running:
ls

You should see a README.md file listed in the output.

Clone failed or repository not found?

Make sure you replaced the username in the URL with your actual GitHub username. The URL should match what you see on your repository's Code button dropdown.

If you see a permission error, confirm you are logged in to Git on your machine. Run git config user.name to check.

Help me troubleshoot my git clone error.

  • Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows) and typing Visual Studio Code.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the health-api folder, and click Open.

You should see README.md listed in the Explorer sidebar on the left.

Initialize the project and install Express

Your repository is an empty shell right now. You need to turn it into a Node.js project by creating a package.json file, then install the Express framework that your API will use.

  • Open a terminal inside VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Initialize the Node.js project by running this command:
npm init -y

What does this command do?

The npm init -y command creates a package.json file with default values. The -y flag accepts all defaults automatically so you don't have to answer each prompt one by one.

This file tracks your project's dependencies, scripts, and metadata. Every Node.js project needs one.

You should see a confirmation message showing the contents of the new package.json file printed in your terminal.

  • Install Express as a dependency by running this command:
npm install express@4

What does this command do?

This installs Express version 4 and adds it to the dependencies section of your package.json. Express is the web framework that will power your health check API.

It also creates a package-lock.json file (which locks exact dependency versions) and a node_modules folder (which holds the installed package files).

  • Verify the installation by running:
ls

You should see node_modules, package.json, package-lock.json, and README.md in the output.

Seeing a permission error or EACCES?

If npm install fails with a permission error, make sure your terminal is open inside the health-api folder. Run pwd to check your current directory.

If you see npm: command not found, Node.js may not be installed correctly. Go back and verify node --version returns a version number.

Help me fix my npm install error.

✔️ Awesome, I've got everything!

Great. Double check you've saved your files and your Explorer sidebar shows node_modules, package.json, package-lock.json, and README.md.

ⓧ I'd like to double check the full code

Open package.json in VS Code and confirm it looks like this. Your version number for Express may show a specific patch version like ^4.21.2 instead of ^4. That is completely fine.

{
  "name": "health-api",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4"
  }
}

Your project foundation is ready. Next up, you'll write the Express API that returns health information about your running service.

Build the Express API

Your project is set up and ready for code. Now you'll build the actual API that your container will serve.

Every production service needs a way to report whether it's alive and healthy. Container orchestrators and load balancers query these health endpoints to decide whether to route traffic to your app. You'll build this standard pattern from scratch using Express.

In this step, get ready to:
  • Create an Express server with a health check endpoint.
  • Test the API locally and verify it responds to requests.
  • Add a .dockerignore file to keep the Docker build clean.
Create the health endpoint server

The /health endpoint will return JSON with the app's status, version, uptime, and a timestamp. This is the same pattern used by Kubernetes readiness probes and cloud load balancers.

  • Create the src directory inside your health-api folder by running this command:
mkdir src
  • Create a new file called server.js inside the src folder by running:
touch src/server.js
  • Confirm the file exists by running:
ls src

You should see server.js in the output.

  • Open src/server.js in VS Code and add the following code:
// Import Express framework
const express = require('express');

// Create the app and configure the port
const app = express();
const PORT = process.env.PORT || 3000;
const startTime = Date.now();

// Health check endpoint - returns system status
app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    version: process.env.npm_package_version || '1.0.0',
    uptime: Math.floor((Date.now() - startTime) / 1000),
    timestamp: new Date().toISOString()
  });
});

// Start the server
app.listen(PORT, () => {
  console.log(`Health API running on port ${PORT}`);
});

What does this code do?

PORT reads from the environment variable or defaults to 3000. This lets the container platform set the port at runtime without changing your code.

startTime records when the server started. The /health endpoint calculates uptime by comparing the current time against this value.

npm_package_version is automatically set by npm when you run the app with npm start. It pulls the version from your package.json so you always know which version is running.

  • Save src/server.js.

File not saving?

Make sure you created the src directory first. If VS Code shows an error about the path not existing, run mkdir src in your terminal and try again.

help me debug a file path issue in VS Code

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file 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;
const startTime = Date.now();

app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    version: process.env.npm_package_version || '1.0.0',
    uptime: Math.floor((Date.now() - startTime) / 1000),
    timestamp: new Date().toISOString()
  });
});

app.listen(PORT, () => {
  console.log(`Health API running on port ${PORT}`);
});
Configure the start script and test locally

Your server code is ready, but Node.js doesn't know how to run it yet. You need a start script in package.json so that both you and the container can launch the app with npm start.

  • Open package.json in VS Code.
  • Replace the entire contents with the following:
{
  "name": "health-api",
  "version": "1.0.0",
  "description": "A health check API with automated container publishing",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4"
  }
}

What changed?

  • description now describes the project so it shows up clearly in registries and on GitHub.
  • main points to src/server.js instead of the default index.js.
  • scripts.start replaces the placeholder test script. Running npm start now launches your server.
  • Save package.json.
  • Start your server by running:
npm start

You should see Health API running on port 3000 in your terminal.

  • Open a second terminal window in VS Code by pressing Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows).
  • Test the health endpoint by running:
curl http://localhost:3000/health

You should see a JSON response with status, version, uptime, and timestamp fields.

  • Go back to your first terminal and stop the server by pressing Ctrl+C.

Not seeing the JSON response?

  • Make sure the server is still running in your first terminal. If you accidentally closed it, run npm start again.
  • Check you're using a second terminal for the curl command. The first terminal is occupied by the running server.
  • Verify the URL is exactly http://localhost:3000/health with no trailing slash or typos.

help me debug why curl isn't getting a response from my Express server

✔️ Awesome, I've got everything!

Great. Make sure you've saved package.json and stopped the server with Ctrl+C before continuing.

ⓧ I'd like to double check the full code

{
  "name": "health-api",
  "version": "1.0.0",
  "description": "A health check API with automated container publishing",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4"
  }
}
Create the .dockerignore file

When Docker builds your image, it sends everything in the project folder to the Docker daemon as the build context. A .dockerignore file tells Docker which files to skip, keeping your image lean and your builds fast.

  • Create a .dockerignore file in the root of your health-api folder by running:
touch .dockerignore
  • Open .dockerignore in VS Code and add the following:
node_modules
.git
.github
.gitignore
*.md

Why exclude these files?

  • node_modules is rebuilt inside the container during the build. Sending it to the daemon wastes time and can introduce platform-specific binaries.
  • .git and .github contain version history and workflow files that the running app doesn't need.
  • *.md matches README and other documentation files that add size without value inside the container.
  • Save .dockerignore.
  • Confirm the file exists by running:
ls -a

You should see .dockerignore listed alongside package.json and src.

Don't see .dockerignore in the listing?

Files starting with a dot are hidden by default. Make sure you used ls -a (with the -a flag) to show hidden files.

help me find my .dockerignore file

✔️ Awesome, I've got everything!

Great. Your project is now ready for containerization.

ⓧ I'd like to double check the full code

node_modules
.git
.github
.gitignore
*.md

Your API is built, tested, and ready for Docker. Next up, you'll write a multi-stage Dockerfile that packages this server into a production-optimized container image.

Write the Multi-Stage Dockerfile

Your API runs perfectly on your machine, but shipping it to someone else means they need the right Node.js version, the right dependencies, and the right configuration.

A Dockerfile packages your app and its entire runtime into a single image that runs identically anywhere. In this step, you'll write a multi-stage Dockerfile that produces a lean production image, then build and test it locally.

In this step, get ready to:
  • Write a two-stage Dockerfile that separates dependency installation from the final image.
  • Add OCI metadata labels so GitHub links the image back to your repository.
  • Build and run the container locally to verify it works.
Create the two-stage Dockerfile

A standard Dockerfile installs everything in a single image. A multi-stage build uses one stage to install dependencies and a second stage to copy only what's needed for production.

Why multi-stage builds?

The first stage can include build tools, compilers, and dev dependencies that bloat the image. The second stage starts fresh and copies only the production artifacts, resulting in a significantly smaller final image.

Smaller images pull faster, start faster, and have a smaller attack surface. This is the standard pattern for production Node.js containers.

  • In VS Code's Explorer sidebar, right-click inside the health-api folder and select New File.
  • Name the file Dockerfile (no extension, capital D).
  • Add the build stage by pasting this code into Dockerfile:
# Stage 1: Install dependencies
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production

What does this code do?

  • FROM node:22-alpine AS build starts from a minimal Alpine Linux image with Node.js 22 and names this stage build.
  • WORKDIR /app sets the working directory inside the container.
  • COPY package.json package-lock.json* ./ copies your dependency files. The * wildcard means the build won't fail if package-lock.json doesn't exist yet.
  • RUN npm ci --only=production installs exact versions from the lockfile, skipping dev dependencies for a cleaner install.
  • Below the build stage, add the production stage:
# Stage 2: Production image
FROM node:22-alpine AS production
WORKDIR /app

COPY --from=build /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./

EXPOSE 3000

USER node

CMD ["node", "src/server.js"]

What does this code do?

  • FROM node:22-alpine AS production starts a brand new image. Nothing from the build stage carries over unless explicitly copied.
  • COPY --from=build /app/node_modules ./node_modules pulls only the installed dependencies from the first stage into this clean image.
  • COPY src ./src and COPY package.json ./ bring in your application source code.
  • USER node switches to a non-root user for security. The node:22-alpine image includes this user by default.
  • CMD ["node", "src/server.js"] tells Docker how to start your API when the container runs.
  • Save Dockerfile.

You won't see a visible change yet. The next substep adds metadata labels, and then we'll build and run the image.

Add OCI metadata labels

OCI labels are standardized metadata fields that container registries read to display information about your image. Adding a source label tells GitHub Container Registry which repository owns this image.

  • In Dockerfile, find this line in the production stage:
WORKDIR /app
  • Add these three LABEL lines below WORKDIR /app, with a blank line above and below the group:
LABEL org.opencontainers.image.source="https://github.com/[[GITHUB_USERNAME="your-github-username"]]/health-api"
LABEL org.opencontainers.image.description="A health check API with automated container publishing"
LABEL org.opencontainers.image.licenses="ISC"

What do these labels do?

  • org.opencontainers.image.source links the published image to your GitHub repository. This is how the package appears connected to your repo on GitHub.
  • org.opencontainers.image.description sets the description shown on the package page (up to 512 characters).
  • org.opencontainers.image.licenses displays the license type in the package details.
  • Save Dockerfile.

Not sure where the labels go?

The labels sit between WORKDIR /app and COPY --from=build in the production stage. Make sure there's a blank line before and after the label group.

help me place OCI labels in my Dockerfile

✔️ Awesome, I've got everything!

Great. Double check you've saved your Dockerfile before moving on.

ⓧ I'd like to double check the full code

# Stage 1: Install dependencies
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production

# Stage 2: Production image
FROM node:22-alpine AS production
WORKDIR /app

LABEL org.opencontainers.image.source="https://github.com/[[GITHUB_USERNAME="your-github-username"]]/health-api"
LABEL org.opencontainers.image.description="A health check API with automated container publishing"
LABEL org.opencontainers.image.licenses="ISC"

COPY --from=build /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./

EXPOSE 3000

USER node

CMD ["node", "src/server.js"]
Build and run the image locally

Time to turn your Dockerfile into a real container image and verify the API responds from inside it.

  • Build the Docker image by running this command in your terminal (make sure you're in the health-api folder):
docker build -t health-api .

What does this command do?

  • docker build reads your Dockerfile and builds an image.
  • -t health-api tags the image with the name health-api so you can reference it easily.
  • . tells Docker to use the current directory as the build context (where it looks for the Dockerfile and source files).
  • Once the build completes, run the container by running this command:
docker run -p 3000:3000 health-api

What does this command do?

-p 3000:3000 maps port 3000 on your machine to port 3000 inside the container. This lets you reach the API at localhost:3000 just like before.

You should see Health API running on port 3000 in your terminal. The container is now running and holding the terminal open.

  • Open a second terminal tab or window.
  • Test the containerized API by running this command:
curl http://localhost:3000/health

You should see the same JSON response you saw when running locally with npm start. The response includes the status, version, uptime, and timestamp fields.

  • Go back to the terminal running the container and press Ctrl+C to stop it.

Container won't start or curl fails?

  • If you see a port conflict error, stop the local server from Step 2 first. Both can't use port 3000 at the same time.
  • If the build fails with a permission error, make sure Docker Desktop is running.
  • If curl returns Connection refused, wait a couple of seconds for the container to finish starting, then try again.

debug my Docker build or container startup issue

Your API is now running inside a container that bundles everything it needs. Next up, you'll create a GitHub Actions workflow that builds and publishes this image automatically every time you push code.

Create the Publish Workflow

Your Docker image builds and runs perfectly on your machine. But in a real team workflow, you do not want every developer building and uploading images manually.

In this step, you will create a GitHub Actions workflow that automatically builds your Docker image and publishes it to GitHub Container Registry every time you push code to main. This is a core CI/CD pattern used by professional teams to ship containerized software.

In this step, get ready to:
  • Create a workflow file that triggers on push to main.
  • Configure the workflow to build and push your image to GitHub Container Registry.
  • Push your code and verify the published image in your Packages tab.
Create the workflow file

GitHub Actions workflows live in a special directory at the root of your repository. Each .yml file in .github/workflows/ defines a separate pipeline that GitHub runs automatically based on your triggers.

  • Create the workflow directory by running this command in your terminal (make sure you are inside the health-api folder):
mkdir -p .github/workflows
  • Create a new file called publish.yml inside .github/workflows/ by running:
touch .github/workflows/publish.yml
  • Confirm the file exists by running:
ls .github/workflows/

You should see publish.yml in the output.

  • Open .github/workflows/publish.yml in VS Code and add the workflow trigger and environment variables:
name: Build and Publish Container Image

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

What does this code do?

  • name: gives the workflow a human-readable title that appears in the Actions tab.
  • on: push: branches: [main] tells GitHub to run this workflow every time code is pushed to the main branch.
  • REGISTRY: ghcr.io sets GitHub Container Registry as the target. This is where your image will be stored.
  • IMAGE_NAME: ${{ github.repository }} dynamically uses your GitHub username and repo name (e.g. yourname/health-api) as the image name.
Add the build-and-push job

The workflow needs a job that checks out your code, authenticates with the registry, and builds and pushes the image. GitHub provides official actions for each of these tasks.

  • In .github/workflows/publish.yml, add the following below the env: block:
jobs:
  build-and-push:
    runs-on: ubuntu-latest

    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Log in to the Container registry
        uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels) for Docker
        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 code do?

  • permissions: grants the workflow's built-in GITHUB_TOKEN permission to read your repo and write (push) packages. Without this, the push to the registry will fail.
  • docker/login-action authenticates with ghcr.io using the built-in GITHUB_TOKEN. No extra secrets or accounts needed.
  • docker/metadata-action generates image tags (like main) and OCI labels automatically based on your branch and repo.
  • docker/build-push-action builds the Docker image from your Dockerfile and pushes it to the registry in one step.

The actions are pinned to specific commit SHAs rather than version tags. This is a security best practice that prevents supply-chain attacks if a tag is ever moved to malicious code.

Why does this use GITHUB_TOKEN instead of a personal access token?

Every GitHub Actions workflow automatically receives a GITHUB_TOKEN that is scoped to the repository running the workflow. For pushing to GitHub Container Registry, this token is sufficient. You do not need to create or manage any extra credentials.

  • Save .github/workflows/publish.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

name: Build and Publish Container Image

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Log in to the Container registry
        uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels) for Docker
        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 }}
Push and verify the workflow

Your workflow file is ready. Now you need to commit everything and push to main so GitHub Actions picks it up and runs it.

  • Stage and commit all your files by running:
git add .
git commit -m "Add publish workflow"
  • Push to your main branch by running:
git push origin main
  • Open your health-api repository on GitHub in your browser.
  • Click the Actions tab at the top of your repository page.

You should see a workflow run called Add publish workflow in progress or completed. A green checkmark means the image was built and pushed successfully.

Seeing a red X on the workflow?

  • Click the failed run and then click the build-and-push job to see the logs. The error message will tell you which step failed.
  • Check that your YAML indentation is correct. YAML is sensitive to spaces. Compare your file against the full code in the double-check tab above.
  • Make sure your repository is set to Public. Private repositories have limited free Actions minutes.

Help me debug my GitHub Actions workflow failure.

  • Once the workflow shows a green checkmark, navigate to your GitHub profile page.
  • Click the Packages tab in your profile header.

You should see health-api listed as a container package.

Why is the package private?

When first published, a package defaults to private visibility. You can still see it because you own it. To allow anyone to pull the image without authentication (which you will need in the next step), you must change its visibility to public.

  • Click the health-api package name to open its landing page.
  • Click Package settings on the right-hand side.
  • Scroll to the bottom. Under Danger Zone, click Change visibility.
  • Select Public, type the package name to confirm, and click I understand the consequences, change package visibility.

Your image is now live on GitHub Container Registry. Next up, you will pull and run this published image to prove it works from anywhere.

Pull and Run Your Published Image

Your image is live on GitHub Container Registry. Anyone with the right access can now pull and run your API without cloning the repo, installing Node.js, or setting up dependencies.

In this step, you will pull your published image, run it locally to prove it works from the registry, and then push a code change to watch the entire pipeline fire automatically.

In this step, get ready to:
  • Pull your published image from GitHub Container Registry.
  • Run the container and verify the health endpoint responds.
  • Push a code change and watch the pipeline auto-rebuild.
Make your package public

GitHub Container Registry defaults new packages to private visibility. To pull your image without extra authentication, you need to make it public first.

  • Open your browser and go to your GitHub profile page.
  • Click the Packages tab.
  • Click the health-api package.
  • On the package landing page, click Package settings on the right-hand side.
  • Scroll to the bottom of the page to the Danger Zone section.
  • Click Change visibility.
  • Select Public.
  • Type health-api to confirm, then click I understand the consequences, change package visibility.

Why make it public?

Public packages on GitHub Container Registry allow anonymous pulls. This means anyone can run your image with a single docker pull command. Private packages require authentication via a personal access token, which adds friction for distribution.

Pull the image from GitHub Container Registry

Your image is now publicly available. Let's pull it down as if you were a different machine or team member consuming the image for the first time.

  • First, remove your locally built image so you know the pull is fetching from the registry. Run this command:
docker rmi health-api
  • Pull your published image from GitHub Container Registry by running:
docker pull ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/health-api:main

You should see Docker downloading layers from ghcr.io. Once it finishes, you will see a success message with the image digest.

Pull failed or access denied?

Make sure you changed the package visibility to public in the previous substep. If you skipped that, Docker cannot pull without authentication.

Double check that your GitHub username in the pull command matches the exact casing of your GitHub account. GitHub usernames in image paths are lowercase.

Help me troubleshoot a docker pull failure from ghcr.io

Run the pulled image and verify the health endpoint

Now that you have the registry image locally, run it exactly as you would in production to confirm everything works.

  • Start the container by running:
docker run -p 3000:3000 ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/health-api:main

You should see the message Health API running on port 3000 in your terminal.

  • Open a new terminal tab or window and test the health endpoint by running:
curl http://localhost:3000/health

You should see JSON output with status, version, uptime, and timestamp fields.

What just happened?

You pulled an image that was built in the cloud by GitHub Actions and ran it locally. This is the exact workflow teams use in production. A CI pipeline builds and publishes the image. Developers, staging environments, and production servers pull and run it.

  • Stop the running container by pressing Ctrl+C in the terminal where it is running.

Port already in use?

If you see an error about port 3000 being in use, you may have a previous container still running. Stop all containers using port 3000 by running docker ps to find the container ID, then docker stop <container-id>.

Help me fix a port conflict with Docker

Push a change and watch the pipeline auto-rebuild

The real power of your pipeline is that it runs automatically on every push. Let's add a new endpoint and watch the workflow rebuild and publish a fresh image.

  • In VS Code, open src/server.js.
  • Find the app.listen block at the bottom of the file. Add the following /info endpoint above it:
app.get('/info', (req, res) => {
  res.json({
    name: 'health-api',
    description: 'A health check API with automated container publishing'
  });
});

What does this code do?

This adds a simple GET /info endpoint that returns basic metadata about your API. The content of the change does not matter much. The goal is to trigger the pipeline and prove it rebuilds automatically.

  • Save src/server.js.
  • Commit and push your change by running these commands:
git add .
git commit -m "Add /info endpoint"
git push
  • Open your repository on GitHub and click the Actions tab.
  • Watch the new workflow run appear automatically. Wait for it to complete with a green checkmark.

Once the workflow finishes, a new image version is published to your Packages tab. Your pipeline detected the push, built a fresh image with the /info endpoint included, and published it to the registry.

Workflow not triggering?

Make sure you pushed to the main branch. The workflow only triggers on pushes to main, not other branches.

If you see the push succeeded but no workflow appears, check that the .github/workflows/publish.yml file exists in your repository on GitHub.

Help me figure out why my GitHub Actions workflow is not running

You have proven the full cycle works. Code goes in, a container image comes out, and anyone can pull and run it. Next up, you will extend this pipeline with a Secret Mission.

Secret mission

Add a Container Health Check Job

Your pipeline builds and pushes the image. But how do you know the container actually works? Add a verification job that pulls your published image and confirms it responds correctly before the workflow passes.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and publishes to 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 (health-api on your Desktop).
  • Local Docker images built during the project.
  • GitHub repository (health-api).
  • Published container image on GitHub Container Registry (ghcr.io).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep the pipeline publishing new images on every push.

  • Your GitHub Actions workflow will continue to build and push a new image every time you push to main.
  • GitHub Actions minutes and container storage remain free for public repositories.
  • Your local Docker images stay on disk until you manually remove them.

Pause - I'll come back to this later

Free up local disk space while keeping your GitHub resources intact for later.

  • Remove the local Docker images by running:
  • Stop any running containers first, then prune the images.
docker stop $(docker ps -q --filter ancestor=health-api) 2>/dev/null
docker rmi health-api
  • Your GitHub repository and published package remain available for you to come back to later.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Delete the local project folder by running:

macOS

cd ~/Desktop
rm -rf health-api

Windows

cd ~\Desktop
rmdir /s /q health-api
  • Remove local Docker images by running:
docker rmi health-api
docker rmi $(docker images "ghcr.io/*/health-api" -q) 2>/dev/null
  • Delete the published container package on GitHub:
  • Go to your GitHub profile page.
  • Click the Packages tab in the header.
  • Click the health-api package.
  • Click Package settings on the right-hand side.
  • Scroll to the bottom. Under Danger zone, click Delete this package.
  • Type the package name to confirm, then click I understand the consequences, delete this package.
  • Delete the GitHub repository:
  • Open your health-api repository on GitHub.
  • Click Settings.
  • Scroll down to the Danger Zone section and 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.

Nice Work!

Nice Work!

You just built a complete container publishing pipeline. Every time you push code to main, your Docker image is automatically built and published to GitHub Container Registry for anyone to pull and run.

Here's what you learned:

  • Created a Node.js Express API with a health endpoint that returns live system information.
  • Wrote a multi-stage Dockerfile that produces an optimized production image by separating the dependency install from the final runtime layer.
  • Built a GitHub Actions workflow that automatically builds and pushes your container image to GitHub Container Registry on every push to main.
  • Added an automated container health check job that verifies your published image actually works before the pipeline reports success.

Ready to quiz yourself?