Containerize a Node.js API with Docker

Package a Node.js bookmarks API into Docker containers with MongoDB.

Introduction

30 Second Summary

Every developer has heard "it works on my machine." Docker solves this by packaging your app and everything it needs into a single portable container that runs the same everywhere.

In this project, you will build a Bookmarks API with Node.js, package it into a Docker image, and run it alongside a MongoDB database using Docker Compose.

What You'll Build

One command, and your entire application stack spins up. You can save bookmarks, restart everything, and see your data survive because you wired up volumes correctly.

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

  • A running Express API at localhost:3000 where you can save and retrieve bookmarks from your browser or terminal.
  • A Dockerfile that packages your app into a portable image anyone can run with a single command. No Node.js install required on the target machine.
  • A Docker Compose setup that spins up your app and MongoDB together, with persistent data that survives container restarts thanks to named volumes.
  • Secret Mission: Harden your image with a .dockerignore and a non-root user for production readiness.

Are there any prerequisites?

This project assumes you're comfortable with basic Node.js and Express concepts (creating routes, handling requests). You'll need a Windows 10 or 11 machine.

No prior Docker experience is required. You'll learn everything from scratch.

Before We Start

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

Install Docker Desktop

Docker packages your application and everything it needs into a single container that runs the same way on any machine. Before you can build or run any containers, you need Docker installed and running locally.

In this step, you'll install Docker Desktop for Windows and confirm it works by running a test container.

In this step, get ready to:
  • Install Docker Desktop for Windows with the WSL 2 backend.
  • Verify Docker works end-to-end by running a test container.
Check if Docker is already installed

You might already have Docker from a previous project. Let's check before downloading anything.

  • Press the Windows key to open your search bar.
  • Type Windows Terminal and press Enter to open it.
  • Check your Docker version by running this command:
docker --version

✔️ I see Docker version 28 or higher

Docker is already installed and up to date. Skip ahead to the next substep to make sure Docker Desktop is running.

ⓧ I see an older version

Your Docker installation needs updating. The latest installer will upgrade it in place.

  • Open the Docker Desktop install page in your browser:

https://docs.docker.com/desktop/setup/install/windows-install/

  • Click the download button at the top of the page to download Docker Desktop Installer.exe.
  • Double-click Docker Desktop Installer.exe to run it.
  • When the installer asks which installation mode you prefer, select the per-user option.
  • On the Configuration page, make sure Use WSL 2 instead of Hyper-V is selected.
  • Follow the remaining wizard steps and select Close when installation completes.

After the installer finishes, move on to the next substep to launch Docker Desktop.

ⓧ Command not found

Docker is not installed yet. Let's set it up from scratch.

  • Open the Docker Desktop install page in your browser:

https://docs.docker.com/desktop/setup/install/windows-install/

  • Click the download button at the top of the page to download Docker Desktop Installer.exe.
  • Double-click Docker Desktop Installer.exe to run it.
  • When the installer asks which installation mode you prefer, select the per-user option. This does not require administrator privileges.
  • On the Configuration page, make sure Use WSL 2 instead of Hyper-V is selected.
  • Follow the remaining wizard steps and select Close when installation completes.

Will it ask for admin permissions?

The per-user installation itself does not require admin rights. However, enabling WSL 2 for the first time may prompt for administrator credentials once. After that initial setup, Docker Desktop runs without admin privileges.

After the installer finishes, move on to the next substep to launch Docker Desktop.

What is Docker Desktop?

Docker Desktop is an all-in-one application that bundles the Docker Engine, CLI tools, Docker Compose, and a graphical dashboard. It uses WSL 2 on Windows to run Linux containers natively on your machine.

Docker Desktop is free for personal use and education.

Launch Docker Desktop

Installing Docker Desktop does not start the engine automatically. You need to launch the application so the Docker daemon starts in the background.

  • Press the Windows key to open your search bar.
  • Type Docker Desktop and press Enter to launch it.
  • Accept the Docker Subscription Service Agreement when prompted.
  • Wait for the Docker engine to start. Watch the whale icon in your system tray (bottom-right corner of your taskbar). When it stops animating and stays steady, Docker is ready.

