Containerize a Node.js API with Docker

Containerize an Express API and automate Docker builds with GitHub Actions.

Introduction

30 Second Summary

You've built an app that works perfectly on your laptop. But the moment a teammate tries to run it, things break because they have a different setup.

In this project, you will build a simple Express API and package it into a Docker container. You will then set up a CI/CD pipeline with GitHub Actions that automatically publishes your Docker image on every push.

What You'll Build

You'll have a book API running inside a Docker container on your machine, backed by a GitHub pipeline that automatically rebuilds and publishes the image every time you push new code.

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

  • An API running inside a Docker container that you can open in your browser at http://localhost:3000/api/books and see live book data.
  • A production-ready Dockerfile with layer caching, a .dockerignore file, and a non-root user that rebuilds in seconds when you change code.
  • A GitHub Actions workflow that automatically builds and publishes your Docker image to GitHub Container Registry on every push.
  • Secret Mission: Shrink your Docker image with a multi-stage build to dramatically cut the final image size.

Are there any prerequisites?

This project assumes basic JavaScript and Node.js knowledge. You will need a GitHub account, which is free to create. Docker Desktop installation is covered in Step 1.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Install Docker Desktop

Your app runs fine on your machine today. But what happens when a teammate pulls your code with a different Node.js version or missing dependencies? The build breaks.

Docker solves this by packaging your app and its entire environment into a single container. In this step, you'll install Docker Desktop, which gives you the Docker CLI and engine on Windows.

In this step, get ready to:
  • Install Docker Desktop on Windows with the WSL 2 backend.
  • Verify Docker can pull and run container images.
Download and install Docker Desktop
  • Open the Docker Desktop for Windows install page in your browser.
  • Click Docker Desktop for Windows - x86_64 to download the installer.
  • Double-click Docker Desktop Installer.exe to run the installer.
  • The installer asks which installation mode you prefer. Choose per-user installation (this is the default). No administrator privileges are required.
  • On the Configuration page, keep Use WSL 2 instead of Hyper-V checked.
  • Click through the remaining prompts and select Close when the installation finishes.

What is WSL 2?

WSL 2 (Windows Subsystem for Linux version 2) lets Docker run Linux containers on your Windows machine. It is the default backend for Docker Desktop and works for most users without administrator privileges.

If WSL 2 is not yet enabled on your machine, the installer will prompt you to enable it. This is a one-time step that requires administrator privileges and a reboot. After the reboot, rerun the Docker Desktop installer to finish.

Start Docker Desktop
  • Press the Windows key to open your search bar.
  • Type Docker Desktop and press Enter to open it.
  • The Docker Subscription Service Agreement appears. Click Accept to continue.
  • Wait for the Docker engine to start.

You'll see a green Engine running indicator in the bottom-left corner of the Docker Desktop window. This confirms the Docker engine is active and ready for commands.

Don't see the green indicator?

  • Docker Desktop can take a minute or two to fully load on first launch. Give it time before troubleshooting.
  • If it stays stuck on starting, close Docker Desktop completely and reopen it from the search bar.
  • Make sure hardware virtualization is enabled in your BIOS/UEFI settings. Docker needs this to run containers.

Docker Desktop won't start on my Windows machine

Verify Docker works in your terminal
  • Press the Windows key, type PowerShell, and press Enter to open a terminal.
  • Check that Docker is installed by running:
docker --version

✔️ I see a version number

Docker is installed and available in your terminal. Move on to the next command below.

ⓧ Command not found

Your terminal cannot find the Docker CLI. Try these fixes:

  • Close and reopen PowerShell. The installer may have updated your PATH, but your current session does not have the change yet.
  • Make sure Docker Desktop is running with the green Engine running indicator visible.
  • If Docker Desktop just finished installing, try restarting your computer and opening Docker Desktop again.

Before running the next command, think about this: what do you think will happen when Docker tries to run a container called hello-world that does not exist on your machine yet?

  • Test that Docker can pull images and run containers by running:
docker run hello-world

You'll see a Hello from Docker! message followed by an explanation of what Docker did behind the scenes. This confirms Docker can pull images from the internet and run them as containers.

What just happened?

  • Docker looked for the hello-world image on your machine and did not find it.
  • It pulled the image from Docker Hub, Docker's public image registry.
  • It created a container from that image, ran it, and printed the greeting message.

