Featured image of post Deploying Node.js with Docker + Nginx

Deploying Node.js with Docker + Nginx

A case study on deploying a Node.js (Express) application with Docker and Nginx reverse proxy β€” from writing code to running in production with Docker Compose.

Deploying Node.js with Docker + Nginx β€” Case Study

You’ve finished writing your API with Node.js, and it works perfectly on localhost. But when it comes to deploying on a real server, you face a bunch of questions:

  • How do I keep the app running stably and auto-restart on crash?
  • How do I prevent clients from accessing port 3000 directly?
  • How do I handle SSL, rate limiting, and static files?

The answer: Docker + Nginx reverse proxy. In this article, I’ll walk you through deploying a Node.js (Express) application from start to finish, using Docker for packaging and Nginx as a reverse proxy in front.


Architecture Overview

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Client (Browser / Mobile)
        β”‚
        β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  Nginx   β”‚  ← Port 80/443 (HTTP/HTTPS)
   β”‚  (proxy) β”‚
   β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
        β”‚ proxy_pass http://app:3000
        β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ Node.js  β”‚  ← Port 3000 (internal)
   β”‚ (Express)β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Nginx sits in front, receives requests from clients, then forwards (proxies) them to Node.js running behind it. Clients never access Node.js directly.

Why do we need Nginx in front?

ReasonExplanation
Reverse proxyHides the app’s real port, clients only see port 80/443
Static filesNginx serves static files (CSS, JS, images) much faster than Node.js
SSL terminationNginx handles HTTPS, Node.js only receives plain HTTP
Rate limitingLimits requests at the Nginx layer, protecting Node.js from being overloaded
Load balancingWhen scaling multiple Node.js instances, Nginx distributes requests

Step 1: Create the Node.js Application (Express)

Project Structure

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
my-app/
β”œβ”€β”€ src/
β”‚   └── server.js
β”œβ”€β”€ package.json
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ .dockerignore
β”œβ”€β”€ nginx/
β”‚   └── default.conf
└── docker-compose.yml

File package.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "name": "my-app",
  "version": "1.0.0",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js"
  },
  "dependencies": {
    "express": "^4.21.0"
  }
}

File src/server.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const express = require("express");
const app = express();
const PORT = process.env.PORT || 3000;

app.get("/", (req, res) => {
  res.json({ message: "Hello from Node.js!", timestamp: new Date() });
});

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

app.listen(PORT, "0.0.0.0", () => {
  console.log(`Server running on port ${PORT}`);
});

Note: app.listen(PORT, "0.0.0.0") β€” you must bind to 0.0.0.0 so the container can receive requests from outside. If you bind to 127.0.0.1 (localhost), Nginx won’t be able to connect to the app.


Step 2: Write the Dockerfile for Node.js

File .dockerignore

1
2
3
4
node_modules
npm-debug.log
.git
.env

According to the official Docker documentation, a Dockerfile for Node.js should use multi-stage builds, create a non-root user, and leverage cache mounts for optimization.

File Dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Stage 1: Install dependencies
FROM node:18-alpine AS deps
WORKDIR /app

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production

# Stage 2: Production
FROM node:18-alpine AS production
WORKDIR /app

# Create non-root user (following best practices from Docker docs)
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001 -G nodejs

ENV NODE_ENV=production

COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./

USER nodejs

EXPOSE 3000

CMD ["node", "src/server.js"]

Explanation:

TechniquePurpose
node:18-alpineSmall base image (~170 MB instead of ~900 MB for node:18)
Multi-stage buildThe deps stage installs dependencies, the production stage only copies the result
npm ciInstalls exactly according to package-lock.json, ensuring reproducible builds
--mount=type=cacheCaches npm packages across builds
Non-root userDoesn’t run the app as root, improving security
COPY src ./srcOnly copies the required source code, not the entire project

Step 3: Configure Nginx as a Reverse Proxy

File nginx/default.conf

According to the official Nginx documentation, the proxy_pass directive is used to forward requests to another server. The proxy_set_header directives need to be configured so the backend server receives correct client information.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
upstream nodejs_app {
    server app:3000;
}

