Build a Production Docker Stack

Orchestrate NestJS, MongoDB, and Redis with multi-stage Docker builds.

Introduction

30 Second Summary

Modern apps rarely run alone. Your API needs a database for storage and a cache for speed, and running them all together reliably is what separates a side project from a production system.

In this project, you will build a containerized bookmarks API with three interconnected services orchestrated by Docker Compose. You will write a multi-stage Dockerfile that shrinks your image from ~1.2GB to under 200MB, wire up health checks and custom networks, and bring the entire stack online with a single command.

What You'll Build

A production-grade Docker stack running a NestJS API backed by MongoDB for persistence and Redis for caching.

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

  • A 3-stage Dockerfile that compiles TypeScript and produces a lean Alpine image under 200MB, running as a non-root user.
  • A Docker Compose stack orchestrating three services with health checks, custom networks, named volumes, and dependency ordering.
  • A working bookmarks API you can hit with curl to verify the full chain of request, cache, and database persistence.
  • Secret Mission: Add a development override with bind mounts, hot reload, and debug port exposure using compose.override.yaml.

Are there any prerequisites?

This project assumes you are comfortable with NestJS and TypeScript. You should also have basic Docker awareness (pulling images, running containers). The focus here is on Docker orchestration patterns, not on writing the application code.

Scaffold the NestJS App

The Docker stack you are building needs an application to containerize. Your API will store bookmarks in MongoDB and cache frequent lookups in Redis. All the Docker work in later steps depends on this app being fully functional first.

In this step, you will scaffold a NestJS project, install the MongoDB and Redis dependencies, then write the modules that make up the bookmarks API. Since you already know NestJS, the focus here is on wiring up environment-variable-driven config so the containerized app can connect to services by name.

In this step, get ready to:
  • Scaffold a NestJS project and install database and caching packages.
  • Write a bookmarks module with Mongoose persistence and Redis caching.
  • Configure the app module to read connection strings from environment variables.
Scaffold the project and install dependencies

The NestJS CLI generates a complete project structure with TypeScript compilation, testing config, and a running server. The flags you will pass skip git initialization (Docker will handle version control context) and lock in npm as the package manager so the CLI runs non-interactively.

  • Scaffold a new NestJS project by running this command:
npx @nestjs/cli new bookmarks-api --strict --skip-git --package-manager npm

What does this command do?

  • npx @nestjs/cli new downloads the CLI temporarily and scaffolds a project named bookmarks-api.
  • --strict enables strict TypeScript compiler flags like strictNullChecks and noImplicitAny.
  • --skip-git prevents creating a .git folder inside the project.
  • --package-manager npm skips the interactive prompt asking which package manager to use.
  • Wait for the scaffold to finish. You should see a success message and a new bookmarks-api folder in your current directory.
  • Move into the new project folder by running:
cd bookmarks-api
  • Install the MongoDB, Redis, and configuration packages by running:
npm install @nestjs/mongoose mongoose @nestjs/cache-manager cache-manager cache-manager-redis-store ioredis @nestjs/config

What are these packages for?

  • @nestjs/mongoose and mongoose provide the MongoDB ODM integration for NestJS.
  • @nestjs/cache-manager and cache-manager provide a unified caching abstraction.
  • cache-manager-redis-store and ioredis connect that caching layer to a Redis server.
  • @nestjs/config reads environment variables (like MONGO_URI) so the container can be configured externally.
  • Confirm the install succeeded. You should see added X packages with no errors in the terminal output.

Seeing permission errors or EACCES?

Make sure you ran cd bookmarks-api before the install command. Running npm install in the wrong directory creates a stray node_modules in an unexpected location.

Help me fix npm permission errors

Write the bookmarks module

The bookmarks module is the core of the API. It defines how bookmarks are stored in MongoDB and how GET requests are cached in Redis to reduce database load.

  • Create the bookmarks directory inside src by running:
mkdir src/bookmarks
  • Create a new file at src/bookmarks/bookmark.schema.ts and paste the following code:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

// Define the bookmark document shape for MongoDB
@Schema({ timestamps: true })
export class Bookmark extends Document {
  @Prop({ required: true })
  title: string;

