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
| |
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?
| Reason | Explanation |
|---|---|
| Reverse proxy | Hides the app’s real port, clients only see port 80/443 |
| Static files | Nginx serves static files (CSS, JS, images) much faster than Node.js |
| SSL termination | Nginx handles HTTPS, Node.js only receives plain HTTP |
| Rate limiting | Limits requests at the Nginx layer, protecting Node.js from being overloaded |
| Load balancing | When scaling multiple Node.js instances, Nginx distributes requests |
Step 1: Create the Node.js Application (Express)
Project Structure
| |
File package.json
| |
File src/server.js
| |
Note:
app.listen(PORT, "0.0.0.0")— you must bind to0.0.0.0so the container can receive requests from outside. If you bind to127.0.0.1(localhost), Nginx won’t be able to connect to the app.
Step 2: Write the Dockerfile for Node.js
File .dockerignore
| |
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
| |
Explanation:
| Technique | Purpose |
|---|---|
node:18-alpine | Small base image (~170 MB instead of ~900 MB for node:18) |
| Multi-stage build | The deps stage installs dependencies, the production stage only copies the result |
npm ci | Installs exactly according to package-lock.json, ensuring reproducible builds |
--mount=type=cache | Caches npm packages across builds |
| Non-root user | Doesn’t run the app as root, improving security |
COPY src ./src | Only 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.
| |
Key directives explained:
| Directive | Meaning |
|---|---|
upstream nodejs_app | Defines a group of backend servers. When scaling multiple instances, add servers here |
proxy_pass http://nodejs_app | Forwards requests to the defined upstream |
proxy_set_header Host $host | Preserves the original hostname from the client (by default Nginx changes it to $proxy_host) |
proxy_set_header X-Real-IP | Sends the client’s real IP to Node.js (not Nginx’s IP) |
proxy_set_header X-Forwarded-For | The chain of IPs through proxies, helping Node.js identify the real client |
proxy_set_header X-Forwarded-Proto | Tells Node.js whether the client is using HTTP or HTTPS |
proxy_http_version 1.1 | Required 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
| |
Key configurations:
| Configuration | Explanation |
|---|---|
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_healthy | Nginx only starts after Node.js is healthy |
restart: unless-stopped | Auto-restarts on crash, unless you manually stop it |
:ro (read-only) | Mounts the Nginx config in read-only mode |
networks: app-network | Both containers share the same network, Nginx calls app:3000 via the service name |
Step 5: Build and Run
| |
Verify it’s working:
| |
Expected output:
| |
Step 6: Redeploying After Code Changes
According to the Docker Compose docs, when updating code, you only need to rebuild the necessary service:
| |
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:
| |
Update nginx/default.conf so Nginx knows about multiple instances:
| |
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
exposeinstead ofportsfor backend services — only Nginx should be exposed to the outside - Bind
0.0.0.0in Node.js, not127.0.0.1 - Use
npm ciinstead ofnpm installto ensure reproducible builds - Create a non-root user in the Dockerfile for security
- Always use
- Best practices:
- Use
docker compose up --no-deps -d appwhen you only need to redeploy the app - Pin versions for base images (
node:18-alpine,nginx:1.27-alpine) instead oflatest - Separate
docker-compose.prod.ymlfor production config
- Use
- Troubleshooting:
- Nginx returns
502 Bad Gateway? → Node.js hasn’t finished starting or is bound to the wrong address. Checkdocker 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_sizeandproxy_read_timeoutin the Nginx config
- Nginx returns
Conclusion
Here’s a summary of the deployment flow:
- Write the app — Node.js (Express) with a health check endpoint
- Dockerfile — multi-stage build + non-root user
- Nginx config — reverse proxy with
proxy_passand the necessary headers - Docker Compose — connect app + Nginx, only expose port 80
- Deploy with
docker compose up --build -d - 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
- Docker Official - Containerize Node.js — Official Docker guide for Node.js
- Docker Compose in Production — Production deployment guide with Compose
- Nginx Reverse Proxy — Official reverse proxy configuration documentation
- Nginx Beginner’s Guide — Beginner’s guide from nginx.org