Docker Desktop won't start?

If you see an error about WSL 2, open Windows Terminal and run wsl --update to ensure you have the latest WSL version. Then restart Docker Desktop.

If the whale icon keeps animating for several minutes, try restarting your computer. Docker occasionally needs a reboot after the initial WSL 2 setup.

Help me troubleshoot Docker Desktop not starting on Windows.

Run your first container

The Docker engine is running. Now let's confirm the full pipeline works by pulling an image from Docker Hub and running it as a container.

Before you run the next command, what do you think will happen? Docker will try to find an image called hello-world locally. Do you think it will find it?

  • Verify Docker works end-to-end by running this command:
docker run hello-world

You should see a message that starts with "Hello from Docker!" This confirms Docker can pull images from the internet and run them as containers on your machine.

What just happened?

Docker looked for the hello-world image on your machine, could not find it, pulled it from Docker Hub (a public registry of images), and ran it as a container. The container printed its message and exited.

This pull-then-run flow is the same loop you will use for every container in this project.

  • Confirm your Docker version by running:
docker --version

You should see something like Docker version 28.x.x printed in your terminal.

Seeing 'connection refused' or 'daemon not running'?

This means the Docker engine has not finished starting. Check that the whale icon in your system tray is steady (not animating). If it is still animating, wait another minute and try again.

If the icon is steady but the command still fails, right-click the whale icon and select Restart. Wait for it to stabilize, then retry the command.

Help me fix Docker daemon not running error on Windows.

Docker is installed and working. Next up, you'll write a real Express API and package it into a Docker image that anyone can run.

Build and Run Your First Container

You have Docker installed and ready. Now it's time to see the core Docker loop in action: write code, package it into an image, and run it as a container.

In this step, you will create a simple Express API that saves bookmarks, package it into a Docker image using a Dockerfile, and run it as a live container you can hit from your browser.

In this step, get ready to:
  • Create an Express API with endpoints to save, list, and delete bookmarks.
  • Write a Dockerfile that packages the API into a portable image.
  • Build the image and run it as a container accessible at localhost:3000.
Create your project folder and Express API
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir bookmarks-api
cd bookmarks-api
  • Confirm you are inside the new folder by running:
ls

The output should be empty since the folder is brand new.

  • Open this folder in VS Code by running:
code .

What does code . do?

The code command launches VS Code and the dot (.) tells it to open the current folder. This gives you a file explorer sidebar showing your bookmarks-api folder.

  • In VS Code, create a new file called package.json by clicking the new file icon in the Explorer sidebar.
  • Paste the following code into package.json:
{
  "name": "bookmarks-api",
  "version": "1.0.0",
  "description": "A bookmarks API for learning Docker",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "4.22.2"
  }
}

What does this file do?

The package.json file tells Node.js which packages your project depends on. Here you are declaring a single dependency: Express version 4.22.2, the web framework that handles HTTP requests.

  • Save package.json with Ctrl+S.

Now you will create the actual API. The server stores bookmarks in a JSON file inside a data/ directory. This file-based approach keeps things simple for now.

  • Create a new file called server.js in VS Code.
  • Paste the following setup and helper functions into server.js:
// Import dependencies
const express = require('express');
const fs = require('fs');
const path = require('path');

// Set up the Express app
const app = express();
app.use(express.json());

// Path to the JSON file that stores bookmarks
const DATA_FILE = path.join('/app/data', 'bookmarks.json');

// Read bookmarks from the JSON file
function readBookmarks() {
  if (!fs.existsSync(DATA_FILE)) return [];
  return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
}

// Write bookmarks to the JSON file
function writeBookmarks(bookmarks) {
  fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
  fs.writeFileSync(DATA_FILE, JSON.stringify(bookmarks, null, 2));
}

What does this code do?

This sets up two helper functions for reading and writing bookmarks. The DATA_FILE points to /app/data/bookmarks.json inside the container's filesystem.

