Dockerize a TypeScript API with Nginx

Containerize a TypeScript API and load-balance it across three containers with Nginx.

Introduction

30 Second Summary

Think about what happens when your favorite website suddenly gets flooded with visitors. If the whole site is running on one server, that server crashes and everyone gets an error page.

In this project, you will build a bookmarks API in TypeScript, package it into a Docker container, and place Nginx in front as a reverse proxy. You will scale to three containers and prove the setup is resilient by stopping one mid-flight and watching the API keep responding.

What You'll Build

You curl a single URL six times and each response names a different container, showing round-robin load balancing in action.

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

  • A TypeScript Express API you can curl for JSON responses that name which container served them, making load distribution visible in real time.
  • A lean Docker production image built from a multi-stage Dockerfile, where TypeScript source and dev tools are left behind so only compiled JavaScript ships.
  • Three API containers behind an Nginx load balancer orchestrated by Docker Compose, where you can stop any one container and the API keeps responding through the others.
  • Secret Mission: an optional challenge to push your skills further.

Before We Start

Before We Start...

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

Build the Bookmarks API

Before you can containerize anything with Docker, you need an application to containerize. In this step, you'll build a REST API from scratch using TypeScript and Express.

Every response from this API will include the machine's hostname. This seems like a small detail now, but it becomes critical later when three containers are running behind a load balancer and you need to prove that requests are being distributed across them.

In this step, get ready to:
  • Create the project folder and install Express with TypeScript dependencies.
  • Write a bookmarks API with health check, GET, and POST endpoints.
  • Compile and run the API locally to confirm it works.
Create the project and install dependencies

You need a project folder with Node.js dependencies installed before you can write any code. This includes Express for the web server and TypeScript for type-safe development.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, then type Terminal (macOS) or PowerShell (Windows) and press Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, move into it, and initialize npm by running these commands:
mkdir docker-nginx-loadbalancer
cd docker-nginx-loadbalancer
npm init -y

You should see a JSON summary of your new package.json printed in the terminal.

Don't see the JSON output?

Make sure you're inside the docker-nginx-loadbalancer folder. Run pwd (macOS/Linux) or Get-Location (Windows) to confirm your current directory ends with docker-nginx-loadbalancer.

Help me troubleshoot npm init

  • Install Express and the TypeScript tooling by running these commands:
npm install express@5.2.1
npm install --save-dev typescript@7.0.2 @types/express@5.0.6 @types/node@26.1.2

You should see added followed by a package count in the terminal output for each command.

What did you just install?

  • express is the web framework that handles HTTP requests and responses.
  • typescript is the compiler that converts your TypeScript code into JavaScript that Node.js can run.
  • @types/express and @types/node provide type definitions so TypeScript understands Express and Node.js APIs.

Install failed or timed out?

Check your internet connection and try the command again. If you see permission errors, try running your terminal as administrator (Windows) or prefix the command with sudo (macOS/Linux).

Help me fix my npm install error

  • Open your code editor. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, then type VS Code or Cursor (whichever you use) and press Enter.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the docker-nginx-loadbalancer folder on your Desktop, then click Open.

You should see package.json, package-lock.json, and a node_modules folder in your editor's file explorer sidebar.

The npm init command created a basic package.json with default values. You need to update it with the correct project metadata and build scripts.

  • Open package.json in your editor by clicking it in the sidebar.
  • Replace the entire file content with the following:
{
  "name": "docker-nginx-loadbalancer",
  "version": "1.0.0",
  "description": "Dockerized TypeScript API with Nginx reverse proxy and load balancing",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "express": "5.2.1"
  },
  "devDependencies": {
    "@types/express": "5.0.6",
    "@types/node": "26.1.2",
    "typescript": "7.0.2"
  }
}

What changed in package.json?

  • main points to dist/index.js because TypeScript compiles into a dist/ folder.
  • The build script runs the TypeScript compiler (tsc), and the start script runs the compiled JavaScript.
  • Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Next, you need a TypeScript configuration file that tells the compiler how to process your code.

  • Create a new file called tsconfig.json in the root of your docker-nginx-loadbalancer folder. Right-click in the sidebar and select New File.
  • Add the following configuration to tsconfig.json:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": false,
    "sourceMap": false
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