Seeing an error instead?

  • Make sure Docker Desktop is running. The Docker engine must be active to pull images.
  • If you see a network-related error, check your internet connection. Docker needs to download the hello-world image the first time.
  • If you see a permission error, try running PowerShell as Administrator for this one command.

Docker run hello-world is failing on my Windows machine

Docker is installed and ready to go. In the next step, you'll build the Express API that you'll eventually containerize.

Build and Run the Express API Locally

Docker is installed and ready to go. Before you containerize anything, you need a working app to put inside a container.

In this step, you will build a small Express API that returns book data. Running it locally first gives you a baseline to compare against when you run it inside Docker later.

In this step, get ready to:
  • Set up a Node.js project folder with Express installed.
  • Write an Express API with a health check route and a books route.
  • Run the API locally and confirm it returns book data in the browser.
Create the project folder and install Express

Every Node.js project starts with a folder and a package.json file that tracks your project's dependencies.

  • Press the Windows key to open your search bar.
  • Type PowerShell and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir books-api
cd books-api
  • Open this folder in VS Code by running:
code .

Command not recognized?

If code . is not recognized, VS Code was not added to your PATH during installation. Open VS Code manually by pressing the Windows key, typing VS Code, and pressing Enter.

Then click File in the top menu bar, click Open Folder, and select the books-api folder on your Desktop.

help me open my project folder in VS Code

VS Code opens with the books-api folder loaded in the Explorer sidebar on the left.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick, the key above Tab).
  • Initialize a new Node.js project by running this command:
npm init -y

You should see a new package.json file appear in the Explorer sidebar.

  • Install Express by running this command:
npm install express@5.1.0

What just happened?

npm init -y created a package.json file with default settings. This file tracks your project's name, version, and dependencies.

npm install express@5.1.0 downloaded Express version 5.1.0 into a node_modules folder and recorded it as a dependency in package.json.

The generated package.json has some default fields that do not match your project. You will update it now to point at the right entry file and add a start script.

  • Click on package.json in the Explorer sidebar to open it.
  • Select all the content with Ctrl+A and replace it with the following:
{
  "name": "books-api",
  "version": "1.0.0",
  "description": "A simple books API for learning Docker",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.1.0"
  }
}

What changed from the defaults?

  • description now describes your project instead of being blank.
  • main points to server.js (the file you will create next) instead of index.js.
  • scripts now has a start command so you can run your API with npm start.
  • Save package.json with Ctrl+S.
Write the Express API and test the health check

Your project is set up and Express is installed. Now you will write the API code that responds to requests, starting with a health check endpoint.

  • Create a new file by clicking the new file icon at the top of the Explorer sidebar.
  • Type server.js as the file name and press Enter.
  • Paste the following code into server.js:
// Import Express and create the app
const express = require("express");
const app = express();
const PORT = 3000;

// Health check endpoint - confirms the API is running
app.get("/", (req, res) => {
  res.json({ status: "healthy", service: "books-api" });
});

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

What does this code do?

  • require("express") loads the Express library you just installed.
  • express() creates an app instance that can listen for incoming HTTP requests.
  • app.get("/", ...) registers a health check route. When someone visits the root URL, the API responds with a JSON status message.
  • app.listen(PORT, ...) starts the server on port 3000 and prints a confirmation message.
  • Save server.js with Ctrl+S.

Before you run the next command, what do you think will happen in the terminal when you start the server?

  • Start the API server by running this command in the VS Code terminal:
node server.js

You should see this message printed in your terminal:

Books API running on http://localhost:3000

Why does the terminal look stuck?

The terminal is not stuck. The server is running and waiting for incoming requests. It takes over the terminal until you stop it with Ctrl+C. You can still interact with the API through your browser.

  • Open your browser and navigate to http://localhost:3000.

You should see the health check response in your browser: {"status":"healthy","service":"books-api"}. Your API is running.

Not seeing the JSON response?

  • Make sure the terminal still shows the running message. If you see an error instead, check server.js for typos or missing parentheses.
  • If you see a port-in-use error, another process is using port 3000. Close any other running servers and try again.

help me debug my Express server

  • Go back to your VS Code terminal and press Ctrl+C to stop the server.
Add the books route

The health check proves the server runs. Now you will add the main endpoint that returns book data.

  • In server.js, find this line:
// Start the server on port 3000
  • Add the following code directly above that comment:
// Books endpoint - returns a list of books
app.get("/api/books", (req, res) => {
  const books = [
    { id: 1, title: "The Pragmatic Programmer", author: "David Thomas & Andrew Hunt" },
    { id: 2, title: "Clean Code", author: "Robert C. Martin" },
    { id: 3, title: "Design Patterns", author: "Gang of Four" },
  ];
  res.json(books);
});

What does this code do?

  • app.get("/api/books", ...) registers a route that responds when someone visits /api/books.
  • The route creates a hardcoded array of three book objects, each with an id, title, and author.
  • res.json(books) sends the array back to the browser as a JSON response.
  • Save server.js with Ctrl+S.

✔️ Awesome, I've got everything!

Great. Double check you have saved both package.json and server.js.

ⓧ I'd like to double check the full code

Here is the complete server.js file. Make sure yours matches.

const express = require("express");
const app = express();
const PORT = 3000;

app.get("/", (req, res) => {
  res.json({ status: "healthy", service: "books-api" });
});

app.get("/api/books", (req, res) => {
  const books = [
    { id: 1, title: "The Pragmatic Programmer", author: "David Thomas & Andrew Hunt" },
    { id: 2, title: "Clean Code", author: "Robert C. Martin" },
    { id: 3, title: "Design Patterns", author: "Gang of Four" },
  ];
  res.json(books);
});

app.listen(PORT, () => {
  console.log(`Books API running on http://localhost:${PORT}`);
});

And here is the complete package.json file.

{
  "name": "books-api",
  "version": "1.0.0",
  "description": "A simple books API for learning Docker",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.1.0"
  }
}

Before you run the server again, what do you expect to see when you visit /api/books in your browser?

  • Start the server again by running:
node server.js
  • Open your browser and navigate to http://localhost:3000/api/books.

You should see a JSON array of three books in your browser. The titles include The Pragmatic Programmer, Clean Code, and Design Patterns.

Not seeing the books data?

  • Confirm the URL in your browser is exactly http://localhost:3000/api/books with no trailing slash or extra characters.
  • Check that you saved server.js after adding the books route. Look for a dot on the file tab in VS Code, which means unsaved changes.
  • Make sure you stopped the server with Ctrl+C before restarting it. Running node server.js while the old server is still running causes a port conflict.

help me debug my Express API

  • Go back to your VS Code terminal and press Ctrl+C to stop the server.

Your API works locally. You built it, ran it, and confirmed it returns real data. Next up, you will package this API into a Docker container so it can run the same way on any machine.

Containerize the API with a Dockerfile

Your Express API is working locally. But it only runs because of your specific Node.js version and local setup.

Docker packages your app and its entire runtime into a single image that runs identically on any machine. In this step, you'll write a Dockerfile and run your API inside a container for the first time.

In this step, get ready to:
  • Create a Dockerfile that defines how to build your container image.
  • Build the image and run it as a container.
  • Discover a rebuild problem with the naive approach.
Create your first Dockerfile

A Dockerfile is a text file that tells Docker how to build an image. Each line is an instruction: which base system to start from, what files to copy in, what commands to run, and how to start your app.

  • In the Explorer sidebar in VS Code, hover over the books-api folder name and click the New File icon.
  • Type Dockerfile and press Enter. This file has no file extension.
  • Paste the following code into Dockerfile:
# Start from a lightweight Node.js image
FROM node:24-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy all project files into the container
COPY . .

# Install dependencies
RUN npm install

# Document the port the app listens on
EXPOSE 3000

# Start the app when the container runs
CMD ["node", "server.js"]

What does this Dockerfile do?

  • FROM node:24-alpine starts from a lightweight Linux image with Node.js pre-installed.
  • WORKDIR /app sets the working directory inside the container. All following commands run from here.
  • COPY . . copies everything from your project folder into the container's /app directory.
  • RUN npm install installs your dependencies inside the container.
  • EXPOSE 3000 documents that the app listens on port 3000.
  • CMD ["node", "server.js"] tells Docker how to start your app when the container runs.
  • Save Dockerfile.
  • Confirm the file exists by running:
ls

You should see Dockerfile listed alongside server.js, package.json, and node_modules.

Don't see the Dockerfile?

  • Make sure the file is named exactly Dockerfile with a capital D and no file extension. Not Dockerfile.txt or dockerfile.
  • Make sure you created the file directly inside the books-api folder, not inside a subfolder like node_modules.