  @Prop({ required: true })
  url: string;
}

// Generate the Mongoose schema from the class definition
export const BookmarkSchema = SchemaFactory.createForClass(Bookmark);

What does this code do?

This defines a Mongoose schema for bookmarks with two required fields: title and url. The timestamps: true option automatically adds createdAt and updatedAt fields to every document.

  • Save bookmark.schema.ts.
  • Create a new file at src/bookmarks/bookmarks.service.ts and paste the following code:
import { Injectable, Inject } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Bookmark } from './bookmark.schema';

@Injectable()
export class BookmarksService {
  constructor(
    @InjectModel(Bookmark.name) private bookmarkModel: Model<Bookmark>,
    @Inject(CACHE_MANAGER) private cacheManager: Cache,
  ) {}

  // Create a new bookmark and invalidate the cache
  async create(title: string, url: string): Promise<Bookmark> {
    const bookmark = new this.bookmarkModel({ title, url });
    const saved = await bookmark.save();
    await this.cacheManager.del('all-bookmarks');
    return saved;
  }

  // Return all bookmarks, serving from Redis cache when available
  async findAll(): Promise<Bookmark[]> {
    const cached = await this.cacheManager.get<Bookmark[]>('all-bookmarks');
    if (cached) {
      return cached;
    }
    const bookmarks = await this.bookmarkModel.find().exec();
    await this.cacheManager.set('all-bookmarks', bookmarks);
    return bookmarks;
  }
}

What does this code do?

The service uses two injected dependencies: a Mongoose model for MongoDB persistence and the NestJS cache manager backed by Redis.

On create, it saves to MongoDB then invalidates the cache so stale data is never served. On findAll, it checks Redis first. If the cache is empty, it fetches from MongoDB and stores the result for 60 seconds.

  • Save bookmarks.service.ts.
  • Create a new file at src/bookmarks/bookmarks.controller.ts and paste the following code:
import { Controller, Get, Post, Body } from '@nestjs/common';
import { BookmarksService } from './bookmarks.service';

// Expose CRUD endpoints at /bookmarks
@Controller('bookmarks')
export class BookmarksController {
  constructor(private readonly bookmarksService: BookmarksService) {}

  @Post()
  create(@Body() body: { title: string; url: string }) {
    return this.bookmarksService.create(body.title, body.url);
  }

  @Get()
  findAll() {
    return this.bookmarksService.findAll();
  }
}

What does this code do?

The controller maps HTTP requests to service methods. POST /bookmarks creates a new bookmark. GET /bookmarks returns all bookmarks (from cache or database). You will test these endpoints with curl once the Docker stack is running.

  • Save bookmarks.controller.ts.
  • Create a new file at src/bookmarks/bookmarks.module.ts and paste the following code:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { CacheModule } from '@nestjs/cache-manager';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { redisStore } from 'cache-manager-redis-store';
import { BookmarksController } from './bookmarks.controller';
import { BookmarksService } from './bookmarks.service';
import { Bookmark, BookmarkSchema } from './bookmark.schema';