The readBookmarks function returns an empty array if the file does not exist yet. The writeBookmarks function creates the data/ directory if needed, then saves the bookmarks array as JSON.

  • Add the route handlers and server start below the helper functions in the same server.js file:
// GET all bookmarks
app.get('/bookmarks', (req, res) => {
  res.json(readBookmarks());
});

// POST a new bookmark
app.post('/bookmarks', (req, res) => {
  const { url, title } = req.body;
  if (!url || !title) return res.status(400).json({ error: 'url and title are required' });
  const bookmarks = readBookmarks();
  const bookmark = { id: Date.now().toString(), url, title, createdAt: new Date() };
  bookmarks.push(bookmark);
  writeBookmarks(bookmarks);
  res.status(201).json(bookmark);
});

// DELETE a bookmark by ID
app.delete('/bookmarks/:id', (req, res) => {
  const bookmarks = readBookmarks().filter(b => b.id !== req.params.id);
  writeBookmarks(bookmarks);
  res.status(204).send();
});

// Start the server on port 3000
app.listen(3000, () => console.log('Bookmarks API running on http://localhost:3000'));

What do these routes do?

  • GET /bookmarks returns all saved bookmarks as a JSON array.
  • POST /bookmarks accepts a URL and title in the request body, creates a new bookmark with a unique ID, and saves it to the file.
  • DELETE /bookmarks/:id removes a bookmark by its ID.
  • Save server.js with Ctrl+S.
  • Confirm your files exist by switching back to Windows Terminal and running:
ls

You should see package.json and server.js listed in the output.

Don't see both files?

  • Make sure you saved both files in VS Code (Ctrl+S).
  • Confirm your terminal is inside the bookmarks-api folder. Run pwd to check the current directory.

Why can't I see my project files in Windows Terminal?

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Your server.js should contain both chunks in order: the setup and helper functions at the top, then the route handlers and server start at the bottom. Scroll up and confirm each section is present.

Your package.json should look exactly like this:

{
  "name": "bookmarks-api",
  "version": "1.0.0",
  "description": "A bookmarks API for learning Docker",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "4.22.2"
  }
}
Write the Dockerfile

A Dockerfile is a recipe that tells Docker how to package your app into an image. Each line is a step in the build process.

  • Create a new file called Dockerfile in VS Code (no file extension).
  • Paste the following into Dockerfile:
# Start from the official Node.js Alpine image (small and fast)
FROM node:24-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy package files first for better layer caching
COPY package*.json ./

# Install only production dependencies
RUN npm install --omit=dev

# Copy the rest of the source code
COPY . .

# Document which port the app uses
EXPOSE 3000

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

What does each instruction do?

  • FROM node:24-alpine picks a lightweight Linux image with Node.js pre-installed. Alpine keeps the image around 50MB instead of 350MB.
  • WORKDIR /app creates and switches to a folder called /app inside the container.
  • COPY package*.json then RUN npm install installs dependencies first. Docker caches this layer, so it skips reinstalling packages unless your dependencies change.
  • COPY . . copies your source code into the image.
  • CMD tells Docker what command to run when the container starts.
  • Save Dockerfile with Ctrl+S.
  • Confirm the Dockerfile exists by running in your terminal:
ls

You should now see three files: Dockerfile, package.json, and server.js.

Dockerfile not showing up?

  • Make sure the file is named exactly Dockerfile with a capital D and no extension (not Dockerfile.txt).
  • Confirm you created it inside the bookmarks-api folder, not a subfolder.

My Dockerfile isn't being recognized. What should I check?

✔️ Awesome, I've got everything!

Great. Double check you saved the file before building.

ⓧ I'd like to double check the full code

Your Dockerfile should look exactly like this:

FROM node:24-alpine

WORKDIR /app

COPY package*.json ./

RUN npm install --omit=dev

COPY . .

EXPOSE 3000

CMD ["node", "server.js"]
Build the image and run the container

You have the source code and the Dockerfile. Time to build a Docker image and launch it as a running container.

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

What does this command do?

The -t bookmarks-api flag tags (names) your image so you can reference it later. The dot (.) at the end tells Docker to look for the Dockerfile in your current directory.