Help me find my Dockerfile in VS Code.

✔️ Awesome, I've got everything!

Great. Make sure you've saved Dockerfile before moving on.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile. Compare it to yours line by line.

# Start from a lightweight Node.js image
FROM node:24-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy all project files into the container
COPY . .

# Install dependencies
RUN npm install

# Document the port the app listens on
EXPOSE 3000

# Start the app when the container runs
CMD ["node", "server.js"]
Build the image and run the container

Your Dockerfile is a recipe. Now you need to "cook" it into a Docker image, and then run that image as a container.

  • Build your Docker image by running this command:
docker build -t books-api .

What does this command do?

  • docker build reads your Dockerfile and creates an image from it.
  • -t books-api tags (names) the image so you can refer to it later.
  • The . at the end tells Docker to use the current folder as the build context.

You'll see Docker execute each instruction from your Dockerfile. It downloads the node:24-alpine base image, copies files, and runs npm install.

  • Check the size of your new image by running:
docker images books-api

You'll see a table with the image name, tag, and size. Make a mental note of the size. You'll compare it later.

Before you run the container, do you think the API will respond the same way it did when you ran it directly with Node.js?

  • Start a container from your image by running:
docker run -p 3000:3000 books-api

What does -p 3000:3000 do?

The -p flag maps a port on your machine to a port inside the container. 3000:3000 means "forward my machine's port 3000 to the container's port 3000."

Without this flag, the container's port would be completely isolated and unreachable from your browser.

You should see Books API running on http://localhost:3000 printed in the terminal, just like before.

You see the same JSON array of books. The difference is that this time, the app is running inside a Docker container, not directly on your machine.

Can't reach localhost:3000?

  • Make sure Docker Desktop is running. If the engine is stopped, you'll see an error mentioning the Docker daemon is not running.
  • Check that you included the -p 3000:3000 flag. Without it, the container's port is not forwarded to your machine.
  • If you see an error about the address already being in use, make sure you stopped the previous Node.js server with Ctrl+C in Step 2. Port 3000 can only be used by one process at a time.

Help me debug why my Docker container isn't responding on localhost:3000.

Watch the rebuild problem in action

Your containerized API works. Now let's see what happens when you make a small code change and need to rebuild the image.

  • Stop the running container by pressing Ctrl+C in the terminal.
  • In server.js, find this line:
{ id: 2, title: "Clean Code", author: "Robert C. Martin" },
  • Change "Clean Code" to "Clean Architecture" so the line looks like this:
{ id: 2, title: "Clean Architecture", author: "Robert C. Martin" },
  • Save server.js.

Before you rebuild, take a guess. You only changed one line of JavaScript. Do you think Docker will reinstall all the npm dependencies, or skip that step?

  • Rebuild the image by running:
docker build -t books-api .

Watch the output carefully. Docker reinstalls every npm dependency from scratch, even though you only changed one line of source code.

Why does Docker reinstall everything?

Docker images are built in layers. Each instruction in the Dockerfile creates a layer. When Docker detects that a layer has changed, it invalidates that layer AND every layer after it.

Because COPY . . comes before RUN npm install, any file change (even a one-character edit in server.js) forces Docker to re-run the entire install. This is slow and wasteful.

  • Change the book title back to "Clean Code" in server.js and save the file.

You've just hit the most common Dockerfile problem: slow rebuilds caused by the wrong instruction order. In the next step, you'll fix this with layer caching, add a .dockerignore file, and lock down security by running as a non-root user.

Fix the Dockerfile with Best Practices

Your API runs inside a Docker container. But the Dockerfile you wrote has three problems hiding in plain sight.

Every code change triggers a full dependency reinstall. Unnecessary files like node_modules and .git get copied into the image. And the app runs as the root user, which is a security risk. In this step, you'll fix all three.

In this step, get ready to:
  • Create a .dockerignore file to keep unnecessary files out of the build.
  • Rewrite the Dockerfile with layer caching and a non-root user.
  • Verify that rebuilds skip the dependency install and the container runs securely.
Create a .dockerignore file

When you run docker build, Docker sends every file in your project folder to the build engine. This is called the build context. Without a filter, it includes things like your local node_modules folder and .git history.

A .dockerignore file works like .gitignore. It tells Docker which files to leave out of the build context entirely.

  • Create a new file called .dockerignore in the root of your books-api project folder (next to your Dockerfile) and paste the following content:
# Dependencies installed inside the container
node_modules
# Version control history
.git
# Debug logs
npm-debug.log
# Docker config files (not needed in the image)
Dockerfile
.dockerignore
# CI/CD workflows
.github

Why exclude these files?

  • node_modules is the most important. Without this exclusion, your local dependencies get copied into the image and overwrite the ones the container installs. This can cause crashes if your machine and the container use different operating systems.
  • .git can be huge and is never needed inside a running container.
  • The Dockerfile and .dockerignore themselves are build instructions, not application code.
  • Save .dockerignore.
  • Confirm the file exists by running this in your terminal:
ls -a

You should see .dockerignore listed alongside your Dockerfile, server.js, and package.json.

Don't see .dockerignore in the list?

  • Files starting with a dot are hidden by default. Make sure you used ls -a (with the -a flag) to show hidden files.
  • Check that the file is in the same folder as your Dockerfile. If it ended up inside a subfolder, move it to the project root.
  • Still stuck? Help me figure out why my .dockerignore isn't showing up.

✔️ Awesome, I've got everything!

Great. Make sure you've saved .dockerignore before moving on.

ⓧ I'd like to double check the full code

Here's the complete .dockerignore file. Compare it with yours line by line.

node_modules
.git
npm-debug.log
Dockerfile
.dockerignore
.github
Rewrite the Dockerfile with layer caching

Docker builds images in layers. Each instruction in a Dockerfile creates one layer. When you rebuild, Docker checks whether anything changed for each layer. If nothing changed, it reuses the cached version and skips that step entirely.

Your current Dockerfile copies ALL files before running npm install. That means any code change invalidates the cache for the copy layer, which forces Docker to reinstall every dependency. The fix is to copy the dependency files first, install them, and then copy your source code.

  • In VS Code, open your Dockerfile and replace its entire contents with the following code:
# Use lightweight Alpine-based Node.js image
FROM node:24-alpine

# Enable Express production optimizations
ENV NODE_ENV=production

# Set the working directory inside the container
WORKDIR /app

# Copy dependency files first (layer caching)
COPY package.json package-lock.json ./

# Install dependencies from the lock file
RUN npm ci

# Copy the rest of the source code
COPY . .

# Run as a non-root user for security
USER node

# Document the port the app listens on
EXPOSE 3000

# Start the application
CMD ["node", "server.js"]

What changed and why?

  • COPY package.json package-lock.json ./ copies only the dependency files first. This creates a layer that only changes when your dependencies change.
  • RUN npm ci installs dependencies from the lock file. Unlike npm install, npm ci guarantees reproducible builds by always using the exact versions in your lock file.
  • COPY . . copies source code AFTER dependencies are installed. Now a code change only invalidates this layer and everything below it. The npm ci layer stays cached.
  • USER node switches the running user from root to the pre-created node user that ships with official Node.js Docker images. Running as root inside a container is a security risk because a compromised process would have full system access.
  • ENV NODE_ENV=production tells Express to enable its production optimizations, like caching compiled templates.
  • Save Dockerfile.

Seeing a "package-lock.json not found" error later?

  • The COPY package.json package-lock.json ./ line requires both files to exist. If you deleted your package-lock.json, run npm install in your project folder to regenerate it.
  • Need more help? Help me fix my Dockerfile build error.

✔️ Awesome, I've got everything!

Great. Make sure you've saved your Dockerfile before moving on to rebuild.

ⓧ I'd like to double check the full code

Here's the complete Dockerfile. Compare it with yours line by line.

FROM node:24-alpine

ENV NODE_ENV=production

WORKDIR /app

COPY package.json package-lock.json ./

RUN npm ci

COPY . .

USER node

EXPOSE 3000

CMD ["node", "server.js"]
Rebuild and verify the improvements

Your Dockerfile and .dockerignore are ready. Time to rebuild the image and prove that all three fixes work.

  • Rebuild your Docker image to establish the new cached layers by running:
docker build -t books-api .

This first build installs all dependencies fresh because the Dockerfile structure changed. Watch the output and note each numbered step completing.

  • Now make a small change in server.js to simulate a code update. For example, find this line:
{ id: 3, title: "Design Patterns", author: "Gang of Four" },
  • Change the title to anything you like, for example "Head First Design Patterns".
  • Save server.js.