@Module({
  imports: [
    // Register the Bookmark schema with Mongoose
    MongooseModule.forFeature([{ name: Bookmark.name, schema: BookmarkSchema }]),
    // Configure the cache to use Redis, reading host and port from env vars
    CacheModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (config: ConfigService) => ({
        store: redisStore as any,
        host: config.get<string>('REDIS_HOST'),
        port: config.get<number>('REDIS_PORT'),
        ttl: 60,
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [BookmarksController],
  providers: [BookmarksService],
})
export class BookmarksModule {}

Why read Redis config from environment variables?

Inside a Docker Compose stack, each service is reachable by its service name (e.g. redis). By reading REDIS_HOST and REDIS_PORT from the environment, the same image works locally, in CI, and in production without code changes.

  • Save bookmarks.module.ts.
  • Verify the module compiles by running:
npx nest build
  • You should see no errors in the output. If TypeScript reports missing imports, double check that all four files exist in src/bookmarks/.

Seeing a compile error?

If you see an error about AppController or AppService, that is expected. The scaffold created those files, and you will replace the app module in the next substep. For now, a warning about unused files is fine.

Help me fix NestJS build errors

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved in src/bookmarks/.

ⓧ I'd like to double check the full code

Here is the complete code for all four bookmarks module files:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

@Schema({ timestamps: true })
export class Bookmark extends Document {
  @Prop({ required: true })
  title: string;

  @Prop({ required: true })
  url: string;
}

export const BookmarkSchema = SchemaFactory.createForClass(Bookmark);
import { Injectable, Inject } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Bookmark } from './bookmark.schema';

@Injectable()
export class BookmarksService {
  constructor(
    @InjectModel(Bookmark.name) private bookmarkModel: Model<Bookmark>,
    @Inject(CACHE_MANAGER) private cacheManager: Cache,
  ) {}

  async create(title: string, url: string): Promise<Bookmark> {
    const bookmark = new this.bookmarkModel({ title, url });
    const saved = await bookmark.save();
    await this.cacheManager.del('all-bookmarks');
    return saved;
  }

  async findAll(): Promise<Bookmark[]> {
    const cached = await this.cacheManager.get<Bookmark[]>('all-bookmarks');
    if (cached) {
      return cached;
    }
    const bookmarks = await this.bookmarkModel.find().exec();
    await this.cacheManager.set('all-bookmarks', bookmarks);
    return bookmarks;
  }
}
import { Controller, Get, Post, Body } from '@nestjs/common';
import { BookmarksService } from './bookmarks.service';

@Controller('bookmarks')
export class BookmarksController {
  constructor(private readonly bookmarksService: BookmarksService) {}

  @Post()
  create(@Body() body: { title: string; url: string }) {
    return this.bookmarksService.create(body.title, body.url);
  }

  @Get()
  findAll() {
    return this.bookmarksService.findAll();
  }
}
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { CacheModule } from '@nestjs/cache-manager';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { redisStore } from 'cache-manager-redis-store';
import { BookmarksController } from './bookmarks.controller';
import { BookmarksService } from './bookmarks.service';
import { Bookmark, BookmarkSchema } from './bookmark.schema';

@Module({
  imports: [
    MongooseModule.forFeature([{ name: Bookmark.name, schema: BookmarkSchema }]),
    CacheModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (config: ConfigService) => ({
        store: redisStore as any,
        host: config.get<string>('REDIS_HOST'),
        port: config.get<number>('REDIS_PORT'),
        ttl: 60,
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [BookmarksController],
  providers: [BookmarksService],
})
export class BookmarksModule {}
Configure the app module with environment variables

The app module is the root of the NestJS dependency graph. You need to replace the scaffolded version with one that wires up MongoDB via environment variables and registers a health endpoint that Docker will use for container health checks.

  • Create the health module directory by running:
mkdir src/health
  • Create a new file at src/health/health.controller.ts and paste the following code:
import { Controller, Get } from '@nestjs/common';

// Simple health endpoint for Docker health checks
@Controller('health')
export class HealthController {
  @Get()
  check() {
    return { status: 'ok' };
  }
}

Why do you need a health endpoint?

Docker health checks poll an HTTP endpoint to decide if the container is alive. Without one, Docker has no way to know whether your API crashed internally. In Step 3, you will configure Docker Compose to hit GET /health every 30 seconds.

  • Save health.controller.ts.
  • Create a new file at src/health/health.module.ts and paste the following code:
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';

@Module({
  controllers: [HealthController],
})
export class HealthModule {}
  • Save health.module.ts.
  • Delete the scaffolded controller and service files you no longer need by running:
rm src/app.controller.ts src/app.controller.spec.ts src/app.service.ts
  • Open src/app.module.ts and replace its entire contents with the following code:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { BookmarksModule } from './bookmarks/bookmarks.module';
import { HealthModule } from './health/health.module';

@Module({
  imports: [
    // Load .env and make ConfigService available globally
    ConfigModule.forRoot({ isGlobal: true }),
    // Connect to MongoDB using the URI from environment variables
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('MONGO_URI'),
      }),
      inject: [ConfigService],
    }),
    BookmarksModule,
    HealthModule,
  ],
})
export class AppModule {}

What does this code do?

ConfigModule.forRoot({ isGlobal: true }) reads from a .env file (or container environment) and exposes ConfigService everywhere in the app without re-importing.

MongooseModule.forRootAsync waits for ConfigService to load, then connects to MongoDB using the MONGO_URI value. In Docker Compose, that URI will point to the mongo service by hostname.

  • Save app.module.ts.
  • Verify the full project compiles cleanly by running:
npx nest build
  • You should see no errors. The compiled output lands in the dist/ folder. This is the folder your Docker image will copy into production.

Still seeing compile errors?

Make sure you deleted app.controller.ts, app.controller.spec.ts, and app.service.ts. If the old app.module.ts still imports them, the build will fail with a missing-file error.

Help me fix my NestJS build

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and that npx nest build completes without errors.

ⓧ I'd like to double check the full code

Here is the complete code for the health module, app module, and main entry point:

import { Controller, Get } from '@nestjs/common';

@Controller('health')
export class HealthController {
  @Get()
  check() {
    return { status: 'ok' };
  }
}
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';

@Module({
  controllers: [HealthController],
})
export class HealthModule {}
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { BookmarksModule } from './bookmarks/bookmarks.module';
import { HealthModule } from './health/health.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('MONGO_URI'),
      }),
      inject: [ConfigService],
    }),
    BookmarksModule,
    HealthModule,
  ],
})
export class AppModule {}
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

