Containerize a Node.js App with Docker
Build and orchestrate a containerized Node.js bookmark API with Docker.
Introduction
30 Second Summary
Every developer has heard "it works on my machine." The problem is that applications depend on specific runtimes, libraries, and configurations that differ between computers. Containers solve this by packaging everything an app needs into a single, portable unit.
In this project, you will containerize a Node.js bookmark API with Docker. You'll build a custom image, optimize it for fast rebuilds, and orchestrate multiple services with Docker Compose.
What You'll Build
A containerized bookmark API that saves and retrieves bookmarks, running as a multi-container application with Express and Redis connected through Docker Compose.
By the end of this project, you'll have:
- A Node.js Express API running inside a Docker container with port mapping and lifecycle management.
- An optimized Dockerfile that leverages layer caching so dependency installs are skipped on code-only changes.
- A Docker Compose configuration connecting your app to a Redis database, with named volumes for data persistence.
- Secret Mission: Add a health check endpoint and configure Docker Compose to use it for production-ready container orchestration.
Are there any prerequisites?
None. This project includes full setup instructions for Docker Desktop, VS Code, and Node.js. You do not need prior experience with any of these tools.
Before We Start
Before We Start...
Install Docker Desktop and Set Up Your Editor
Before you can containerize anything, you need the tools that build and run containers on your machine.
In this step, you'll install Docker Desktop (the application that runs containers locally), set up VS Code for editing Dockerfiles, and create the Node.js project you'll containerize in the next step.
In this step, get ready to:
- Install Docker Desktop and verify it runs in your terminal.
- Set up VS Code with the Docker extension for syntax highlighting.
- Create and initialize your bookmark-api project with Express.
Install Docker Desktop
Docker Desktop is the application that lets you build, run, and manage containers on your Mac.
- Download Docker Desktop for Mac from the official install page.
- Open the Docker.dmg file you downloaded.
- Drag the Docker icon into your Applications folder.
- Press Cmd+Space to open Spotlight search.
- Type Docker and press Enter to launch Docker Desktop.
- Accept the service agreement when prompted.
What is Docker Desktop?
Docker Desktop is a free application (for personal use and education) that provides the Docker engine, CLI tools, and a graphical interface for managing containers.
Once it's running, you can use docker commands in your terminal to build images and run containers.
Docker Desktop takes a moment to start up. Wait until you see the Docker whale icon in your menu bar at the top of your screen.
- Open Terminal by pressing Cmd+Space, typing Terminal, and pressing Enter.
- Verify Docker is installed by running this command:
docker --version
- Verify Docker Compose is available by running:
docker compose version
✔️ I see version numbers for both commands
Docker is installed and ready to go. You should see output like Docker version 28.x.x, build ... for the first command and a version number for the second.
ⓧ Command not found or Docker not running
If you see a "command not found" error or a message about the Docker daemon not running, try these fixes:
- Make sure Docker Desktop is fully started. Look for the whale icon in your menu bar.
- If Docker Desktop is open but commands still fail, close and reopen your Terminal window.
- If Docker Desktop won't open, confirm your Mac meets the system requirements (at least 4 GB RAM) and try restarting your Mac.
Set up VS Code with the Docker extension
VS Code gives you syntax highlighting and autocomplete for Dockerfiles, making it easier to spot mistakes before you build.
- If you don't have VS Code installed, download it from code.visualstudio.com and drag it into your Applications folder.
- Press Cmd+Space, type Visual Studio Code, and press Enter to open it.
- Click the Extensions icon in the left sidebar (it looks like four small squares).
- Type ms-azuretools.vscode-docker in the search bar.
- Click Install on the Docker extension by Microsoft.
What does this extension do?
The Docker extension adds syntax highlighting for Dockerfiles and compose.yaml files. It also adds a Docker icon to your sidebar where you can view running containers and images later in the project.
Can't find the extension?
Make sure you're searching for the full extension ID: ms-azuretools.vscode-docker. You can also open the Extensions search with Cmd+Shift+X.
Help me find and install the Docker VS Code extension.
Create your project directory and initialize the app
Now you'll create the Node.js project that you'll containerize in the next step. This project uses Express, a lightweight web framework for building APIs.
You need Node.js installed on your machine to run npm (the package manager that installs project dependencies).
- Check that Node.js is installed by running this in your Terminal:
node --version
✔️ I see a version number
Node.js is ready. Move on to creating your project folder below.
ⓧ Command not found
You need to install Node.js first.
- Download the installer from nodejs.org.
- Run the installer and accept the defaults.
- Close and reopen Terminal, then run node --version again to confirm it works.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir bookmark-api
cd bookmark-api
- Confirm the folder was created by running:
ls
The folder will be empty for now. That's expected.
- Initialize a new Node.js project by running:
npm init -y
What does this command do?
The npm init -y command creates a package.json file with default values. This file tracks your project's name, version, and dependencies (the external packages your code relies on).
You should see a new package.json file listed in your folder.
- Install Express (the web framework for your API) by running:
npm install express@4
What does this command do?
This downloads Express version 4 and adds it to your package.json under dependencies. It also creates a node_modules folder containing Express and all its supporting packages.
- Verify Express was added by opening package.json in VS Code:
code .
- Click on package.json in the VS Code sidebar.
- Confirm you see express listed under dependencies with a version starting with ^4.
Seeing an error from npm install?
Make sure you're inside the bookmark-api folder when you run the install command. Run pwd to check your current location.
If you see permission errors, try running the command again without sudo. Using sudo with npm can cause more problems than it solves.
Help me fix my npm install error.
✔️ Awesome, I've got everything!
Make sure you've saved all your files. Your project is ready for containerization.
ⓧ I'd like to double check the full code
Your package.json should look similar to this. Your file may have additional fields like keywords, author, and license. Those are normal defaults from npm init.
{
"name": "bookmark-api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"express": "^4.21.0"
}
}
Your tools are installed and your project is initialized. Next up, you'll write a Dockerfile and run your Express app inside a container for the first time.
Build and Run Your First Container
Your development environment is ready. Now it's time to package your app into a Docker container.
A container bundles your code, dependencies, and runtime into a single portable unit that runs the same way everywhere. In this step, you'll write a simple Express server, create a Dockerfile that tells Docker how to build your app's image, and run your first container.
In this step, get ready to:
- Write the Express server and create a Dockerfile to package it.
- Add a .dockerignore file to keep your image lean.
- Build a Docker image and run it as a container.
Write the Express server and Dockerfile
The Dockerfile packages your app into an image, but first you need an app to package. Let's create a simple Express server that responds to requests.
- In VS Code, create a new file called server.js inside your bookmark-api folder.
- Paste the following code into server.js:
const express = require('express');
const app = express();
app.use(express.json());
// Respond with a status message at the root URL
app.get('/', (req, res) => {
res.json({ message: 'Bookmark API is running', status: 'ok' });
});
// Start the server on port 3000 (or a custom PORT from the environment)
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Bookmark API listening on port ${PORT}`);
});
What does this code do?
This is a minimal Express server with one route.
- app.get('/') defines a GET endpoint at the root URL that returns a JSON status message.
- app.listen(PORT) starts the server on port 3000, making it accessible to incoming requests.
- Save server.js.
- Test that it works by running this command in your terminal (make sure you're inside the bookmark-api folder):
node server.js
- You should see Bookmark API listening on port 3000 in your terminal output.
- Press Ctrl+C to stop the server.
Seeing an error when running the server?
- If you see Cannot find module 'express', make sure you ran npm install express@4 in Step 1 and that your terminal is inside the bookmark-api folder.
- If you see address already in use, another process is using port 3000. Close any other servers you might have running.
Still stuck? Help me debug why my Express server won't start.
✔️ Awesome, I've got everything!
Great. Make sure you've saved server.js before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const app = express();
app.use(express.json());
app.get('/', (req, res) => {
res.json({ message: 'Bookmark API is running', status: 'ok' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Bookmark API listening on port ${PORT}`);
});
Now that you have a working server, let's create the Dockerfile that tells Docker how to package it into an image.
- Create a new file called Dockerfile (no extension) in the root of your bookmark-api folder.
- Paste the following code into Dockerfile:
# Use a lightweight Node.js base image
FROM node:24-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy all project files into the container
COPY . .
# Install dependencies inside the container
RUN npm install
# Document which port the app uses
EXPOSE 3000
# Start the server when the container runs
CMD ["node", "server.js"]
What does each Dockerfile instruction do?
- FROM node:24-alpine picks the base image. Alpine is a tiny Linux distribution, so your image stays small.
- WORKDIR /app sets the directory where all following commands will run inside the container.
- COPY . . copies everything from your project folder into the container's /app directory.
- RUN npm install installs your dependencies inside the container, just like you did locally.
- CMD ["node", "server.js"] is the command Docker runs when the container starts.
- Save Dockerfile.
- You won't see a visible change yet. The next substep builds this into a running container.
✔️ Awesome, I've got everything!
Great. Make sure you've saved Dockerfile before moving on.
ⓧ I'd like to double check the full code
FROM node:24-alpine
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
Create the .dockerignore file
When Docker builds your image, it sends your entire project folder to the Docker engine as the "build context." A .dockerignore file tells Docker which files to skip, keeping your image smaller and your builds faster.
- Create a new file called .dockerignore in the root of your bookmark-api folder.
- Paste the following into .dockerignore:
node_modules
.git
.gitignore
*.md
.env
Why exclude node_modules?
Your Dockerfile already runs npm install inside the container, so it installs fresh dependencies there. Copying your local node_modules would bloat the image and could introduce platform-specific binaries that don't work in the container's Linux environment.
- Save .dockerignore.
- You won't see a visible change yet. This file takes effect during the next build step.
✔️ Awesome, I've got everything!
Great. Make sure you've saved .dockerignore before moving on.
ⓧ I'd like to double check the full code
node_modules
.git
.gitignore
*.md
.env
Build the image and run the container
You now have everything Docker needs to create an image. Building converts your Dockerfile instructions into a layered image. Running starts a live container from that image.
- Build the Docker image by running this command in your terminal (make sure you're inside the bookmark-api folder):
docker build -t bookmark-api .
What does this command do?
- docker build reads your Dockerfile and executes each instruction to create an image.
- -t bookmark-api tags the image with a human-readable name so you can reference it later.
- . tells Docker to use the current directory as the build context.
- You should see Docker processing each instruction from your Dockerfile. When it finishes, you'll see a confirmation that the image was built successfully.
- Now run a container from your new image:
docker run -d -p 3000:3000 bookmark-api
What does this command do?
- -d runs the container in detached mode (in the background), so your terminal stays free.
- -p 3000:3000 maps port 3000 on your machine to port 3000 inside the container. This is called port mapping.
- bookmark-api is the name of the image to run.
- Open your browser and visit http://localhost:3000.
- You should see a JSON response: {"message":"Bookmark API is running","status":"ok"}.
Your Express app is running inside a Docker container. The app no longer relies on your local machine's Node.js installation. Everything it needs is packaged inside the image.
Not seeing the JSON response?
- Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your menu bar.
- If you see a "port already in use" error, stop any other process on port 3000 (like the node server.js you tested earlier). Press Ctrl+C in that terminal window if it's still running.
- If the build failed, check your Dockerfile for typos. Each instruction (FROM, WORKDIR, COPY, RUN, CMD) must be in ALL CAPS.
Still stuck? Help me debug why my Docker container isn't serving my Express app on localhost:3000.
Now let's inspect the running container and learn how to clean it up.
- View your running containers by running:
docker ps
- You should see a table with columns including CONTAINER ID, IMAGE, STATUS, PORTS, and NAMES. Your bookmark-api container will be listed there.
- Copy the value in the CONTAINER ID column. You'll need it to stop the container.
- Stop the running container by running:
docker stop [[CONTAINER_ID="your container ID from docker ps"]]
- Remove the stopped container by running:
docker rm [[CONTAINER_ID="your container ID from docker ps"]]
Why stop and remove separately?
docker stop gracefully shuts down the running process. docker rm deletes the stopped container from your system. A stopped container still takes up disk space until you remove it.
You've built your first Docker image and run it as a container. Next up, you'll optimize the Dockerfile so that rebuilds are lightning fast when you change your code.
Optimize Your Dockerfile for Fast Rebuilds
In the previous step, you built and ran your first container. Every time you changed any file and rebuilt, Docker reinstalled all your npm packages from scratch.
Docker builds images in layers. Each instruction in your Dockerfile creates one layer. If a layer changes, all layers below it must be rebuilt too. In this step, you will restructure your Dockerfile so that code changes do not trigger a slow dependency reinstall.
In this step, get ready to:
- Observe how a code change forces a full npm reinstall.
- Restructure the Dockerfile to separate dependency installation from code copying.
- Verify that code-only changes no longer trigger npm install.
Examine the current build behavior
Your current Dockerfile copies all files at once, then runs npm install. This means any file change invalidates the copy layer and forces Docker to reinstall dependencies from scratch.
- Open server.js in VS Code and add this comment on the last line of the file:
// test change
- Save server.js.
- Rebuild the image by running this command:
docker build -t bookmark-api .
Watch the build output. You will see that the npm install step runs again, even though your dependencies have not changed.
- Inspect the image layers by running this command:
docker image history bookmark-api
What does this command show?
Each row in the output represents a layer in your image. The newest layers appear at the top. You can see each Dockerfile instruction (WORKDIR, COPY, RUN) mapped to its own layer with a size.
Notice the RUN npm install layer is one of the largest. Every time you change a source file above it, this heavy layer gets rebuilt unnecessarily.
- Remove the test comment you added to server.js and save the file.
Build did not show npm install running?
If your build showed all layers as cached, Docker may still have the previous build context in cache. Make a different change to server.js (like adding a space to a string) and rebuild again.
Help me debug why my Docker build shows cached layers after a file change
Restructure the Dockerfile for layer caching
The fix is to copy your dependency files first, install packages, and only then copy your source code. This way, Docker caches the dependency layer and only rebuilds it when package.json actually changes.
- Open Dockerfile in VS Code and replace its entire contents with the following:
FROM node:24-alpine
WORKDIR /app
# Copy dependency manifests first
COPY package.json package-lock.json ./
# Install only production dependencies
RUN npm install --omit=dev
# Copy the rest of the source code
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
What changed and why?
Previously, COPY . . copied everything in one step, then npm install ran after. Any file change invalidated the copy layer and forced a full reinstall.
Now package.json and package-lock.json are copied first in their own layer. The npm install --omit=dev layer sits on top of that. The second COPY . . comes last, so changes to your source code only invalidate that final layer.
The --omit=dev flag skips devDependencies, keeping your production image smaller.
- Save Dockerfile.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Verify fast rebuilds with caching
Now you will prove the optimization works. You will build the image fresh, make a code change, and rebuild to confirm the dependency layer stays cached.
- Build the image with the restructured Dockerfile by running:
docker build -t bookmark-api .
This first build will run npm install because the Dockerfile structure changed. That is expected.
- Now make a small code change. Open server.js and add this comment on the last line:
// another test change
- Save server.js and rebuild the image:
docker build -t bookmark-api .
Look at the build output. You should see CACHED next to the npm install --omit=dev step. Only the final COPY . . step re-runs.
What should I see?
Your build output will show lines like these:
- => CACHED [2/5] WORKDIR /app
- => CACHED [3/5] COPY package.json package-lock.json ./
- => CACHED [4/5] RUN npm install --omit=dev
- => [5/5] COPY . .
The CACHED prefix means Docker reused the layer from the previous build. The build finishes in about one second instead of re-downloading all your dependencies.
- Remove the test comment from server.js and save the file.
Not seeing CACHED for the npm install layer?
Make sure you did not accidentally change package.json or package-lock.json. Any change to those files invalidates the dependency layer and triggers a full reinstall.
Also confirm your Dockerfile has COPY package.json package-lock.json ./ before RUN npm install --omit=dev. The order matters.
Help me debug why my Docker build is not caching the npm install layer
Your Dockerfile is now optimized for the developer workflow. Code changes rebuild in seconds instead of waiting for npm to reinstall everything. Next up, you will add Redis as a second container and connect both services with Docker Compose.
Add Redis with Docker Compose
Your Dockerfile now rebuilds in seconds thanks to layer caching. The next challenge is connecting your API to a real database.
Right now, your app has no way to store data. In this step, you will add a Redis database container alongside your API container. Docker Compose is the tool that lets you define and run multiple containers together as a single stack.
In this step, get ready to:
- Create a Docker Compose configuration to orchestrate your API and Redis containers.
- Update your Express app to store and retrieve bookmarks from Redis.
- Launch the multi-container stack and test the API endpoints.
Create a compose.yaml file
Docker Compose uses a YAML file to define all the services (containers) your app needs. Instead of running long docker run commands manually, you describe your entire stack in one file.
- Create a new file called compose.yaml in your bookmark-api folder, then paste in the following:
services:
api:
build: .
ports:
- "3000:3000"
environment:
- REDIS_HOST=redis
depends_on:
- redis
redis:
image: redis:alpine
What does this configuration do?
- api service: builds from your local Dockerfile, maps port 3000 to your machine, and passes REDIS_HOST=redis as an environment variable so your app knows where to find Redis.
- redis service: pulls the lightweight redis:alpine image from Docker Hub. No build needed.
- depends_on tells Docker Compose to start the Redis container before the API container.
How do the containers talk to each other?
Docker Compose automatically creates a shared network for all services defined in the same file. Each service can reach the others by using the service name as a hostname. That is why the API connects to Redis using the hostname redis (the service name in your compose.yaml).
- Save compose.yaml.
YAML giving you trouble?
YAML is sensitive to indentation. Make sure you use spaces (not tabs) and that each level is indented by exactly 2 spaces. The VS Code Docker extension provides syntax highlighting that helps catch formatting issues.
help me fix YAML indentation in my compose.yaml
✔️ Awesome, I've got everything!
Great. Make sure you have saved compose.yaml before moving on.
ⓧ I'd like to double check the full code
services:
api:
build: .
ports:
- "3000:3000"
environment:
- REDIS_HOST=redis
depends_on:
- redis
redis:
image: redis:alpine
Install Redis and update the Express app
Your Compose file is ready, but the API code still does not know how to talk to Redis. You need to add the Redis client library and rewrite your server to store bookmarks in Redis instead of losing them every time the container stops.
- Replace the contents of package.json with the following:
{
"name": "bookmark-api",
"version": "1.0.0",
"description": "A containerized bookmark API",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.21.0",
"redis": "^4.7.0"
}
}
What changed in package.json?
- Added redis as a dependency. This is the official Node.js client for Redis.
- Updated main to point to server.js and added a start script so the app entry point is clear.
- Save package.json, then install the updated dependencies by running this in your terminal:
npm install
This generates an updated package-lock.json that your Dockerfile needs during the build.
Seeing permission errors?
Make sure your terminal is inside the bookmark-api folder. Run pwd to confirm. If you are in the wrong directory, run cd ~/Desktop/bookmark-api to get back.
help me fix npm install errors in my bookmark-api project
- Now replace the entire contents of server.js with this updated version that uses Redis:
const express = require("express");
const { createClient } = require("redis");
const app = express();
app.use(express.json());
// Create a Redis client using the REDIS_HOST env variable (defaults to localhost)
const redisClient = createClient({
url: `redis://${process.env.REDIS_HOST || "localhost"}:6379`,
});
redisClient.on("error", (err) => console.log("Redis Client Error", err));
async function startServer() {
// Connect to Redis before handling requests
await redisClient.connect();
app.get("/", (req, res) => {
res.json({ message: "Bookmark API is running", status: "ok" });
});
// POST /bookmarks - save a new bookmark to Redis
app.post("/bookmarks", async (req, res) => {
const { url, title } = req.body;
if (!url || !title) {
return res.status(400).json({ error: "url and title are required" });
}
const bookmark = { url, title, createdAt: new Date().toISOString() };
await redisClient.lPush("bookmarks", JSON.stringify(bookmark));
res.status(201).json(bookmark);
});
// GET /bookmarks - retrieve all bookmarks from Redis
app.get("/bookmarks", async (req, res) => {
const bookmarks = await redisClient.lRange("bookmarks", 0, -1);
res.json(bookmarks.map((b) => JSON.parse(b)));
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Bookmark API listening on port ${PORT}`);
});
}
startServer().catch(console.error);
What does this code do?
- createClient from the redis package connects to your Redis container using the REDIS_HOST environment variable you set in compose.yaml.
- startServer() is an async function that connects to Redis first, then starts listening for requests. This ensures Redis is ready before your API accepts traffic.
- POST /bookmarks saves a bookmark as JSON in a Redis list using lPush.
- GET /bookmarks retrieves all items from the Redis list using lRange and parses them back into objects.
- Save server.js.
Syntax error when saving?
Check that you replaced the entire file contents. Leftover lines from the old version can cause conflicts. Make sure every opening brace { has a matching closing brace } and every opening parenthesis has a closing one.
help me fix a syntax error in my server.js Redis code
✔️ Awesome, I've got everything!
Great. Double check that both package.json and server.js are saved.
ⓧ I'd like to double check the full code
{
"name": "bookmark-api",
"version": "1.0.0",
"description": "A containerized bookmark API",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.21.0",
"redis": "^4.7.0"
}
}
const express = require("express");
const { createClient } = require("redis");
const app = express();
app.use(express.json());
const redisClient = createClient({
url: `redis://${process.env.REDIS_HOST || "localhost"}:6379`,
});
redisClient.on("error", (err) => console.log("Redis Client Error", err));
async function startServer() {
await redisClient.connect();
app.get("/", (req, res) => {
res.json({ message: "Bookmark API is running", status: "ok" });
});
app.post("/bookmarks", async (req, res) => {
const { url, title } = req.body;
if (!url || !title) {
return res.status(400).json({ error: "url and title are required" });
}
const bookmark = { url, title, createdAt: new Date().toISOString() };
await redisClient.lPush("bookmarks", JSON.stringify(bookmark));
res.status(201).json(bookmark);
});
app.get("/bookmarks", async (req, res) => {
const bookmarks = await redisClient.lRange("bookmarks", 0, -1);
res.json(bookmarks.map((b) => JSON.parse(b)));
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Bookmark API listening on port ${PORT}`);
});
}
startServer().catch(console.error);
Start the stack and test the API
All your files are ready. It is time to launch both containers together and see them work as a team.
- Start the entire stack by running this command:
docker compose up -d --build
What does this command do?
- docker compose up starts all services defined in your compose.yaml.
- -d runs them in detached mode (in the background) so you get your terminal back.
- --build forces a fresh image build for the api service, picking up your code changes.
- Confirm both containers are running:
docker compose ps
You should see two services listed: api and redis, both with a status of running.
- Test the root endpoint to confirm the API is responding:
curl http://localhost:3000
You should see {"message":"Bookmark API is running","status":"ok"} in your terminal.
- Create a bookmark by running this command:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"url":"https://nextwork.org","title":"NextWork"}'
You should see a JSON response with the bookmark you just created, including a createdAt timestamp.
- Retrieve all bookmarks to confirm it was saved:
curl http://localhost:3000/bookmarks
You should see an array containing the bookmark you just posted.
- View real-time logs from both services by running:
docker compose logs -f
You should see interleaved logs from both the api and redis containers. Press Ctrl+C to stop watching logs.
Does data survive a container restart?
If you restart just the API container, your bookmarks remain safe because Redis is still running and holding the data. However, if you run docker compose down (which removes containers entirely), your bookmarks disappear because Redis stores data inside the container.
You will fix this in the next step by adding a persistent volume.
- Test data persistence by restarting only the API container:
docker compose restart api
- Retrieve your bookmarks again to confirm they survived the restart:
curl http://localhost:3000/bookmarks
Your bookmark is still there. Redis kept the data safe while the API container restarted.
Getting connection refused?
If curl returns connection refused, your API container may have failed to start. Run docker compose logs api to check for error messages.
Common causes: a typo in server.js preventing the app from starting, or package-lock.json not being up to date (re-run npm install locally and rebuild with docker compose up -d --build).
help me debug my Docker Compose API connection issue
Your bookmark API is now running as a multi-container application. Next up, you will add a persistent volume so your data survives even when containers are removed entirely.
Persist Data with Volumes
Your multi-container stack is running, and bookmarks flow between Express and Redis. But there is a problem. All of your data lives inside the Redis container's filesystem.
When that container is removed, the data disappears with it. Docker volumes solve this by storing data outside the container, on your host machine. In this step, you will add a volume to keep your bookmarks safe across container restarts.
In this step, get ready to:
- Observe how container data is lost without volumes.
- Add a named volume to persist Redis data.
- Explore container management commands.
Stop the stack and observe data loss
Right now your stack is running with bookmarks stored in Redis. Let's prove the problem by tearing down the containers and seeing what happens.
- Stop and remove the running containers by running this command:
docker compose down
- Restart the stack by running:
docker compose up -d
- Check your bookmarks by visiting http://localhost:3000/bookmarks in your browser.
You should see an empty array []. All the bookmarks you created earlier are gone. Redis was storing data inside the container's filesystem, and docker compose down removed that container entirely.
Why did my data disappear?
Containers are ephemeral by design. Their writable filesystem layer is tied to the container's lifecycle.
When you run docker compose down, Docker removes the containers and their filesystems. A named volume stores data on your host machine, outside the container, so it survives container removal.
- Stop the stack again so you can update the configuration:
docker compose down
- Open compose.yaml in VS Code.
- Replace the entire file with this updated configuration:
services:
api:
build: .
ports:
- "3000:3000"
environment:
- REDIS_HOST=redis
depends_on:
- redis
redis:
image: redis:alpine
volumes:
- redis-data:/data
volumes:
redis-data:
What does this code do?
- The volumes: key under the redis service mounts the named volume redis-data to /data inside the container. Redis stores its dump files in /data by default.
- The top-level volumes: section declares redis-data as a named volume. Docker manages this volume on your host machine, independent of any container.
- Save compose.yaml.
Seeing a YAML syntax error?
- YAML relies on consistent indentation. Make sure you are using spaces, not tabs.
- The volumes: key under redis should be indented at the same level as image:.
- The top-level volumes: must be at the root level (no indentation), the same level as services:.
Help me fix a YAML indentation error in my compose.yaml
Verify data persists across restarts
- Start the stack with the new volume configuration by running:
docker compose up -d
- Create a bookmark by running this command:
curl -X POST http://localhost:3000/bookmarks \
-H "Content-Type: application/json" \
-d '{"url": "https://docker.com", "title": "Docker Docs"}'
You should see a JSON response with your new bookmark.
- Now tear down the entire stack and bring it back up by running:
docker compose down
docker compose up -d
- Check that your bookmark survived by running:
curl http://localhost:3000/bookmarks
You should see the bookmark you just created. The named volume kept your Redis data safe even though the containers were completely removed and recreated.
Still seeing an empty array?
- Make sure you saved compose.yaml before running docker compose up -d.
- Check that the volumes: section under redis maps to redis-data:/data (not a different path).
- Confirm the top-level volumes: key declares redis-data:.
Help me debug why my Docker volume is not persisting data
Explore container management commands
Docker Compose provides commands to inspect, interact with, and fully clean up your running services. These are essential for debugging and resetting your environment.
- Check the status of your services by running:
docker compose ps
You should see both the api and redis services listed with their status, ports, and how long they have been running.
- Open an interactive shell inside the running API container by running:
docker compose exec api sh
What does this command do?
The exec command runs a command inside an already-running container. Here, api is the service name from your compose.yaml, and sh opens a shell. This is useful for debugging (checking files, testing network connectivity, or verifying environment variables inside the container).
- Look around inside the container. Try running ls to see your app files, then type exit to leave the shell.
You should see your application files (server.js, package.json, node_modules) inside the /app directory.
- Finally, remove everything including the persistent volume by running:
docker compose down --volumes
What does the --volumes flag do?
By default, docker compose down removes containers but keeps named volumes intact. Adding --volumes removes the named volumes too, giving you a completely clean slate.
- Bring the stack back up for the next section by running:
docker compose up -d
You should see both services start up fresh. Since you used --volumes to wipe the data, visiting http://localhost:3000/bookmarks now returns an empty array again.
Container won't start?
- Run docker compose logs -f to see error messages from both services.
- Make sure Docker Desktop is still running in the background.
Help me debug a container that won't start after docker compose up
✔️ Awesome, I've got everything!
Great. Double check you have saved your file.
ⓧ I'd like to double check the full code
Here is the complete compose.yaml for this step:
services:
api:
build: .
ports:
- "3000:3000"
environment:
- REDIS_HOST=redis
depends_on:
- redis
redis:
image: redis:alpine
volumes:
- redis-data:/data
volumes:
redis-data:
Your bookmark API now has real data persistence. Containers can come and go, but your bookmarks stay safe on disk. Next up, you will tackle the Secret Mission and add health checks to make your containers production-ready.
Secret mission
Add a Health Check
In production, containers crash and dependencies go down. Right now your API starts whether Redis is ready or not. Add health checks so Docker can monitor your services and control startup order based on actual readiness.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker Desktop, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers (bookmark-api and Redis).
- Docker image (bookmark-api).
- Docker volume (redis-data).
- Project folder (bookmark-api on your Desktop).
- Docker Desktop (installed application).
Keep everything running
No action needed. Choose this if you're still actively building or want to keep experimenting with Docker containers.
- Your Docker containers, images, and volumes stay on your machine.
- Docker Desktop continues running in the background (you can quit it from the menu bar icon to free up memory).
- Your bookmark-api folder remains on your Desktop.
Pause - I'll come back to this later
Shut down running containers to free up memory, but keep your files and images so you can pick up where you left off.
- Stop all containers by running this command from your bookmark-api folder:
docker compose down
- This stops and removes containers but keeps your images, volumes, and project files intact.
- Quit Docker Desktop from the Docker icon in your menu bar to free up system memory.
- When you're ready to come back, open Docker Desktop and run docker compose up -d from your project folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to clean up everything.
- Stop all containers and remove the Docker volume by running this from your bookmark-api folder:
docker compose down --volumes
- Remove the Docker image by running:
docker image rm bookmark-api
- Delete the project folder (macOS/Linux):
rm -rf ~/Desktop/bookmark-api
- Delete the project folder (Windows):
rmdir /s /q %USERPROFILE%\Desktop\bookmark-api
- To uninstall Docker Desktop, open Docker Desktop, click the Troubleshoot icon (top-right corner), then select Uninstall and confirm.
Nice Work!
Nice Work!
You containerized a Node.js application from scratch. You now have a multi-container bookmark API running locally with Docker Compose, complete with optimized builds and persistent storage.
Here's what you accomplished:
- Built a Docker image from a custom Dockerfile and ran containers with port mapping and lifecycle management.
- Optimized your Dockerfile for fast rebuilds using layer caching, so dependency installs are skipped when only source code changes.
- Orchestrated multiple services (Express + Redis) with Docker Compose and persisted data across container restarts with volumes.
- Added health checks for production-ready container orchestration with startup dependencies.
Ready to quiz yourself?