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