Docker reads each instruction in your Dockerfile, executes it, and stacks the results into layers. The final output is a portable image you can run anywhere Docker is installed.

You will see Docker pulling the node:24-alpine base image, installing your npm dependencies, and copying your source code. This takes a minute the first time.

Build failing with an error?

  • Make sure Docker Desktop is running (check for the whale icon in your system tray).
  • Confirm you are inside the bookmarks-api folder where the Dockerfile lives.
  • Check that your package.json has valid JSON (no trailing commas, matching brackets).

My docker build command is failing. What could be wrong?

  • Run your freshly built image as a container:
docker run -d -p 3000:3000 --name bookmarks bookmarks-api

What do these flags mean?

  • -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. Without this, the container's port is unreachable.
  • --name bookmarks gives your container a friendly name so you can stop or remove it by name later.
  • bookmarks-api at the end is the image you just built.

Docker prints a long container ID. That means your container is running.

Before you test, what do you think you will get back if you request all bookmarks from a brand new API with no data?

  • Test your running API by fetching all bookmarks:
curl http://localhost:3000/bookmarks

You should see an empty JSON array: []. Your API is alive inside the container and responding to requests.

  • Add a bookmark to prove writes work too:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d "{\"url\": \"https://docker.com\", \"title\": \"Docker Docs\"}"

You should get back a JSON object with your bookmark, including an id and createdAt timestamp.

  • Verify the bookmark was saved by fetching all bookmarks again:
curl http://localhost:3000/bookmarks

This time you should see an array containing the Docker Docs bookmark you just added.

Getting 'connection refused' or no response?

  • Check that the container is actually running with docker ps. If it is not listed, run docker logs bookmarks to see what went wrong.
  • Make sure nothing else is using port 3000 on your machine.
  • If the container exited immediately, there may be a syntax error in server.js. Run docker logs bookmarks to see the Node.js error output.

My container is not responding to curl requests. Help me debug it.

Your Express API is running inside a Docker container. You wrote the code, packaged it into an image with a Dockerfile, and launched it with one command. Next up, you will discover what happens to your data when the container disappears.

Discover and Fix the Disappearing Data

Your API is running inside a container, and you can save bookmarks to it. Everything works.

But your bookmarks are stored in a file inside the container's filesystem. When that container is destroyed, the file goes with it. This is the core Docker insight: containers are ephemeral. In this step, you will experience that data loss firsthand and then fix it with a volume.

In this step, get ready to:
  • Experience data loss when a container is removed.
  • Fix persistence with a bind mount that maps a host folder into the container.
  • Explore a running container's filesystem from the inside.
Add bookmarks and destroy the container

Your container from the last step is still running. It has bookmarks stored inside its filesystem. Let's prove that by adding some data and then seeing what happens when the container is gone.

  • Add a bookmark to your running container by running this command:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d "{\"url\": \"https://nodejs.org\", \"title\": \"Node.js\"}"
  • Add a second bookmark by running:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d "{\"url\": \"https://expressjs.com\", \"title\": \"Express\"}"
  • Verify both bookmarks exist by running:
curl http://localhost:3000/bookmarks

You should see a JSON array containing both bookmarks with their URLs and titles.

Now, before you do the next step, think about this: what do you think will happen to your bookmarks if you destroy this container and start a fresh one from the same image?

  • Stop the running container by running:
docker stop bookmarks
  • Remove the container by running:
docker rm bookmarks
  • Start a brand new container from the same image by running:
docker run -d -p 3000:3000 --name bookmarks bookmarks-api
  • Check for your bookmarks by running:
curl http://localhost:3000/bookmarks

You get back an empty array: []. Your bookmarks are gone.

Why did the data disappear?

Every container has its own isolated filesystem. When you ran docker rm bookmarks, that filesystem was destroyed along with the bookmarks.json file inside it.

The new container starts fresh from the image. It has no memory of what the previous container wrote. This is by design. Containers are meant to be disposable.

Fix persistence with a bind mount

The fix is to store your data outside the container. A bind mount connects a folder on your host machine to a folder inside the container. Anything the container writes to that folder actually lands on your hard drive, surviving any container restart or removal.

  • Stop and remove the current container by running:
docker stop bookmarks
docker rm bookmarks
  • Start a new container with a bind mount by running this command:
docker run -d -p 3000:3000 --name bookmarks -v %cd%\data:/app/data bookmarks-api

What does the -v flag do?

The -v %cd%\data:/app/data flag creates a bind mount. The part before the colon (%cd%\data) is a folder on your Windows machine. The part after the colon (/app/data) is the path inside the container.

Now when the app writes to /app/data/bookmarks.json inside the container, the file actually lands in your project's data/ folder on disk.

Seeing a path error or empty responses?

Make sure you are running the command from your project folder (the one containing your Dockerfile). The %cd% variable expands to whatever directory your terminal is currently in.

If you are using PowerShell instead of Command Prompt, replace %cd% with ${PWD}.

help me fix a Docker bind mount path error on Windows

  • Add a bookmark to the volume-mounted container by running:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d "{\"url\": \"https://docker.com\", \"title\": \"Docker Docs\"}"
  • Verify the bookmark exists by running:
curl http://localhost:3000/bookmarks

You should see your bookmark returned in the JSON array. Now the real test.

Before you run the next commands, predict: will your bookmark survive this time?

  • Stop and remove the container by running:
docker stop bookmarks
docker rm bookmarks
  • Start a fresh container with the same bind mount by running:
docker run -d -p 3000:3000 --name bookmarks -v %cd%\data:/app/data bookmarks-api
  • Check for your bookmark by running:
curl http://localhost:3000/bookmarks

Your bookmark is still there. The data survived because it lives on your host machine, not inside the container.

What just happened?

The container was destroyed and recreated, but the data/ folder on your Windows machine stayed untouched. The new container mounted that same folder and read the existing bookmarks.json file. This is how production apps keep data safe across deployments.

Explore inside the running container

Your container is a tiny Linux environment. You can open a shell inside it to look around, just like SSH-ing into a server.

  • Open a shell inside your running container by running:
docker exec -it bookmarks sh

What does docker exec do?

The docker exec command runs a new process inside an already-running container. The -it flags give you an interactive terminal. The sh at the end is the shell program to run (Alpine Linux uses sh instead of bash).

  • List the contents of the data directory by running:
ls /app/data

You should see bookmarks.json listed. This is the same file that exists on your host machine in the data/ folder.

  • Exit the container shell by running:
exit
  • Clean up by stopping and removing the container:
docker stop bookmarks
docker rm bookmarks

Your container is gone, but the data/ folder and bookmarks.json remain on your machine. You could start a new container with the same -v flag any time and pick up right where you left off.

Container not responding to docker exec?

Make sure the container is still running before you exec into it. Run docker ps to confirm it shows in the list. If it's not there, restart it with the docker run command from the previous substep.

help me troubleshoot docker exec not working

You have now seen the full lifecycle: containers lose their data when destroyed, but bind mounts let you persist anything that matters. Next up, you will swap the JSON file for a real database and use Docker Compose to orchestrate multiple containers with a single command.

Orchestrate with Docker Compose

In the last step, you discovered the core Docker insight. Containers are ephemeral, and volumes keep data alive. You fixed disappearing bookmarks with a bind mount.

But a JSON file is a brittle storage layer. Real applications use databases. And managing multiple containers with separate docker run commands gets unwieldy fast. Docker Compose lets you define your entire stack in a single file and launch everything with one command.

In this step, get ready to:
  • Create a compose.yaml file that defines your app and MongoDB services.
  • Rewrite server.js to use MongoDB for storing bookmarks.
  • Verify that bookmarks persist across full container restarts.
Create the Compose file

A compose.yaml file declares all the services your application needs. Instead of remembering long docker run flags, you write them once and bring everything up together.

  • Create a new file called compose.yaml in your project folder (the same folder as your Dockerfile) and paste the following:
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - MONGO_URL=mongodb://mongo:27017
    depends_on:
      - mongo

  mongo:
    image: mongo:8
    volumes:
      - mongo-data:/data/db