Your NestJS app compiles cleanly and is ready to be containerized. Next up, you will write a multi-stage Dockerfile that turns this app into a lean production image.

Write the Multi-Stage Dockerfile

Your NestJS app is scaffolded and ready to go. The next challenge is packaging it into a container that's small, secure, and production-ready.

A naive Dockerfile that installs everything in one stage produces an image over 1 GB. In this step, you'll write a multi-stage Dockerfile that separates dependency installation, TypeScript compilation, and the final runtime into three distinct stages. The result is a lean Alpine-based image under 200 MB.

In this step, get ready to:
  • Create a .dockerignore to minimize build context.
  • Write a 3-stage Dockerfile that compiles TypeScript and produces a production image.
  • Build the image and verify it's under 200 MB.
Create a .dockerignore file

Every time you run docker build, Docker sends the entire project folder (the "build context") to the build engine. Files like node_modules and dist are unnecessary because the Dockerfile recreates them inside the container.

  • Create a new file called .dockerignore in the root of your bookmarks-api folder, then add the following content:
node_modules
dist
.git
*.log
*.md
.env

What does this file do?

Each line tells Docker to skip matching files when sending the build context.

  • node_modules is excluded because the Dockerfile runs its own npm ci to install fresh dependencies inside the container.
  • dist is excluded because the build stage compiles TypeScript from scratch.
  • .env is excluded to prevent secrets from being baked into the image layer history.
  • Save .dockerignore.
  • Confirm the file exists by running:
ls -a .dockerignore

You should see .dockerignore printed in the output.

Write the multi-stage Dockerfile

A multi-stage build uses multiple FROM instructions in a single Dockerfile. Each stage can copy artifacts from previous stages, but only the final stage ends up in the shipped image. This is how you drop build tools, dev dependencies, and source files from the production container.

  • Create a new file called Dockerfile (no extension) in the root of your bookmarks-api folder, then add Stage 1:
# Stage 1: Install production dependencies
FROM node:22-alpine AS deps
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

What does Stage 1 do?

  • FROM node:22-alpine AS deps starts a fresh Alpine-based Node.js image and names this stage deps so later stages can reference it.
  • npm ci --omit=dev installs only production dependencies. Dev tools like the NestJS CLI and TypeScript compiler are excluded from this stage entirely.
  • Add Stage 2 below Stage 1 in the same Dockerfile:
# Stage 2: Build the application
FROM node:22-alpine AS build
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
COPY src ./src
RUN npm run build

