BACK TO BLOG
DockerNginxDevOpsNext.js

From Zero to Production: Dockerizing a Full-Stack App with Nginx

Step-by-step guide to containerizing a Next.js + Express app, setting up Nginx reverse proxy, and achieving zero-downtime deployments.

December 10, 20257 min read

The Goal

Take a Next.js frontend + Express backend running locally and deploy it to a VPS with:

  • Both services in Docker containers
  • Nginx as a reverse proxy (single port 443 exposed)
  • Zero-downtime deployments via rolling restarts
  • SSL via Let's Encrypt

Project Structure

app/
├── frontend/          # Next.js
├── backend/           # Express
├── nginx/
│   └── nginx.conf
└── docker-compose.yml

Dockerfiles

Frontend:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.next/standalone .
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

Backend:

FROM node:20-alpine
WORKDIR /app
COPY package*.json .
RUN npm ci --only=production
COPY . .
EXPOSE 4000
CMD ["node", "index.js"]

Nginx Config

upstream frontend { server frontend:3000; }
upstream backend  { server backend:4000; }

server {
    listen 443 ssl;
    server_name yourdomain.com;

    location / {
        proxy_pass http://frontend;
        proxy_set_header Host $host;
    }

    location /api/ {
        proxy_pass http://backend;
        proxy_set_header Host $host;
    }
}

docker-compose.yml

services:
  frontend:
    build: ./frontend
    restart: unless-stopped

  backend:
    build: ./backend
    restart: unless-stopped
    env_file: .env

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
      - /etc/letsencrypt:/etc/letsencrypt:ro
    depends_on:
      - frontend
      - backend

Zero-Downtime Deployments

The trick is to rebuild and restart one service at a time:

# Pull latest code
git pull origin main

# Rebuild and restart backend (frontend stays up)
docker compose up -d --build --no-deps backend

# Then frontend
docker compose up -d --build --no-deps frontend

Nginx keeps routing to the old container until the new one is healthy. No downtime.

Health Checks

Add this to each service in docker-compose.yml so Nginx only routes to healthy containers:

healthcheck:
  test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
  interval: 10s
  timeout: 5s
  retries: 3

This is exactly the setup I use at Saciva — it's been running for months without a single unplanned outage.

All PostsRana Dolui