What does this configuration do?

  • outDir sends compiled JavaScript to a dist/ folder, keeping source and output separate.
  • rootDir tells the compiler to look for TypeScript files in src/.
  • esModuleInterop lets you use import express from "express" syntax instead of the older require() style.
  • Save tsconfig.json.
  • Confirm your project structure by switching back to your terminal and running:
ls

You should see node_modules, package-lock.json, package.json, and tsconfig.json listed in the output.

Missing tsconfig.json or package.json?

Make sure you saved both files in the editor. Check that tsconfig.json is in the docker-nginx-loadbalancer folder, not accidentally inside node_modules.

Help me troubleshoot my project setup

Write the bookmarks API

Your project skeleton is ready. Now you'll write the actual API code that serves bookmark data and tags every response with the machine's hostname using os.hostname().

  • Create the src directory by running this in your terminal:
mkdir src

You should see the src folder appear in your editor's file explorer sidebar.

  • Create a new file called index.ts inside the src folder. Right-click the src folder in the sidebar and select New File.
  • Add the following imports, app setup, and seed data to src/index.ts:
import express from "express";
import os from "os";

// Create the Express app and set the port
const app = express();
const PORT = 3000;

// Parse incoming JSON request bodies
app.use(express.json());

// Define the shape of a bookmark
interface Bookmark {
  id: number;
  title: string;
  url: string;
}

// Seed bookmarks so the API returns data right away
const bookmarks: Bookmark[] = [
  { id: 1, title: "TypeScript Docs", url: "https://www.typescriptlang.org" },
  { id: 2, title: "Docker Docs", url: "https://docs.docker.com" },
  { id: 3, title: "Nginx Docs", url: "https://nginx.org" },
];

What does this code do?

  • express handles HTTP requests. os gives you access to the machine's hostname, which is the key to visualizing load balancing later.
  • The Bookmark interface enforces the shape of each bookmark object so TypeScript catches mistakes at compile time.
  • The bookmarks array provides three seed entries so the API has data to return immediately.
  • Add the following three GET endpoints below the seed data in src/index.ts:
// Health check endpoint for container monitoring
app.get("/health", (_req, res) => {
  res.json({
    status: "healthy",
    hostname: os.hostname(),
    uptime: process.uptime(),
  });
});

// Return all bookmarks
app.get("/api/bookmarks", (_req, res) => {
  res.json({
    data: bookmarks,
    servedBy: os.hostname(),
  });
});

// Return a single bookmark by ID
app.get("/api/bookmarks/:id", (req, res) => {
  const bookmark = bookmarks.find((b) => b.id === parseInt(req.params.id));
  if (!bookmark) {
    res.status(404).json({ error: "Bookmark not found" });
    return;
  }
  res.json({
    data: bookmark,
    servedBy: os.hostname(),
  });
});

What do these endpoints do?

  • /health reports whether the server is alive. Docker will use this endpoint later to monitor container health.
  • /api/bookmarks returns the full list of bookmarks.
  • /api/bookmarks/:id looks up a single bookmark. If the ID does not exist, it returns a 404 error.
  • Every response includes os.hostname() so you can see exactly which machine (or container) handled the request.
  • Add the POST endpoint and the server listener below the GET endpoints in src/index.ts:
// Create a new bookmark
app.post("/api/bookmarks", (req, res) => {
  const { title, url } = req.body as { title: string; url: string };
  if (!title || !url) {
    res.status(400).json({ error: "Title and URL are required" });
    return;
  }
  const newBookmark: Bookmark = {
    id: bookmarks.length + 1,
    title,
    url,
  };
  bookmarks.push(newBookmark);
  res.status(201).json({
    data: newBookmark,
    servedBy: os.hostname(),
  });
});