What does Stage 2 do?

  • RUN npm ci installs ALL dependencies (including dev) because this stage needs TypeScript and the NestJS compiler to build the app.
  • Only the config files and src folder are copied in. No node_modules from the host, keeping the build reproducible.
  • RUN npm run build compiles TypeScript to JavaScript, outputting to dist/.
  • Add Stage 3 below Stage 2 in the same Dockerfile:
# Stage 3: Production image
FROM node:22-alpine AS production
WORKDIR /usr/src/app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nestapp && \
    adduser --system --uid 1001 nestapp

COPY --from=deps --chown=nestapp:nestapp /usr/src/app/node_modules ./node_modules
COPY --from=build --chown=nestapp:nestapp /usr/src/app/dist ./dist
COPY --from=build --chown=nestapp:nestapp /usr/src/app/package.json ./package.json

USER nestapp
EXPOSE 3000

CMD ["node", "dist/main"]

What does Stage 3 do?

  • This is the only stage that ships. It starts from a clean node:22-alpine base with no leftover build tools.
  • RUN addgroup ... && adduser ... creates a non-root user called nestapp. Running as non-root is a security best practice that limits damage if the container is compromised.
  • COPY --from=deps and COPY --from=build pull only the production node_modules and compiled dist/ from earlier stages. The TypeScript source and dev dependencies are left behind.
  • USER nestapp switches to the non-root user before the CMD runs.
  • Save Dockerfile.

Seeing a syntax error in Dockerfile?

  • Make sure the file is named exactly Dockerfile with a capital D and no file extension.
  • Check that the backslash \ in the addgroup line has no trailing spaces after it.

Ask for help: My Dockerfile has a syntax error, how do I fix it?

✔️ Awesome, I've got everything!

Great. Double check you've saved your Dockerfile.

ⓧ I'd like to double check the full code

# Stage 1: Install production dependencies
FROM node:22-alpine AS deps
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# Stage 2: Build the application
FROM node:22-alpine AS build
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
COPY src ./src
RUN npm run build

# Stage 3: Production image
FROM node:22-alpine AS production
WORKDIR /usr/src/app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nestapp && \
    adduser --system --uid 1001 nestapp

COPY --from=deps --chown=nestapp:nestapp /usr/src/app/node_modules ./node_modules
COPY --from=build --chown=nestapp:nestapp /usr/src/app/dist ./dist
COPY --from=build --chown=nestapp:nestapp /usr/src/app/package.json ./package.json

USER nestapp
EXPOSE 3000

CMD ["node", "dist/main"]
Build the image and verify the size

With the Dockerfile written, it's time to build the image and see the size savings in action. Docker will execute each stage in order and tag the final production stage.

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

What does this command do?

  • docker build triggers the multi-stage build process.
  • -t bookmarks-api tags (names) the resulting image so you can reference it later.
  • . tells Docker to use the current directory as the build context (filtered by .dockerignore).

The build takes 30-60 seconds on first run while it downloads the node:22-alpine base image and installs dependencies.

  • Verify the final image size by running:
docker images bookmarks-api

You should see a single row showing bookmarks-api with a size under 200 MB. A naive single-stage build of the same app would be over 1.2 GB.

Why is the image so much smaller?

The production stage only contains the Alpine base (~50 MB), production node_modules, and the compiled dist/ folder. TypeScript, the NestJS CLI, all dev dependencies, and even the source .ts files are left behind in earlier stages that don't ship.

Build failing with an error?

  • If you see a "file not found" error for package-lock.json, make sure you ran npm install in Step 1. The lock file must exist locally for COPY to find it.
  • If you see a permission error, make sure Docker Desktop is running.

Ask for help: My Docker build is failing, how do I fix it?

Your production image is built and lean. Next up, you'll write the Docker Compose stack that orchestrates this image alongside MongoDB and Redis into a fully connected multi-service architecture.

Write the Docker Compose Stack

You have a lean production image for the NestJS app. Running one container in isolation is useful, but your bookmarks API needs MongoDB for storage and Redis for caching.

In this step, you will write a Docker Compose file that orchestrates all three services. A single command will start the API, database, and cache together with health-checked startup ordering.

In this step, get ready to:
  • Create a compose.yaml defining three services with health checks.
  • Configure custom networks and a persistent named volume.
  • Externalize configuration into a .env file.