server {
    listen 80;
    server_name _;

    # Limit request body size (prevent oversized uploads)
    client_max_body_size 10m;

    location / {
        proxy_pass http://nodejs_app;

        # Forward original client information to Node.js
        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;

        # WebSocket support (if the app needs it)
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    # Health check endpoint (Nginx responds directly, no need to go through Node.js)
    location = /nginx-health {
        access_log off;
        return 200 "ok";
        add_header Content-Type text/plain;
    }
}

Key directives explained:

DirectiveMeaning
upstream nodejs_appDefines a group of backend servers. When scaling multiple instances, add servers here
proxy_pass http://nodejs_appForwards requests to the defined upstream
proxy_set_header Host $hostPreserves the original hostname from the client (by default Nginx changes it to $proxy_host)
proxy_set_header X-Real-IPSends the client’s real IP to Node.js (not Nginx’s IP)
proxy_set_header X-Forwarded-ForThe chain of IPs through proxies, helping Node.js identify the real client
proxy_set_header X-Forwarded-ProtoTells Node.js whether the client is using HTTP or HTTPS
proxy_http_version 1.1Required for WebSocket and keep-alive support

Step 4: Docker Compose β€” Connecting Everything

According to the Docker Compose production guide, when deploying to production you should: remove volume binds for code, set restart policies, and separate compose files for each environment.

File docker-compose.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    container_name: nodejs-app
    restart: unless-stopped
    environment:
      - NODE_ENV=production
      - PORT=3000
    expose:
      - "3000"
    healthcheck:
      test:
        [
          "CMD",
          "wget",
          "--quiet",
          "--tries=1",
          "--spider",
          "http://localhost:3000/health",
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    networks:
      - app-network

  nginx:
    image: nginx:1.27-alpine
    container_name: nginx-proxy
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app:
        condition: service_healthy
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

Key configurations:

ConfigurationExplanation
expose: "3000"Only opens the port internally between containers, not exposed to the host
ports: "80:80"Only Nginx is exposed to the outside
depends_on: condition: service_healthyNginx only starts after Node.js is healthy
restart: unless-stoppedAuto-restarts on crash, unless you manually stop it
:ro (read-only)Mounts the Nginx config in read-only mode
networks: app-networkBoth containers share the same network, Nginx calls app:3000 via the service name

Step 5: Build and Run

1
2
3
4
5
6
7
8
# Build and run everything
docker compose up --build -d

# View logs
docker compose logs -f

# Check status
docker compose ps

Verify it’s working:

1
2
3
4
5
6
7
8
# Request through Nginx (port 80)
curl http://localhost

# Check Node.js health
curl http://localhost/health

# Check Nginx health
curl http://localhost/nginx-health

Expected output:

1
{ "message": "Hello from Node.js!", "timestamp": "2026-02-10T12:00:00.000Z" }

Step 6: Redeploying After Code Changes

According to the Docker Compose docs, when updating code, you only need to rebuild the necessary service:

1
2
3
# Rebuild only the app service (without affecting Nginx)
docker compose build app
docker compose up --no-deps -d app

The --no-deps flag ensures only the app service is restarted, not Nginx. Clients experience no interruption.


Scaling: Running Multiple Node.js Instances

When traffic increases, you can run multiple Node.js instances and let Nginx load balance:

1
docker compose up --scale app=3 -d

Update nginx/default.conf so Nginx knows about multiple instances:

1
2
3
4
5
upstream nodejs_app {
    # Docker Compose DNS automatically resolves the service name
    # to all running container IPs
    server app:3000;
}

When using Docker Compose with --scale, Docker DNS automatically resolves app to all IPs of the app containers. Nginx will round-robin requests to each instance.


Implementation Notes

  • When applying to your project, keep these important points in mind:
    • Always use expose instead of ports for backend services β€” only Nginx should be exposed to the outside
    • Bind 0.0.0.0 in Node.js, not 127.0.0.1
    • Use npm ci instead of npm install to ensure reproducible builds
    • Create a non-root user in the Dockerfile for security
  • Best practices:
    • Use docker compose up --no-deps -d app when you only need to redeploy the app
    • Pin versions for base images (node:18-alpine, nginx:1.27-alpine) instead of latest
    • Separate docker-compose.prod.yml for production config
  • Troubleshooting:
    • Nginx returns 502 Bad Gateway? β†’ Node.js hasn’t finished starting or is bound to the wrong address. Check docker compose logs app
    • Can’t connect between Nginx and Node.js? β†’ Make sure both are on the same network and the correct service name is used in proxy_pass
    • Requests are timing out? β†’ Check client_max_body_size and proxy_read_timeout in the Nginx config

🎯 Conclusion

Here’s a summary of the deployment flow:

  1. Write the app β€” Node.js (Express) with a health check endpoint
  2. Dockerfile β€” multi-stage build + non-root user
  3. Nginx config β€” reverse proxy with proxy_pass and the necessary headers
  4. Docker Compose β€” connect app + Nginx, only expose port 80
  5. Deploy with docker compose up --build -d
  6. Update with docker compose build app && docker compose up --no-deps -d app

This is the most basic architecture for deploying a Node.js application to production. From here, you can extend with SSL (Let’s Encrypt), databases, monitoring, and more as needed. πŸš€

If you find this article helpful, feel free to ⭐ star the repo or πŸ“± share it with your friends! 😊


References