Before you rebuild, think about this: you only changed source code, not package.json. What do you think will happen to the npm ci step this time?

  • Rebuild the image again by running:
docker build -t books-api .

Look at the build output. The npm ci step now shows CACHED. Docker skipped the entire dependency install because package.json and package-lock.json did not change. The rebuild is now much faster.

Why is this faster?

In your old Dockerfile, COPY . . came before npm install. Any file change invalidated the copy layer and forced a full reinstall. Now, dependency files are copied and installed in their own layers. Source code changes only invalidate the second COPY . . layer, leaving the expensive install step cached.

  • Run the container in detached mode (in the background) by running:
docker run -p 3000:3000 -d --name my-api books-api

What do these flags do?

  • -d runs the container in the background so you get your terminal back.
  • --name my-api gives the container a friendly name instead of a random string. You'll use this name to run commands inside the container.
  • Open http://localhost:3000/api/books in your browser to confirm the API is working.

You should see your JSON array of books (with the title you changed). The API is running inside the container, just like before.

Now for the security check. What user do you think is running the app inside the container?

  • Check which user the container is running as by running:
docker exec my-api whoami

The output prints node instead of root. Your container is now running as a non-root user. If the process is ever compromised, the attacker has limited permissions instead of full system access.

Seeing "no such container" or the API isn't responding?

  • If you see a "name already in use" error when running docker run, a container named my-api already exists from a previous attempt. Remove it first with docker rm -f my-api and try again.
  • If port 3000 is already in use, make sure you stopped any previous containers or local Node.js processes with Ctrl+C.
  • Still stuck? Help me troubleshoot my Docker container.
  • Clean up the running container by running:
docker stop my-api && docker rm my-api

This stops the container and removes it so the name my-api is free for next time.

Your Dockerfile is now production-ready. It installs dependencies efficiently, excludes junk files, and runs securely as a non-root user. Next up, you'll automate the whole build process so every push to GitHub produces a fresh Docker image automatically.

Add CI/CD with GitHub Actions

Your Docker image builds cleanly on your laptop. But building images manually does not scale. Every time you change your code, you would have to remember to rebuild and redistribute the image yourself.

CI/CD (Continuous Integration / Continuous Delivery) automates this entire process. In this step, you will push your project to GitHub and set up a GitHub Actions workflow that automatically builds and publishes your Docker image on every push.

In this step, get ready to:
  • Initialize a Git repository and push your code to GitHub.
  • Write a GitHub Actions workflow that builds and pushes your Docker image.
  • Verify the pipeline runs and publishes your image to GitHub Container Registry.
Initialize a Git repository and push to GitHub

To push your project to GitHub, you need Git installed on your machine. Let's check if you already have it.

  • Check if Git is installed by running this command in your terminal:
git --version

✔️ I see a version number

Git is installed and ready to go.

ⓧ Command not found

Git is not installed yet. Follow these steps to install it.

  • Go to the official Git download page for Windows. The download starts automatically.
  • Run the installer and accept the default options on each screen.
  • Close and reopen your terminal, then run git --version again to confirm the installation.

Before you can track your project with Git, you need a .gitignore file. This tells Git which files to skip so large folders like node_modules never end up in your repository.

  • In VS Code, create a new file called .gitignore in your project's root folder (books-api).
  • Add this single line to the file:
node_modules/
  • Save .gitignore.
  • Initialize a new Git repository by running this command:
git init

You should see a message that says Initialized empty Git repository followed by a path.

  • Stage all your project files and create your first commit by running these commands:
git add .
git commit -m "Initial commit"

You should see output listing the files that were committed, including server.js, Dockerfile, package.json, and .dockerignore. Notice that node_modules is not in the list because your .gitignore excluded it.

Seeing a warning about branch name?

Git may warn you about the default branch name. This is safe to ignore. You will set the branch to main in the next command.

Help me fix a Git branch name warning.

Your code is committed locally. Now you need a remote repository on GitHub to push it to.

  • Go to github.com in your browser and sign in to your account.
  • Click the + button in the top-right corner, then select New repository.
  • Enter books-api as the Repository name.
  • Select Public so GitHub Actions can run for free.
  • Do NOT check any of the initialization options (README, .gitignore, or license). You already have local files to push.
  • Click Create repository.

GitHub shows you a page with setup instructions. You will use the commands for pushing an existing repository.

  • Back in your VS Code terminal, connect your local repo to GitHub and push your code by running these commands:
git remote add origin [[GITHUB_REPO_URL="https://github.com/your-username/books-api.git"]]
git branch -M main
git push -u origin main

What do these commands do?

  • git remote add origin links your local repository to the one you just created on GitHub.
  • git branch -M main renames your current branch to main, which is the default branch name GitHub expects.
  • git push -u origin main uploads your code to GitHub and sets main as the default push target.

First time pushing to GitHub?

Git Credential Manager (bundled with Git for Windows) will open a browser window asking you to sign in to GitHub. Select the Web browser option if prompted, complete the sign-in, and your push will continue automatically.

  • Refresh your GitHub repository page in the browser. You should see your project files (server.js, Dockerfile, package.json) listed in the repository.

Push failed or nothing showing up?

  • Make sure the repository URL matches the one GitHub gave you. Copy it directly from the GitHub page.
  • If you see an authentication error, make sure you completed the browser sign-in when Git Credential Manager prompted you.
  • If you checked any initialization options (README or license) when creating the repo, delete the repo on GitHub, create a new one without those options, and push again.

Help me fix a failed git push to GitHub.

Create the GitHub Actions workflow

GitHub Actions workflows live in a special directory inside your project. GitHub automatically detects any workflow files in this directory and runs them based on their triggers.

  • Create the workflow directory by running this command:
mkdir -p .github/workflows
  • In VS Code, create a new file at .github/workflows/docker-build.yml.
  • Add the workflow trigger and environment variables by pasting this code:
# Name shown in the GitHub Actions UI
name: Build and Push Docker Image

# Run this workflow on every push to the main branch
on:
  push:
    branches: [main]

# Set shared variables for the registry URL and image name
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

What does this code do?

  • on: push: branches: [main] tells GitHub to run this workflow every time you push code to the main branch.
  • REGISTRY: ghcr.io points to GitHub Container Registry, where your Docker image will be published.
  • IMAGE_NAME: ${{ github.repository }} automatically uses your GitHub username and repo name (e.g. your-username/books-api) as the image name.
  • Below the env block, add the jobs section that defines the build steps:
# Define the build job
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      # Pull down the repository code
      - name: Checkout repository
        uses: actions/checkout@v6

      # Authenticate with GitHub Container Registry
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Generate tags and labels for the image
      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      # Build the image from your Dockerfile and push it
      - name: Build and push Docker image
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

What does this code do?

This job runs on a fresh Ubuntu machine in GitHub's cloud. Each step uses a pre-built action from the GitHub marketplace.

  • actions/checkout@v6 pulls your repository's code onto the runner so Docker can access your Dockerfile.
  • docker/login-action@v4 authenticates with ghcr.io using the built-in secrets.GITHUB_TOKEN. No manual secrets setup is needed.
  • docker/metadata-action@v5 generates image tags and labels based on your Git branch and commit.
  • docker/build-push-action@v7 runs docker build and pushes the resulting image to the registry.

Why no manual secrets setup?

GitHub automatically provides a GITHUB_TOKEN secret to every workflow run. The permissions block scopes this token to only what the job needs: reading your code (contents: read) and publishing packages (packages: write).

  • Save docker-build.yml.
  • Confirm the file exists by running:
ls .github/workflows

You should see docker-build.yml in the output.

✔️ Awesome, I've got everything!

Great. Double check you have saved docker-build.yml.

ⓧ I'd like to double check the full code

Your docker-build.yml is built from two chunks. Scroll up and confirm both are present in order:

  • Chunk 1: The name, on trigger, and env block at the top of the file.
  • Chunk 2: The jobs block with the build-and-push job containing four steps (checkout, login, metadata, build and push).

Make sure the YAML indentation is correct. Each level should be indented by exactly 2 spaces.

VS Code showing YAML errors?

  • YAML is indentation-sensitive. Use spaces, not tabs. Each nesting level uses 2 spaces.
  • Make sure the jobs: key starts at the same level as name: and on: (no indentation).

Help me fix YAML indentation errors in my workflow file.

Push the workflow and verify the pipeline

Your workflow file is ready, but GitHub does not know about it yet. You need to commit and push it so GitHub Actions can detect and run it.

  • Commit and push the workflow file by running these commands:
git add .
git commit -m "Add CI/CD workflow"
git push