Create the compose.yaml file

The Compose file declares every service, network, and volume your stack needs. Docker Compose reads this file and creates the entire environment in one shot.

  • Create a new file called compose.yaml in the root of your bookmarks-api folder (at the same level as your Dockerfile).
  • Paste the following content into compose.yaml:
services:
  api:
    build:
      context: .
      target: production
    ports:
      - "3000:3000"
    env_file:
      - .env
    networks:
      - backend
    depends_on:
      mongo:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget --spider --quiet http://localhost:3000/health || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    restart: unless-stopped

  mongo:
    image: mongo:8.0
    volumes:
      - mongo-data:/data/db
    networks:
      - backend
      - data
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.runCommand('ping').ok", "--quiet"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s
    restart: unless-stopped

  redis:
    image: redis:7.4-alpine
    networks:
      - backend
      - data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

networks:
  backend:
    driver: bridge
  data:
    driver: bridge

volumes:
  mongo-data:

What does this file do?

This Compose file defines three services that work together:

  • api builds from your Dockerfile (targeting the production stage), maps port 3000, and waits for both dependencies to be healthy before starting.
  • mongo uses the official mongo:8.0 image and mounts a named volume so your data survives container restarts.
  • redis uses the lightweight redis:7.4-alpine image as an in-memory cache.

Each service has a healthcheck that Compose uses to determine when the service is truly ready to accept connections. The depends_on with condition: service_healthy ensures the API only starts once MongoDB and Redis report healthy.

  • Save compose.yaml.

Seeing a YAML syntax error?

YAML is whitespace-sensitive. Make sure you are using spaces (not tabs) for indentation. Each nested level uses exactly two spaces.

Also check that the test arrays in each healthcheck use straight double quotes, not curly quotes from a word processor.

help me fix a YAML syntax error in my compose.yaml

✔️ Awesome, I've got everything!

Great. Double check you have saved compose.yaml before moving on.

ⓧ I'd like to double check the full code

services:
  api:
    build:
      context: .
      target: production
    ports:
      - "3000:3000"
    env_file:
      - .env
    networks:
      - backend
    depends_on:
      mongo:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget --spider --quiet http://localhost:3000/health || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    restart: unless-stopped

  mongo:
    image: mongo:8.0
    volumes:
      - mongo-data:/data/db
    networks:
      - backend
      - data
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.runCommand('ping').ok", "--quiet"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s
    restart: unless-stopped

  redis:
    image: redis:7.4-alpine
    networks:
      - backend
      - data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

networks:
  backend:
    driver: bridge
  data:
    driver: bridge

volumes:
  mongo-data:
Understand the network and volume architecture

Your compose.yaml defines two custom networks and a named volume. These are the infrastructure patterns that separate a production-grade stack from a toy setup.

Why two networks instead of one?

The backend network connects all three services so the API can reach MongoDB and Redis by their service names. The data network connects only mongo and redis for potential internal communication between data stores.

In a larger stack, this pattern prevents a public-facing reverse proxy from having direct network access to the database. Each service only sees the services it needs to talk to.

What is a named volume?

The mongo-data volume is mounted at /data/db inside the MongoDB container. Unlike a bind mount that maps a host folder, a named volume is managed entirely by Docker.

When you run docker compose down and then docker compose up again, the named volume persists. Your bookmarks survive container recreation. Only docker compose down -v deletes it.

How does depends_on with service_healthy work?

Without condition: service_healthy, Compose only waits for the container process to start. The process might be running, but the database might not be accepting connections yet.

With condition: service_healthy, Compose waits until the health check passes. This means MongoDB is actually accepting queries and Redis is responding to ping before the API attempts to connect.

Create the environment file

Your compose.yaml references env_file: - .env on the API service. This injects environment variables into the container at runtime without hardcoding values in the Compose file itself.

  • Create a new file called .env in the root of your bookmarks-api folder.
  • Paste the following content into .env:
MONGO_URI=mongodb://mongo:27017/bookmarks
REDIS_HOST=redis
REDIS_PORT=6379

What does this file do?