volumes:
  mongo-data:

What does this file do?

  • app builds from your Dockerfile, maps port 3000, and passes a MONGO_URL environment variable so your code knows where the database lives.
  • depends_on tells Docker to start the mongo service before the app service.
  • mongo pulls the official mongo:8 image and attaches a named volume mongo-data at /data/db so database files survive restarts.
  • The top-level volumes: section declares mongo-data as a named volume that Docker manages for you.
  • Save compose.yaml.
  • Confirm the file was created by running:
dir

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

Don't see compose.yaml?

Make sure you created the file in the same directory as your Dockerfile. Run cd to check your current directory and ensure you are in the project folder.

Help me troubleshoot my compose.yaml file location.

Upgrade server.js to use MongoDB

Your compose file references a MongoDB service, but your app still reads from a JSON file. You need to swap the file-based storage for a proper database connection.

  • Install the MongoDB driver by running:
npm install mongodb

You should see output confirming the package was added. Your package.json now includes mongodb in its dependencies.

Seeing a permissions or command not found error?

Make sure Node.js is installed on your machine and that you are in your project folder (the one containing package.json) before running the install command.

Help me fix my npm install error.

  • Open server.js in VS Code.
  • Select all the existing code (Ctrl+A) and delete it.
  • Paste the following setup and connection code:
const express = require('express');
const { MongoClient, ObjectId } = require('mongodb');

const app = express();
app.use(express.json());

const MONGO_URL = process.env.MONGO_URL || 'mongodb://mongo:27017';
const DB_NAME = 'bookmarks';

let db;

async function connectDB() {
  const client = new MongoClient(MONGO_URL);
  await client.connect();
  db = client.db(DB_NAME);
  console.log('Connected to MongoDB');
}

What does this code do?

  • MongoClient and ObjectId come from the mongodb package you just installed.
  • MONGO_URL reads from the environment variable your compose.yaml sets. The hostname mongo matches the service name in your Compose file. Docker networking resolves it automatically.
  • connectDB() establishes the database connection and stores it in db so your route handlers can use it.
  • Below the connectDB function, paste the route handlers and server startup:
app.get('/bookmarks', async (req, res) => {
  const bookmarks = await db.collection('bookmarks').find().toArray();
  res.json(bookmarks);
});

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 result = await db.collection('bookmarks').insertOne({
    url,
    title,
    createdAt: new Date()
  });
  res.status(201).json({ id: result.insertedId, url, title });
});

app.delete('/bookmarks/:id', async (req, res) => {
  const { id } = req.params;
  await db.collection('bookmarks').deleteOne({ _id: new ObjectId(id) });
  res.status(204).send();
});

connectDB().then(() => {
  app.listen(3000, () => {
    console.log('Bookmarks API running on http://localhost:3000');
  });
});

What changed in the routes?

  • Every route handler is now async because MongoDB operations return promises.
  • Instead of reading/writing a JSON file, each handler talks directly to the bookmarks collection in MongoDB.
  • connectDB().then() ensures the database connection is ready before the server starts listening for requests.
  • Save server.js.

✔️ Awesome, I've got everything!

Great. Double check you have saved both compose.yaml and server.js.

ⓧ I'd like to double check the full code

Your server.js should contain the two sections you pasted above, in this order:

  • The setup and connection block (imports, MONGO_URL, connectDB function).
  • The route handlers and server startup (GET, POST, DELETE routes followed by connectDB().then()).

Scroll up and confirm each block is present in order with no leftover code from the old file-based version.

Your compose.yaml should look exactly like this:

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - MONGO_URL=mongodb://mongo:27017
    depends_on:
      - mongo

  mongo:
    image: mongo:8
    volumes:
      - mongo-data:/data/db

volumes:
  mongo-data:
Launch and test persistence

Everything is wired up. One command now builds your app image, pulls the MongoDB image, creates a network connecting both containers, and starts them.

  • Start the full stack by running:
docker compose up -d

Docker builds your updated app image (with the new mongodb dependency), pulls the mongo:8 image, and starts both containers in the background.