Before you check GitHub, what do you think will happen? The workflow triggers on every push to main. You just pushed to main. So the workflow should already be running.

  • Go to your GitHub repository in the browser.
  • Click the Actions tab at the top of the repository.

You should see a workflow run in progress (orange dot) or already completed (green checkmark). Click on the run to watch each step execute in real time.

What should I see?

The workflow run page shows each step: checkout, login, metadata extraction, and build/push. A green checkmark next to each step means it completed successfully. The entire run takes about 1-2 minutes.

  • Wait for the workflow to finish with a green checkmark.
  • Go back to your repository's main page.
  • Look in the right sidebar for the Packages section. Click the package name to see your published Docker image on GitHub Container Registry.

Your Docker image is now published. Every future push to main will automatically build and publish a fresh image without you lifting a finger.

Workflow failed with a red X?

  • Click the failed run, then click the failed step to see the error log. The most common cause is a YAML indentation error in docker-build.yml.
  • Make sure your repository is Public. Private repositories have limited free GitHub Actions minutes.
  • Check that the permissions block in your workflow includes packages: write. Without it, the push to the registry will fail.

Help me debug a failed GitHub Actions workflow.

Your API is containerized, your Dockerfile follows best practices, and your CI/CD pipeline ships a fresh Docker image on every push. Next up, take on the Secret Mission to shrink your image even further with a multi-stage build.

Secret mission

Shrink Your Image with a Multi-Stage Build

Your Docker image works, but it is carrying extra baggage. The npm binary, package manager tools, and the full Alpine toolchain are all baked into the final image, even though none of them are needed at runtime. Can you restructure the Dockerfile into a multi-stage build that produces a leaner, production-ready image?

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 (books-api on your Desktop).
  • Docker images built locally (books-api and any stopped containers).
  • GitHub repository with your project code.
  • Docker image published to GitHub Container Registry (ghcr.io).
  • Docker Desktop installed on your machine.

Keep everything running

No action needed. Choose this if you are still actively building on this project or plan to extend it soon.

  • Your local project files, Docker images, GitHub repository, and GHCR images all stay as they are.
  • Docker Desktop will continue running in the background. You can quit it from the system tray when you are not using it to free up memory.
  • Git remains installed on your machine for future projects.

Pause - I'll come back to this later

Shut down running processes to free up memory while keeping your files and remote resources intact.

  • Stop any running Docker containers by running:
  • Quit Docker Desktop by right-clicking the Docker icon in your system tray and selecting Quit Docker Desktop.
  • Your local project files, GitHub repository, and GHCR images all stay intact for when you return.
docker stop $(docker ps -q)

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

Remove local Docker resources
  • Stop and remove all containers, then remove the books-api image by running these commands:
docker stop $(docker ps -q)
docker rm $(docker ps -aq)
docker rmi books-api
Delete the local project folder
  • Navigate to your Desktop and remove the project folder by running:
cd ~/Desktop
rmdir /s /q books-api

On macOS or Linux

Use rm -rf books-api instead of the rmdir command.

Delete the GitHub Container Registry image
  • Go to your GitHub profile page.
  • Click the Packages tab in the header.
  • Click the name of your container package.
  • Click View and manage all versions.
  • To the right of the version you want to delete, click the kebab menu and select Delete version.
  • Type the package name to confirm, then click I understand the consequences, delete this version.
Delete the GitHub repository
  • Open your repository on GitHub.
  • Click Settings in the repository menu.
  • 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.
Uninstall Docker Desktop
  • Open the Windows Start menu and go to Settings.
  • Select Apps, then Apps & features.
  • Find Docker Desktop in the list and click Uninstall.
  • Click Uninstall again to confirm.

Nice Work!

Nice Work!

Nice work! You just containerized an Express.js API with Docker and built a CI/CD pipeline that automatically ships it on every push.

You've learned how to:

  • Containerized an Express.js API with a Dockerfile and ran it inside a Docker container accessible at localhost:3000.
  • Fixed a naive Dockerfile with production best practices: layer caching to skip redundant dependency installs, .dockerignore to exclude unnecessary files, and a non-root user for security.
  • Set up a GitHub Actions CI/CD pipeline that automatically builds and pushes your Docker image to GitHub Container Registry on every push to main.
  • Secret Mission: Restructured the Dockerfile into a multi-stage build to dramatically shrink the production image size.

Ready to quiz yourself?