MONGO_URI uses mongo as the hostname. Docker Compose's built-in DNS resolves this to the MongoDB container's IP on the backend network.

REDIS_HOST and REDIS_PORT work the same way. The NestJS app reads these via ConfigService (configured in src/app.module.ts and src/bookmarks/bookmarks.module.ts).

  • Save .env.
  • Confirm both new files exist by running:
ls compose.yaml .env

You should see both compose.yaml and .env listed in the output.

Why externalize configuration?

The same Docker image can connect to different databases in staging vs production just by swapping the .env file. This is the "one image, many environments" pattern. Your code never changes between environments.

Don't see .env in the file list?

Files starting with a dot are hidden by default in most file browsers. Use ls -a to show hidden files. Make sure you created the file in the bookmarks-api root folder, not inside src/.

help me find or create my .env file in the right location

✔️ Awesome, I've got everything!

Great. Double check you have saved .env before moving on.

ⓧ I'd like to double check the full code

MONGO_URI=mongodb://mongo:27017/bookmarks
REDIS_HOST=redis
REDIS_PORT=6379

Your orchestration layer is complete. Next up, you will bring the entire stack online and verify that all three services communicate correctly.

Bring Up the Stack and Verify Connectivity

You have all the pieces in place. The Dockerfile compiles your app in three stages, compose.yaml wires the services together, and the .env file externalizes configuration.

Now it is time to start the full stack and prove that everything connects. You will bring up all three services, hit the API with real requests, and verify that the senior-level patterns you configured (non-root execution, named volumes, network segmentation) are actually working.

In this step, get ready to:
  • Build and start the full Docker Compose stack.
  • Test the bookmarks API to confirm MongoDB persistence and Redis caching.
  • Verify non-root execution, named volumes, custom networks, and data persistence.
Build and start all services

Docker Compose reads your compose.yaml and orchestrates every service in the correct order. The --build flag tells Compose to build your Dockerfile image fresh before starting containers.

  • Build all images and start the stack in detached mode by running this command:
docker compose up --build -d

What does this command do?

The --build flag forces a fresh image build from your Dockerfile. The -d flag (detached) runs containers in the background so your terminal stays free.

Because your compose.yaml uses depends_on with condition: service_healthy, Docker starts MongoDB and Redis first, waits for their health checks to pass, then starts the API.

The build process takes a minute or two on first run as Docker pulls the base images and installs dependencies.

  • Watch for all services to become healthy by running:
docker compose ps
  • Wait about 30 seconds, then run the command again. All three services should show (healthy) in the STATUS column.

Services not showing healthy?

If a service shows (health: starting), wait another 20 seconds and run docker compose ps again. MongoDB has a start_period of 20 seconds.

If a service shows (unhealthy), check its logs by running docker compose logs <service-name> (replace with api, mongo, or redis).

help me debug unhealthy containers in my Docker Compose stack

Test the full request chain

Healthy containers do not guarantee that the application logic works end-to-end. You need to send real HTTP requests to confirm that the API writes to MongoDB and caches responses in Redis.

  • Create a bookmark by sending a POST request:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"title":"Docker Docs","url":"https://docs.docker.com"}'

What does this command do?

This sends a JSON payload to the /bookmarks endpoint. The NestJS controller passes the title and URL to the service, which writes a new document to MongoDB and invalidates the Redis cache.

  • You should see a JSON response containing your bookmark with an _id field, a title, a url, and timestamps. This confirms MongoDB accepted the write.
  • Retrieve all bookmarks to verify the GET path and Redis caching by running:
curl http://localhost:3000/bookmarks
  • You should see a JSON array containing the bookmark you just created.

How does the caching work?

The first GET request fetches from MongoDB and stores the result in Redis with a 60-second TTL. Subsequent GET requests within that window return the cached value directly from Redis, skipping the database entirely.

Seeing connection refused?

If curl returns a connection error, the API container might not be ready yet. Run docker compose ps and confirm the api service is healthy before retrying.

If you see a 500 error, check the API logs with docker compose logs api for MongoDB or Redis connection failures.

help me debug my NestJS API returning errors in Docker Compose

Verify Docker concepts are working