What is Docker Compose doing behind the scenes?

  • It creates an isolated network so the two containers can talk to each other by service name.
  • The app container can reach MongoDB at mongodb://mongo:27017 because Docker resolves the service name mongo to the right container's IP.
  • The named volume mongo-data is created and mounted at /data/db inside the MongoDB container.
  • Add a bookmark to test the API by running:
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, including an id field assigned by MongoDB.

  • Confirm the bookmark was stored by running:
curl http://localhost:3000/bookmarks

You should see a JSON array containing the bookmark you just added.

Seeing a connection error or empty response?

  • Run docker compose logs app to check if your app container started successfully. Look for the line Connected to MongoDB.
  • If you see a Cannot find module 'mongodb' error, your image was cached before you ran npm install mongodb. Run docker compose down then docker compose up -d --build to force a rebuild.
  • Check that your server.js has no leftover code from the file-based version. The old fs and path imports should be gone.

Help me debug my Docker Compose setup.

Now for the real test. In the last step, removing a container destroyed your bookmarks. With a named volume backing MongoDB, your data should survive a full teardown and restart.

Before you run the next commands, predict: will your bookmark still be there after you destroy both containers and bring them back up?

  • Tear down both containers by running:
docker compose down
  • Bring everything back up by running:
docker compose up -d
  • Check if your bookmark survived by running:
curl http://localhost:3000/bookmarks

Your bookmark is still there. The named volume mongo-data kept MongoDB's data safe even though both containers were completely destroyed and recreated.

Why did this work when the JSON file approach failed?

In Step 3, your data lived inside the container's filesystem. Removing the container deleted the file. Here, the named volume mongo-data lives outside any container. Docker keeps it on disk until you explicitly delete it with docker compose down -v.

You now have a multi-container application running with a single command. Your Express API and MongoDB talk to each other over Docker's internal network, and a named volume guarantees your data outlives any container. Next up, you will harden this image for production in the Secret Mission.

Secret mission

Harden Your Image for Production

Your Docker image works, but it ships unnecessary files and runs as root. Real production images are lean and locked down. Can you fix both issues?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Docker Desktop, so there are no ongoing costs.

Resources you used:

  • Docker images on your local machine: bookmarks-api, mongo:8, hello-world.
  • Docker volume: mongo-data.
  • Local project folder containing your source code, Dockerfile, and Compose file.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your Bookmarks API.

  • Docker Desktop continues running in the background.
  • Your app and mongo containers stay up and accessible at http://localhost:3000.
  • The mongo-data volume keeps your bookmarks intact.

Pause - I'll come back to this later

Stop the containers to free up memory, but keep your images and volumes so you can resume quickly.

  • Stop and remove the running containers by running:
docker compose down
  • Your images and volumes remain on disk. When you want to resume, navigate to your project folder and run:
docker compose up -d

Everything comes back up with your data intact.

Delete - I don't want to use this again

Remove all project resources and start fresh. This frees disk space but cannot be undone.

  • Stop containers and remove the named volume by running:
docker compose down -v
  • Remove any leftover stopped containers (including the hello-world container from Step 1) by running:
docker container prune -f
  • Remove all unused Docker images from your machine by running:
docker image prune -a

What does this remove?

This removes the bookmarks-api, mongo:8, and hello-world images along with the mongo-data volume. Your bookmarks data will be permanently deleted.

  • Delete your local project folder by running:
cd %USERPROFILE%\Desktop
rmdir /s /q bookmarks-api

Your machine is now back to its pre-project state with only Docker Desktop remaining.

Nice Work!

Nice Work!

You just built a fully containerized Bookmarks API backed by MongoDB, all running inside Docker containers you configured from scratch.

Here's what you learned:

  • Built a Docker image from a Dockerfile and ran it as a container with port mapping.
  • Discovered that container filesystems are ephemeral and fixed it with volumes to persist data across restarts.
  • Orchestrated a multi-container application (Express + MongoDB) with Docker Compose using a single compose.yaml file.
  • Secret Mission: Hardened your image with a .dockerignore and a non-root user for production readiness.

Ready to quiz yourself?