Docker Tutorial: Containerize a Node.js and Express REST API

Docker Tutorial: Containerize a Node.js and Express REST API

Introduction

If you've ever heard the phrase "it works on my machine" right before a deployment fell apart, you already understand the problem Docker solves. Docker packages your application together with everything it needs to run — the runtime, libraries, and system tools — into a single, portable unit called a container. That container behaves the same way on your laptop, your teammate's laptop, and a production server in the cloud.

In this tutorial, you'll learn Docker by doing something practical: building and containerizing a small REST API using Node.js and Express. By the end, you won't just know Docker theory — you'll have a working API that returns JSON data, running inside a container you built yourself, with data persisted through Docker volumes and a full development workflow you can reuse on future projects.

This matters because containerization is now the default way software gets shipped. Whether you're deploying to AWS, Google Cloud, Kubernetes, or a simple VPS, the deployment artifact is almost always a container image. Learning Docker here means you're learning a skill that transfers directly to real jobs and real production systems.

By the end of this tutorial, you will have built a working, containerized REST API, understood the core Docker concepts (images, containers, Dockerfiles, volumes, and networks), and practiced the everyday commands you'll use in any Docker-based project.

Prerequisites

  • Basic JavaScript knowledge — you should be comfortable reading functions, objects, and require/import statements. No prior backend or Node.js experience is assumed.
  • No Docker knowledge required — this tutorial explains every concept from zero.
  • A code editor, such as VS Code (free).
  • Docker Desktop installed — download it from docker.com/products/docker-desktop. Installation includes the Docker Engine, CLI, and Docker Compose.
  • Node.js installed locally (optional but helpful for testing) — version 18 or later from nodejs.org.
  • A terminal — Terminal (macOS/Linux) or PowerShell/WSL2 (Windows).
  • ~500MB of free disk space for Docker images.

To confirm Docker is installed correctly, run:

bash

docker --version
docker compose version

You should see version numbers printed for both. If you get a "command not found" error, revisit the Docker Desktop installation step before continuing.

What You'll Build

You'll build a small Task API — a REST API with endpoints to create, read, update, and delete "tasks" (like a minimal to-do list backend). Specifically, you'll:

  • Write a Node.js/Express server with four REST endpoints (GET, POST, PUT, DELETE).
  • Create a Dockerfile that packages the app into a portable image.
  • Run the app inside a container, mapped to a port on your machine.
  • Use a Docker volume so your task data survives container restarts.
  • Use Docker Compose to manage the whole setup with one command.
  • Optimize your image with a multi-stage build and a .dockerignore file.
ConceptWhat it doesWhere you'll use it
ImageA read-only template with your app + dependenciesBuilt once via docker build
ContainerA running instance of an imageStarted via docker run or docker compose up
DockerfileInstructions for building an imageDockerfile in your project root
VolumePersistent storage outside the container's lifecycleTask data storage
Docker ComposeTool to define and run multi-container setups from one YAML filedocker-compose.yml

Understanding Docker's Core Concepts

Before writing any code, it helps to understand the mental model Docker is built on, since the steps ahead will make far more sense once these pieces click.

A container is a running process that's isolated from the rest of your machine — it has its own filesystem, its own view of running processes, and its own network interface, even though it shares the same underlying operating system kernel as your host machine. This is different from a virtual machine, which emulates an entire separate operating system, including its own kernel. Because containers skip that emulation layer, they start in milliseconds rather than minutes and use a fraction of the memory a VM would.

An image is the blueprint a container is built from — a read-only snapshot containing your application code, its dependencies, and the instructions for how to run it. You can start any number of containers from the same image, and each one runs independently. Think of the image as a class in object-oriented programming, and the container as an instance of that class.

Images are built in layers. Each instruction in a Dockerfile (like COPY or RUN) creates a new layer stacked on top of the previous one. Docker caches these layers, so if you rebuild an image and only the last few instructions changed, Docker reuses the cached layers for everything earlier — this is why the order of instructions in a Dockerfile has a real, measurable impact on build speed.

Finally, a registry (like Docker Hub) is where built images are stored and shared, similar to how GitHub stores code repositories. When you write FROM node:20-alpine in a Dockerfile, Docker pulls that base image from a registry before layering your own instructions on top of it.

With that vocabulary in place, you're ready to build something real.

Step 1: Set Up the Project Folder

Every containerized app starts as a normal app. Before Docker enters the picture, you need working code to containerize.

Create a new folder and initialize a Node.js project:

bash

mkdir docker-task-api
cd docker-task-api
npm init -y
npm install express