// Start the server
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT} | hostname: ${os.hostname()}`);
});

What does this code do?

  • The POST handler validates that both title and url are present, then adds the new bookmark to the in-memory array.
  • app.listen(PORT) starts the server on port 3000 and logs the hostname so you can confirm which machine is serving.
  • Save src/index.ts.

✔️ Awesome, I've got everything!

Double check you saved src/index.ts. You're ready to compile and run.

ⓧ I'd like to double check the full code

Make sure your src/index.ts has these sections in order:

  • Imports (express and os), app setup with PORT = 3000, the Bookmark interface, and the seed data array with three bookmarks.
  • Three GET endpoints: /health, /api/bookmarks, and /api/bookmarks/:id.
  • The POST /api/bookmarks endpoint and the app.listen(PORT) call at the very end.

Scroll up and compare each section with the code blocks above. Make sure every endpoint includes os.hostname() in its response.

Compile and run the API locally

Your TypeScript source code needs to be compiled into JavaScript before Node.js can run it. This is the moment you find out if everything is wired up correctly.

  • Compile the TypeScript code by running this in your terminal:
npx tsc

If the compilation succeeds, the command finishes with no output. A new dist/ folder should appear in your editor's sidebar containing index.js.

Seeing compilation errors?

The error message includes a line number. Open src/index.ts and compare that line to the code blocks above. Common causes include a missing comma, an extra bracket, or a typo in a function name.

If you see an error about Cannot find module 'express', check that you ran npm install and that node_modules exists in your project folder.

Help me fix my TypeScript compilation error

  • Start the API server by running:
node dist/index.js

You should see a message like Server running on port 3000 | hostname: YOUR-MACHINE-NAME in the terminal. The hostname is your computer's name. This terminal is now occupied by the running server.

Why does the terminal stop responding?

The server process keeps running so it can listen for incoming requests. You need a second terminal to send those requests. Don't close this terminal or press Ctrl+C yet.

  • Open a second terminal window. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, then type Terminal or PowerShell and press Enter.

Before you test, think about what you expect the response to contain. What fields should you see in the JSON, and what value will servedBy show when the API is running directly on your machine?

  • Test the bookmarks endpoint by running this command in your new terminal:
curl http://localhost:3000/api/bookmarks

You should see a JSON response with a data array containing three bookmarks and a servedBy field showing your computer's hostname. The hostname is your machine's name because the API is running directly on your computer, not inside a container.

Remember this hostname

In the next step, you'll run the same API inside a Docker container. The servedBy value will change from your machine's name to a container ID. That shift is how you prove the app is running inside Docker.

Not seeing JSON output from curl?

If you see HTML output instead of JSON, you may be using a PowerShell alias. On Windows PowerShell 5.1, curl is aliased to Invoke-WebRequest. Use curl.exe instead to call the real curl binary.

If you see Connection refused, check that the server is still running in your first terminal. You should see the Server running on port 3000 message there.

Help me troubleshoot my curl request

  • Switch back to the terminal running the server and press Ctrl+C to stop the server.

Your API is running and every response tags itself with the hostname. Next up, you'll package this application into a Docker container so it runs the same way regardless of what machine it's on.

Dockerize with a Multi-Stage Build

Your bookmarks API works on your machine. But it depends on your local Node.js version, your operating system, and your file system layout. Move it to another computer, and there is no guarantee it runs the same way.

Docker solves this by packaging your app and everything it needs into a single, portable image. In this step, you will write a multi-stage Dockerfile that compiles TypeScript in a build stage and produces a lean production image with only the compiled JavaScript.

In this step, get ready to:
  • Create a .dockerignore file and a multi-stage Dockerfile.
  • Build the Docker image and inspect its size.
  • Run the API inside a container and verify the response shows a container ID.
Create the .dockerignore and Dockerfile

When Docker builds an image, it sends every file in your project directory to the Docker engine as a build context. A .dockerignore file tells Docker which files to skip, keeping the build fast and the image clean.

  • In your code editor, create a new file called .dockerignore in the docker-nginx-loadbalancer folder.
  • Add these patterns to tell Docker which files to exclude from the build context:
node_modules
dist
.git
*.md
.env
.env.*
Dockerfile
.dockerignore
compose.yaml
nginx

Why exclude these files?

node_modules and dist are rebuilt inside the container, so copying them from your machine wastes time and disk space.

Files like .env, .git, and *.md are not needed to build or run the app. The Dockerfile itself and future config files like compose.yaml are also excluded.

  • Save .dockerignore.
  • Confirm the file exists by running:
ls
  • You should see .dockerignore listed alongside your existing project files like package.json and src.

✔️ Awesome, I've got everything!

Great. Make sure you have saved .dockerignore and move on to the Dockerfile.

ⓧ I'd like to double check the full code

Here is the complete .dockerignore file. Each line is a pattern that Docker will exclude from the build context.

node_modules
dist
.git
*.md
.env
.env.*
Dockerfile
.dockerignore
compose.yaml
nginx

Now for the Dockerfile. A multi-stage build uses multiple FROM instructions in a single file. Each stage can do different work, and only the final stage becomes your production image.

  • Create a new file called Dockerfile (no file extension) in the docker-nginx-loadbalancer folder.
  • Add the builder stage, which installs dependencies and compiles TypeScript:
# Build stage: install all dependencies and compile TypeScript
FROM node:24-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

What does the builder stage do?

  • FROM node:24-slim AS builder starts from a lightweight Debian-based image with Node.js pre-installed. The AS builder label names this stage so the next stage can reference it.
  • COPY package*.json copies package.json and package-lock.json first, then npm ci installs all dependencies (including TypeScript). Copying these files first lets Docker cache the install layer.
  • npm run build compiles your TypeScript into JavaScript. Everything in this stage is temporary and gets discarded after the build.
  • Below the builder stage in Dockerfile, add the production runtime stage:
# Production stage: lean image with only compiled code
FROM node:24-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

What does the runtime stage do?

  • This stage starts fresh from node:24-slim with none of the builder's files. It is a clean slate.
  • npm ci --omit=dev installs only production dependencies, skipping TypeScript and type definitions entirely.
  • COPY --from=builder /app/dist ./dist pulls the compiled JavaScript from the builder stage. This is the only thing that crosses between stages.
  • USER node switches to the pre-built non-root node user, following Docker security best practices.
  • Save Dockerfile.

✔️ Awesome, I've got everything!

Great. Double check you have saved your Dockerfile with both stages.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile. Make sure your file matches this exactly.

FROM node:24-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM node:24-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Build the Docker image
  • Build your Docker image by running this command:
docker build -t bookmarks-api .

What does this command do?

  • docker build reads the Dockerfile in the current directory (.) and executes each instruction.
  • -t bookmarks-api tags the resulting image with the name bookmarks-api so you can reference it by name later.
  • Docker runs the builder stage first, compiles TypeScript, then starts the production stage from scratch. The builder stage is discarded once the final image is complete.
  • Check the image size by running:
docker images bookmarks-api

You should see the bookmarks-api image listed with its size. The multi-stage build keeps the image lean because TypeScript, type definitions, and dev dependencies were left behind in the discarded builder stage.

Build failed with an error?

  • Make sure Docker Desktop is running. Look for the Docker icon in your system tray at the bottom right of your screen.
  • Verify you are inside the docker-nginx-loadbalancer folder where your Dockerfile is located.
  • Check that Dockerfile has no file extension. It should be named Dockerfile, not Dockerfile.txt.

My Docker build is failing. What could be wrong?

Run the container and verify

Your image is built. Now you will run it as a container and confirm the API responds from inside Docker instead of your local machine.

  • If your API server from Step 1 is still running, switch to that terminal and press Ctrl+C to stop it. Port 3000 needs to be free for the container.
  • Start a container from your image by running this command:
docker run --rm -p 3000:3000 bookmarks-api

What do these flags do?

  • --rm automatically removes the container when it stops, keeping your system clean.
  • -p 3000:3000 maps port 3000 on your machine to port 3000 inside the container. Without this, you cannot reach the app from your browser or terminal.

You should see the server startup message in your terminal. Notice that the hostname in the message is now a short hex string instead of your machine name. That is the container ID.

Before you test the API, think about what you expect the servedBy field to show. Will it still be your machine's hostname, or something different?

  • Open a new terminal window and test the API by running:
curl.exe http://localhost:3000/api/bookmarks

The response includes a servedBy value that is a 12-character hex string like a1b2c3d4e5f6. That is the Docker container ID, proving the app is running inside a container, not on your host machine.

Connection refused or empty reply?

  • Make sure the container is still running in the other terminal. You should see the Server running on port 3000 startup message there.
  • If port 3000 is already in use, stop any other process using it. Check that you pressed Ctrl+C in the terminal that was running your local server from Step 1.
  • Verify Docker Desktop is running by checking for the Docker icon in your system tray.

My Docker container is not responding on port 3000. How do I debug this?

Your API is now running inside a portable Docker container that you can move to any machine with Docker installed. Next up, you will put Nginx in front of it as a reverse proxy so the app is never exposed directly to the outside world.

Add Nginx as a Reverse Proxy

Your containerized API works, but right now it sits on a raw port with no proxy in front of it.

In production, you never expose Node.js directly to the internet. Nginx handles incoming connections far more efficiently, adds forwarding headers so your app knows the real client IP, and gives you a stable port 80 entry point. In this step, you will put Nginx in front of your API using Docker Compose to orchestrate both containers.

In this step, get ready to:
  • Create an Nginx configuration that forwards traffic to the API container.
  • Define a multi-container stack with Docker Compose.
  • Test the API through Nginx on port 80.
Create the Nginx configuration

Nginx needs a configuration file that tells it where to forward incoming requests. You will create this file inside an nginx directory in your project.

  • If you still have the container from Step 2 running, press Ctrl+C in its terminal to stop it.
  • Create the nginx directory inside your project by running this command:
mkdir nginx
  • Inside the nginx folder, create a new file called default.conf in your code editor.
  • Paste the following Nginx configuration into nginx/default.conf:
upstream api_backend {
    server app:3000;
}

server {
    listen 80;

    location / {
        proxy_pass http://api_backend;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Connection "";
    }
}

What does this configuration do?

  • upstream api_backend defines a named group of backend servers. Right now it has one server (app:3000), but you will add more in the next step.
  • app is the Docker Compose service name. Docker Compose creates a shared network where each service is reachable by name, so Nginx can find your API container at app:3000.
  • proxy_pass http://api_backend forwards every request arriving at Nginx to the upstream group.
  • The proxy_set_header lines pass the original client's IP address, hostname, and protocol through to your Express app so it knows who really sent the request.
  • Save nginx/default.conf.
  • Confirm the file exists by running:
ls nginx

You should see default.conf listed in the output.

Don't see default.conf?

  • Make sure you created the file inside the nginx folder, not in the project root.
  • Check that you ran mkdir nginx first. The directory must exist before you can save a file into it.
  • Help me troubleshoot my nginx directory setup.

Why Nginx instead of exposing Node.js directly?

Node.js is single-threaded and handles connections one event loop tick at a time. Nginx is built from the ground up as a high-performance connection handler that can manage thousands of simultaneous connections with minimal memory.

Putting Nginx in front gives you a stable port 80 entry point, TLS termination, request buffering, and the ability to swap or scale your backend without clients noticing any change.

Define the services in Docker Compose

Docker Compose lets you define multiple containers, their networks, and their startup order in a single file. You will create a compose.yaml with two services: the Express API and Nginx.

  • Create a new file called compose.yaml in the root of your docker-nginx-loadbalancer folder.
  • Start by adding the app service definition:
services:
  app:
    build: .
    expose:
      - "3000"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://localhost:3000/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 5s

What does the app service do?

  • build: . tells Compose to build the image from the Dockerfile in the current directory.
  • expose makes port 3000 available to other containers on the same Docker network, but does NOT publish it to your host machine. Only Nginx will be able to reach this port.
  • The healthcheck uses Node.js built-in fetch() to hit the /health endpoint every 10 seconds. If the endpoint stops responding, Docker marks the container as unhealthy.
  • Add the Nginx service below the app service in the same file:
  nginx:
    image: nginx:stable-alpine
    container_name: nginx-lb
    ports:
      - "80:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app:
        condition: service_healthy
    restart: unless-stopped

What does the Nginx service do?

  • image: nginx:stable-alpine pulls the official Nginx image on Alpine Linux. No custom build needed.
  • ports: "80:80" maps port 80 on your machine to port 80 inside the Nginx container. This is the only port exposed to your host.
  • The volumes line mounts your local nginx/default.conf into the container's config directory as read-only (:ro).
  • depends_on: service_healthy means Nginx will not start until the app container passes its health check. This prevents Nginx from trying to forward requests to a container that is not ready yet.
  • Save compose.yaml.

Why use fetch() instead of curl in the health check?

The node:24-slim image does not include curl or wget. Node.js 24 ships with built-in fetch(), so the health check uses that to hit the /health endpoint without adding any extra tools to the image.

✔️ Awesome, I've got everything!

Double check you have saved compose.yaml. Time to fire up the stack.

ⓧ I'd like to double check the full code

Here is the full compose.yaml. Make sure yours matches exactly:

services:
  app:
    build: .
    expose:
      - "3000"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://localhost:3000/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 5s

  nginx:
    image: nginx:stable-alpine
    container_name: nginx-lb
    ports:
      - "80:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app:
        condition: service_healthy
    restart: unless-stopped
Start the stack and test through Nginx

Everything is configured. Time to build and start both containers with a single command.

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

What is happening behind the scenes?

Docker Compose builds the app image from your Dockerfile, starts the app container, and waits for its health check to pass. Once the app reports healthy, Compose starts the Nginx container.

The -d flag runs everything in the background so you can keep using the same terminal to test.

  • Confirm both services are running by checking their status:
docker compose ps

You should see two services listed. The app container should show a status of healthy, and the nginx-lb container should be running on port 80.

Nginx container not starting?

  • Nginx waits for the app to become healthy before starting. Run docker compose logs app to check whether the app started successfully.
  • If another process is using port 80, the Nginx container will fail to bind. On Windows, check with netstat -ano | findstr :80 and stop the conflicting process.
  • Make sure the nginx/default.conf path in your compose.yaml volumes line matches the actual file location. A typo there causes Nginx to start with an empty config.
  • Help me troubleshoot my Docker Compose stack.

Both containers are up. Before you test, think about this: what port will you use to reach the API now? It is no longer 3000.

  • Test the API through Nginx by running:
curl.exe http://localhost/api/bookmarks

You should see the familiar bookmarks JSON with a servedBy field showing a container ID. The request arrived on port 80, passed through Nginx, and was forwarded to the app container on port 3000 inside the Docker network.

  • Now verify that the response is actually coming through Nginx by checking the response headers:
curl.exe -I http://localhost/health

Look for the Server header in the output. It should show nginx. This confirms that Nginx is the service handling your request and forwarding it to the app behind the scenes.

  • Finally, confirm that port 3000 is no longer accessible from your machine:
curl.exe http://localhost:3000/api/bookmarks

This request should fail with a connection refused error. The app container only exposes port 3000 to other containers on the Docker network, not to your host machine. Nginx is now the single entry point.

Still getting a response on port 3000?

  • You might still have the standalone container from Step 2 running. Run docker ps to list all running containers. If you see a container with port 3000 mapped that is not part of your Compose stack, stop it with docker stop <container-id>.
  • Check your compose.yaml and make sure the app service uses expose (not ports). Using ports would publish port 3000 to your host.
  • Help me figure out why port 3000 is still accessible.

Your API is now behind Nginx, accessible on port 80 with proper forwarding headers. But there is a problem: you only have one app container. If it goes down, Nginx has nothing to route to. Next up, you will scale to three instances and prove that the API survives the loss of any single container.

Scale to Three Instances with Load Balancing

In Step 3, you placed Nginx in front of your API on port 80. Every request now flows through a single app container. That setup works, but it has a critical weakness: if that one container goes down, your entire API goes offline.

In this step, you'll spin up three identical API containers and configure Nginx to distribute requests across all of them. This gives you redundancy and load distribution. Stop any one container and the API keeps responding through the other two.

In this step, get ready to:
  • Update compose.yaml to run three API instances using YAML anchors.
  • Configure Nginx to load-balance across all three instances with an upstream block.
  • Prove resilience by stopping a container and confirming the API stays up.
Expose the single point of failure

Your API currently relies on one container behind Nginx. What happens to incoming requests if that container goes down?

  • Stop the app container by running this command:
docker compose stop app

Before you send a request, predict: will Nginx still return your bookmarks, or will something go wrong?

  • Try to reach the API by running:
curl.exe http://localhost/api/bookmarks

You see an HTML page with 502 Bad Gateway. Nginx tried to forward your request, but the app container is gone. There is nothing left to proxy to.

Why 502 Bad Gateway?

Nginx sits between the client and the backend. When every backend server in its upstream list is unreachable, Nginx cannot complete the proxy and returns a 502 error. That single point of failure is exactly what you'll fix in the rest of this step.

  • Bring everything down to prepare for the new configuration by running:
docker compose down
Define three app services with YAML anchors

Instead of copying the same build, health check, and restart config three times, you'll use a YAML anchor to define it once and reference it from each service.

  • Open compose.yaml in your code editor.
  • Replace the entire file content with the following code:
# Shared config for all app instances
x-app: &app-common
  build: .
  expose:
    - "3000"
  restart: unless-stopped
  healthcheck:
    test: ["CMD", "node", "-e", "fetch('http://localhost:3000/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"]
    interval: 10s
    timeout: 5s
    retries: 3
    start_period: 5s

# Three app instances using the shared config
services:
  app-1:
    <<: *app-common
    container_name: app-1

  app-2:
    <<: *app-common
    container_name: app-2

  app-3:
    <<: *app-common
    container_name: app-3

What are YAML anchors?

The x-app block is a Docker Compose extension field. The &app-common anchor saves its contents under a name. Each service then uses <<: *app-common to merge all those fields in, so you only write the build, health check, and restart config once.

Each service still gets its own container_name so you can tell them apart in logs and commands.

  • Below the app-3 service, add the updated nginx service:
  # Nginx reverse proxy depends on all three app instances
  nginx:
    image: nginx:stable-alpine
    container_name: nginx-lb
    ports:
      - "80:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app-1:
        condition: service_healthy
      app-2:
        condition: service_healthy
      app-3:
        condition: service_healthy
    restart: unless-stopped

Why does Nginx depend on all three?

The depends_on block with condition: service_healthy tells Docker Compose to wait until all three app containers pass their health checks before starting Nginx. This prevents Nginx from starting and immediately returning 502 errors while the backends are still booting.

  • Save compose.yaml.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

Your compose.yaml has two sections. Scroll up and confirm both are in place, in this order:

  • The x-app: &app-common anchor block at the top, followed by three services (app-1, app-2, app-3) each using <<: *app-common.
  • The nginx service at the bottom with depends_on listing all three app services with condition: service_healthy.
Add three backend servers to the Nginx config

Nginx still points to the old single app service. You need to update the upstream block to list all three new service names so Nginx can distribute requests across them.

  • Open nginx/default.conf in your code editor.
  • Find the upstream api_backend block at the top of the file:
upstream api_backend {
    server app:3000;
}
  • Replace server app:3000; with three server lines, so the block looks like this:
upstream api_backend {
    server app-1:3000;
    server app-2:3000;
    server app-3:3000;
}

How does Nginx distribute requests?

When no load balancing directive is specified, Nginx uses round-robin by default. It sends the first request to app-1, the second to app-2, the third to app-3, then starts over. Each container's name resolves to its IP address through Docker's internal DNS.

  • Save nginx/default.conf.

✔️ Awesome, I've got everything!

Great. Confirm the file is saved and the rest of the config below the upstream block is unchanged.

ⓧ I'd like to double check the full code

upstream api_backend {
    server app-1:3000;
    server app-2:3000;
    server app-3:3000;
}

server {
    listen 80;

    location / {
        proxy_pass http://api_backend;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Connection "";
    }
}
Rebuild and test load balancing

Both config files are updated. Time to rebuild the stack and see round-robin load balancing in action.

  • Start the new multi-container stack by running:
docker compose up --build

What should I see?

Docker builds one image and creates three app containers from it, plus the Nginx container. You'll see health check logs for each app instance. Once all three report healthy, Nginx starts and the stack is ready.

This takes a bit longer than before because Docker waits for three health checks instead of one. Wait until you see all four containers running before testing.

Seeing an error about orphaned containers?

Docker Compose may warn about the old app container that no longer exists in your updated compose.yaml. Press Ctrl+C to stop, then run docker compose down --remove-orphans to clean up the leftover container. Then run docker compose up --build again.

Help me fix orphaned container errors in Docker Compose.

Now test whether Nginx is distributing requests across your three containers. The servedBy field in each response reveals which container handled the request.

  • Open a second terminal and run this loop to send six requests:
for ($i=1; $i -le 6; $i++) { (curl http://localhost/api/bookmarks -UseBasicParsing).Content | ConvertFrom-Json | Select-Object -ExpandProperty servedBy }

You see a list of six hostnames. At least two of them are different, proving Nginx is rotating requests across your containers using round-robin.

Seeing the same hostname every time?

Check that all three app containers are running. In your second terminal, run docker compose ps and confirm app-1, app-2, and app-3 all show a healthy status. If any are missing or unhealthy, check that your compose.yaml anchor syntax matches the code above.

Help me debug why Nginx is not distributing requests across my containers.

Load balancing works. Now prove that the API survives losing a container. What do you think happens when you stop one of the three instances?

  • Stop the first app container by running:
docker compose stop app-1
  • Send another batch of requests by running:
for ($i=1; $i -le 6; $i++) { (curl http://localhost/api/bookmarks -UseBasicParsing).Content | ConvertFrom-Json | Select-Object -ExpandProperty servedBy }

The API still responds. Every hostname in the output comes from app-2 or app-3. Nginx detected that app-1 is unreachable and automatically routed around it. No downtime.

Compare this to Step 3

Earlier, stopping the single app container gave you 502 Bad Gateway. Now with three instances, losing one container has zero impact on your users. This is the core benefit of running multiple replicas behind a load balancer.

Your API now runs across three containers with automatic failover. Next up, you'll tackle an optional Secret Mission to push your skills even further.

Secret mission

Add Nginx Rate Limiting

Your load-balanced API handles traffic beautifully across three containers. But right now, nothing stops a single client from flooding it with thousands of requests per second. Can you add Nginx rate limiting to protect your API without changing a single line of application code?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on 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 (app-1, app-2, app-3, nginx-lb) running on Docker Desktop.
  • Docker images (bookmarks-api and nginx:stable-alpine) stored locally.
  • Project folder (docker-nginx-loadbalancer) on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with your load-balanced API.

  • Your four containers will keep running and respond on port 80.
  • The containers consume minimal CPU and memory while idle.

Pause - I'll come back to this later

Stop all containers without removing them to free up system resources. Your images and configuration stay intact so you can restart later.

  • Open a terminal in your docker-nginx-loadbalancer folder.
  • Stop all containers by running:
docker compose stop
  • When you're ready to pick up again, restart all containers by running:
docker compose start

Delete - I don't want to use this again

Remove all containers, images, and volumes created by this project, then delete the project folder.

  • Open a terminal in your docker-nginx-loadbalancer folder.
  • Remove all containers, images, and volumes by running:
docker compose down --rmi all --volumes
  • Reclaim additional disk space from any dangling images by running:
docker image prune
  • Confirm when prompted to proceed.
  • Move out of the project folder and delete it by running:

macOS

cd ~/Desktop
rm -rf docker-nginx-loadbalancer

Windows

cd ~\Desktop
Remove-Item -Recurse -Force docker-nginx-loadbalancer

Nice Work!

Nice Work!

Nice work! You just built a production-grade TypeScript API, containerized it with Docker, and scaled it behind an Nginx load balancer. You can stop any container and your API keeps responding.

You've learned how to:

  • Build and containerize a TypeScript Express API using a multi-stage Dockerfile that compiles TypeScript in a build stage and produces a lean production image running as a non-root user.
  • Orchestrate a multi-container stack with Docker Compose, wiring an Nginx reverse proxy in front of your API with proper forwarding headers and health-checked service dependencies.
  • Scale to three API instances behind an Nginx upstream block with round-robin load balancing, and prove resilience by stopping a container without any downtime.
  • Secret Mission: Protect the API with Nginx rate limiting, capping requests per client IP without any application code changes.

Ready to quiz yourself?