Running containers is one thing. Proving that the security, networking, and persistence patterns are active is what separates a production setup from a tutorial.

  • Confirm the API runs as a non-root user by running:
docker compose exec api id
  • You should see uid=1001 in the output. This confirms your container is not running as root, which limits the damage if the process is ever compromised.
  • Verify your named volume exists by running:
docker volume ls
  • Look for a volume ending in _mongo-data in the list. This is the named volume keeping MongoDB data safe outside the container filesystem.
  • Verify your custom networks exist by running:
docker network ls
  • Look for networks ending in _backend and _data. These are the isolated bridge networks separating your services by concern.

Why does network segmentation matter?

In a production stack, not every service should talk to every other service. The backend network connects the API to both databases. The data network connects only the databases to each other.

If you later add a reverse proxy, it would join only the backend network. It could reach the API but never contact the databases directly.

The final test proves that your named volume survives container destruction. This is the difference between a named volume and data stored inside the container.

  • Tear down all containers by running:
docker compose down
  • Bring the stack back up (no --build needed since images already exist) by running:
docker compose up -d
  • Wait about 30 seconds for health checks to pass, then fetch your bookmarks again:
curl http://localhost:3000/bookmarks
  • You should see the same bookmark you created earlier. The data survived because MongoDB writes to the mongo-data named volume, which persists independently of the container lifecycle.

Bookmark gone after restart?

If the GET returns an empty array, check that you ran docker compose down without the -v flag. The -v flag deletes named volumes, which destroys your persisted data.

help me understand why my data disappeared after restarting Docker Compose

Secret mission

Add Development Override with Hot Reload

Your production stack is locked down, but what about development? Real teams need a way to iterate quickly without rebuilding the image on every code change. Create a compose.override.yaml that gives you hot reload, bind mounts, and debug port access while leaving your production Dockerfile untouched.

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, so there are no ongoing costs. Containers consume memory while running but do not incur any cloud charges.

Resources you used:

  • Locally built Docker images (production and development stages of bookmarks-api).
  • Named volume mongo-data storing MongoDB data.
  • Custom Docker networks backend and data.
  • The bookmarks-api project folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with the stack.

  • Your containers keep running and consuming memory, but there is no external cost.
  • Bookmark data persists in the mongo-data volume.
  • If Docker Desktop restarts, services resume automatically thanks to the restart: unless-stopped policy.

Pause - I'll come back to this later

Shut down running containers to free up memory. Your data and images stay intact so you can resume later.

  • Stop all containers by running:
docker compose stop
  • Your containers, images, networks, and the mongo-data volume all remain on disk.
  • When you're ready to resume, run docker compose start from the bookmarks-api folder to bring everything back up.

Delete - I don't want to use this again

Remove all project resources and start fresh. Nothing persists after these steps.

  • Make sure you're in the bookmarks-api folder, then remove containers, the named volume, custom networks, and locally-built images by running:
docker compose down -v --rmi local

What does this command do?

  • -v removes named volumes (including mongo-data and all stored bookmark data).
  • --rmi local removes images built by the project (your bookmarks-api image). Pulled images like mongo:8.0 and redis:7.4-alpine remain unless you remove them separately.
  • Optionally remove the pulled base images by running:
docker image rm mongo:8.0 redis:7.4-alpine node:22-alpine
  • Delete the project folder by running:
cd ~/Desktop
rm -rf bookmarks-api

On Windows, use this command instead:

cd ~\Desktop
Remove-Item -Recurse -Force bookmarks-api

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade Docker stack that orchestrates three services with the networking, security, and persistence patterns expected of a senior engineer.

You've learned how to:

  • Build a multi-stage Dockerfile that compiles TypeScript and produces a lean Alpine image running as a non-root user.
  • Orchestrate a 3-service stack (NestJS + MongoDB + Redis) with Docker Compose, including health checks and dependency ordering.
  • Configure custom networks isolating services by concern, a named volume ensuring data survives container recreation, and externalized configuration with .env injection.
  • Secret Mission: Separated dev and production workflows using compose.override.yaml, enabling hot-reload development without modifying the production Dockerfile.

Ready to quiz yourself?