This creates a package.json file (which tracks your project's dependencies) and installs Express, a minimal web framework for Node.js that handles HTTP routing.

Why this matters: Docker doesn't replace your normal development tools — it wraps around them. You still write and test code the way you always have; Docker's job starts once your code is ready to be packaged.

Common mistake: Forgetting to run npm init -y before installing packages. Without a package.json, npm install still works, but you won't have a manifest listing your dependencies — which the Docker build process needs later to install the same packages inside the container.

Step 2: Write the Express API

Create a file named server.js in your project root:

javascript

const express = require('express');
const fs = require('fs');
const path = require('path');

const app = express();
const PORT = process.env.PORT || 3000;
const DATA_FILE = path.join(__dirname, 'data', 'tasks.json');

app.use(express.json());

// Ensure the data directory and file exist
function ensureDataFile() {
  const dir = path.dirname(DATA_FILE);
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  if (!fs.existsSync(DATA_FILE)) fs.writeFileSync(DATA_FILE, '[]');
}

function readTasks() {
  ensureDataFile();
  return JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'));
}

function writeTasks(tasks) {
  fs.writeFileSync(DATA_FILE, JSON.stringify(tasks, null, 2));
}

app.get('/health', (req, res) => res.json({ status: 'ok' }));

app.get('/tasks', (req, res) => {
  res.json(readTasks());
});

app.post('/tasks', (req, res) => {
  const tasks = readTasks();
  const newTask = { id: Date.now(), title: req.body.title, done: false };
  tasks.push(newTask);
  writeTasks(tasks);
  res.status(201).json(newTask);
});

app.put('/tasks/:id', (req, res) => {
  const tasks = readTasks();
  const task = tasks.find(t => t.id === Number(req.params.id));
  if (!task) return res.status(404).json({ error: 'Task not found' });
  task.done = req.body.done ?? task.done;
  task.title = req.body.title ?? task.title;
  writeTasks(tasks);
  res.json(task);
});

app.delete('/tasks/:id', (req, res) => {
  const tasks = readTasks();
  const filtered = tasks.filter(t => t.id !== Number(req.params.id));
  writeTasks(filtered);
  res.status(204).send();
});

app.listen(PORT, '0.0.0.0', () => {
  console.log(`Task API listening on port ${PORT}`);
});

Why 0.0.0.0 and not localhost: Inside a container, binding to localhost only accepts connections from within the container itself. Binding to 0.0.0.0 tells the server to accept connections from any network interface — which is required for Docker's port mapping to reach it from your host machine.

Before moving to Docker, it's worth understanding what each endpoint does, since you'll be testing all of them shortly:

MethodRoutePurpose
GET/healthSimple check that the server is alive — commonly used by monitoring tools
GET/tasksReturns the full list of tasks as JSON
POST/tasksCreates a new task from the request body
PUT/tasks/:idUpdates a task's title or completion status
DELETE/tasks/:idRemoves a task by its ID

Test it locally first (before Docker) to confirm the logic works:

bash

node server.js

In another terminal, walk through the full lifecycle of a task. First, create one:

bash

curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":"Learn Docker"}'

You should get back a JSON object with an id, title, and done: false. Copy that id value, then mark the task as done:

bash

curl -X PUT http://localhost:3000/tasks/<id> -H "Content-Type: application/json" -d '{"done":true}'

Confirm the update by listing all tasks:

bash

curl http://localhost:3000/tasks

Finally, delete it:

bash

curl -X DELETE http://localhost:3000/tasks/<id>

A 204 No Content response with no body confirms the deletion worked. Stop the server with Ctrl+C once you've confirmed all four operations behave as expected.

Why test locally before containerizing: Docker isolates your app, which makes debugging application-level bugs slightly more awkward (extra layer of docker logs and rebuilds between fixes). Confirming your logic works in a plain Node.js process first means that if something breaks once you containerize it, you'll know the bug is related to Docker configuration — not your JavaScript.

Step 3: Write the Dockerfile

A Dockerfile is a text file containing step-by-step instructions Docker follows to build an image. Create a file named Dockerfile (no extension) in your project root:

dockerfile

# Use an official, lightweight Node.js runtime as the base image
FROM node:20-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy only dependency manifests first (for caching benefits)
COPY package*.json ./

# Install dependencies inside the container
RUN npm install --omit=dev

# Copy the rest of the application code
COPY . .

# Document which port the app listens on
EXPOSE 3000

# Command that runs when the container starts
CMD ["node", "server.js"]

Why the order matters: Docker builds images in layers, and it caches each layer. By copying package.json and running npm install before copying the rest of your code, Docker can reuse the cached dependency layer whenever you change your application code but not your dependencies — making rebuilds dramatically faster.

Why alpine: The node:20-alpine image is based on Alpine Linux, a minimal distribution that keeps the final image small (roughly 40–50MB vs. several hundred MB for the default image). Smaller images pull, push, and start faster.

Troubleshooting: If npm install fails inside the container with permission errors, avoid running as root in production images by adding a non-root user — but for this tutorial's learning purposes, the default root user is fine to keep things simple.

Step 4: Add a .dockerignore File

Just like .gitignore tells Git which files to skip, .dockerignore tells Docker which files not to copy into the image. Create .dockerignore:

node_modules
npm-debug.log
.git
.gitignore
.env
data/

Why this matters: Without this file, Docker would copy your local node_modules folder into the image, overwriting the clean dependencies you just installed with RUN npm install. Local node_modules might contain OS-specific binaries (built for macOS or Windows) that break when run inside the container's Linux environment. Excluding it also keeps your build context smaller, which speeds up every build.

Step 5: Build and Run the Image

Now build the image from your Dockerfile:

bash

docker build -t task-api:1.0 .

Breaking this command down: -t task-api:1.0 tags (names) your image task-api with version 1.0, and the trailing . tells Docker to use the current directory as the build context — the set of files Docker is allowed to read and copy while building. This is also why the .dockerignore file from Step 4 matters: everything in the build context gets sent to the Docker build process, even files you never COPY, so excluding unnecessary files keeps builds fast.

As the build runs, watch the terminal output. You'll see each instruction from your Dockerfile execute as a separate step, something like:

[1/5] FROM node:20-alpine
[2/5] WORKDIR /app
[3/5] COPY package*.json ./
[4/5] RUN npm install --omit=dev
[5/5] COPY . .

If you run docker build a second time without changing package.json, you'll notice steps 1 through 4 complete almost instantly with a CACHED label next to them. That's the layer caching from the previous section in action — Docker recognized those instructions and their inputs hadn't changed, so it reused the results instead of redoing the work.

Once the build finishes, confirm the image exists:

bash

docker images

Now run a container from that image:

bash

docker run -d -p 3000:3000 --name task-api-container task-api:1.0

Here's what each flag does:

  • -d runs the container in detached mode (in the background).
  • -p 3000:3000 maps port 3000 on your machine to port 3000 inside the container (host:container).
  • --name task-api-container gives the running container a memorable name instead of a random one.

Verify it's running:

bash

docker ps

Then test it exactly as before:

bash

curl http://localhost:3000/health

You should see {"status":"ok"} — except now it's coming from inside a container, not your local Node.js process.

Common mistake: Forgetting -p entirely. Without a port mapping, the container runs fine internally, but nothing on your host machine can reach it — a very common source of "my container works but I can't connect" confusion.

Step 6: Persist Data with a Volume

Right now, task data lives inside the container's filesystem. If you remove the container, your tasks disappear. A volume is Docker's mechanism for storing data outside a container's lifecycle, so it survives restarts and removals.

Stop and remove the current container:

bash

docker stop task-api-container
docker rm task-api-container

Create a named volume and re-run the container, mounting the volume to the folder where your app stores data:

bash

docker volume create task-data
docker run -d -p 3000:3000 --name task-api-container -v task-data:/app/data task-api:1.0

The -v task-data:/app/data flag mounts the task-data volume to /app/data inside the container — the exact path your server.js writes tasks.json to.

Test persistence: create a task, then remove and recreate the container using the same commands above. The task you created will still be there, because the data lived in the volume, not the container.

Why this matters: Containers are meant to be disposable — you should be able to destroy and recreate one at any time (for updates, scaling, or crashes) without losing data. Volumes are what make that possible.

It's worth distinguishing volumes from a related concept you may encounter: bind mounts. A volume (what you just used) is storage that Docker manages internally, stored in a location Docker controls on your host machine — you refer to it by name (task-data) rather than a filesystem path. A bind mount, by contrast, links a specific folder on your host machine directly into the container, using a real path like -v $(pwd)/data:/app/data. Bind mounts are useful during development because they let you edit files on your host and see changes reflected instantly inside the container. Volumes are generally preferred for production data because Docker manages their lifecycle, backups, and permissions more predictably.

You can inspect any volume's details, including where Docker actually stores its data on disk, with:

bash

docker volume inspect task-data

And you can list every volume on your system with docker volume ls — useful for spotting orphaned volumes left behind by removed containers, which is a common source of "my disk is full and I don't know why" surprises.

Step 7: Simplify with Docker Compose

Typing long docker run commands with multiple flags gets tedious and error-prone. Docker Compose lets you define your entire setup — image, ports, volumes — in one YAML file, then start everything with a single command.

Create docker-compose.yml in your project root:

yaml

version: '3.8'
services:
  api:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - task-data:/app/data
    environment:
      - PORT=3000
    restart: unless-stopped

volumes:
  task-data:

Stop and remove any running container from Step 6 first:

bash

docker stop task-api-container
docker rm task-api-container

Then start everything with Compose:

bash

docker compose up -d --build

This single command builds the image (if needed), creates the volume, starts the container, and maps the port — replacing everything you did manually in Steps 5 and 6.

To stop everything:

bash

docker compose down

Why this matters: In real projects, you'll often run multiple containers together — an API, a database, a cache — and Compose is how you coordinate them as one unit instead of juggling separate docker run commands for each.

Putting It All Together

Here's your complete project structure and files, so you can verify your setup matches:

docker-task-api/
├── Dockerfile
├── .dockerignore
├── docker-compose.yml
├── package.json
├── server.js
└── data/            (created automatically, holds tasks.json)

Full end-to-end workflow, from a clean clone of this project to a running API:

bash

# 1. Install dependencies (only needed if testing locally without Docker)
npm install

# 2. Build and start the containerized API with Compose
docker compose up -d --build

# 3. Confirm it's running
docker ps

# 4. Create a task
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Ship the Docker tutorial"}'

# 5. List all tasks
curl http://localhost:3000/tasks

# 6. Stop everything when done
docker compose down

If every command above runs without errors and curl http://localhost:3000/tasks returns your created task as JSON, your setup is complete and correct.

Troubleshooting / FAQ

"Bind for 0.0.0.0:3000 failed: port is already allocated." Another process (possibly a previous container) is already using port 3000. Run docker ps to find it, then docker stop <container-name>, or change the host-side port mapping to something like -p 3001:3000.

My code changes aren't showing up in the container. Docker images are snapshots — editing server.js locally doesn't change a container built from an older image. Rebuild with docker build -t task-api:1.0 . or docker compose up -d --build after any code change. For active development, consider mounting your source code as a volume so changes reflect immediately without rebuilding.

"Cannot connect to the Docker daemon." Docker Desktop isn't running. Open the Docker Desktop application and wait for it to fully start (the whale icon in your system tray/menu bar should stop animating) before retrying.

My container exits immediately after starting. Check the logs with docker logs task-api-container. This usually means the Node process crashed — often due to a syntax error, a missing dependency, or the app trying to bind to a port that's already taken inside the container.

How do I see what's happening inside a running container? Use docker exec -it task-api-container sh to open an interactive shell inside the running container, letting you inspect files or run commands directly, as if you'd SSH'd into it.

Do I need to rebuild the image every time I change a line of code? With the setup in this tutorial, yes — each code change requires docker compose up -d --build to bake the new code into the image. For active development, many teams instead bind-mount their source folder into the container (see Step 6's note on bind mounts) combined with a tool like nodemon, so the running container picks up file changes immediately without a rebuild. This tutorial uses the rebuild approach because it mirrors how images are actually built for deployment.

My image is much larger than I expected — how do I shrink it? Check what's taking up space with docker image inspect task-api:1.0 or a tool like dive. Common culprits include forgetting a .dockerignore entry, installing dev dependencies you don't need at runtime (make sure you're using npm install --omit=dev, as this tutorial does), or using a full node base image instead of the alpine variant. For compiled languages or apps with a separate build step, a multi-stage build (mentioned in Next Steps) is the standard way to strip build tools out of the final image entirely.

Next Steps

  1. Add a real database. Extend docker-compose.yml with a second service (e.g., PostgreSQL or MongoDB) and connect your API to it — this is the natural next step toward a production-realistic multi-container setup.
  2. Learn multi-stage builds. For larger apps with build steps (like TypeScript compilation), multi-stage Dockerfiles let you keep your final image lean by discarding build-only dependencies.
  3. Push your image to a registry. Try docker push to Docker Hub or GitHub Container Registry so your image can be pulled and run on any server.
  4. Explore orchestration. Once you're comfortable with Compose, look into Kubernetes or Docker Swarm for running containers at scale across multiple machines.

Summary

You've gone from zero Docker knowledge to a fully containerized Node.js and Express REST API. Along the way, you learned the core building blocks: images as reusable templates, containers as running instances of those images, the Dockerfile as the recipe that builds an image, volumes for data that outlives a container's lifecycle, and Docker Compose for orchestrating it all with a single command. You also picked up practical habits — layer caching for faster builds, .dockerignore for clean images, and reading container logs to debug issues. These same patterns apply directly to any real-world containerized project you build next, regardless of language or framework.

Read more