Categories
Web Development, Website Design
5
(13)

Introduction

Deploying a web application to production is where theory meets reality — and where a lot of things can go wrong if your infrastructure isn’t set up thoughtfully. Over the years, we’ve iterated our deployment workflow significantly. We’ve moved from manual server setups to fully containerized environments, from basic Apache configurations to battle-tested Nginx reverse proxy setups, and from fragile deploy scripts to repeatable, environment-agnostic pipelines. In this article, we’re pulling back the curtain on exactly how we deploy production applications today — using Docker for containerization and Nginx as our reverse proxy and SSL termination layer. This isn’t a beginner’s tutorial rehashing documentation. It’s a practical walkthrough of the actual stack we use, the decisions behind it, and the lessons we learned the hard way. If you’re running production workloads or planning to, this guide will give you a solid foundation to build on.


Why Docker + Nginx in 2026?

Before diving into the how, it’s worth briefly addressing the why — because there are more infrastructure options available today than ever before. Why not just use a PaaS like Railway, Render, or Heroku? Managed platforms are excellent for many use cases. But when you need full control over your networking layer, custom Nginx configurations, multi-service orchestration, cost predictability at scale, or the ability to self-host on any VPS or bare-metal server — rolling your own Docker + Nginx stack gives you flexibility that managed platforms can’t match. Why Docker?

  • Environment consistency: The container runs identically on your laptop, CI/CD pipeline, and production server. “Works on my machine” stops being a phrase you use.
  • Isolation: Each service runs in its own container with defined dependencies, reducing version conflicts and side effects between services.
  • Portability: Your entire application stack can be moved to a different server or cloud provider with minimal friction.
  • Reproducibility: Infrastructure is defined in code (Dockerfile, docker-compose.yml) and can be versioned, reviewed, and rolled back.

Why Nginx? Nginx handles what your application servers shouldn’t have to think about: routing incoming requests to the right service, terminating SSL, managing load balancing, serving static files efficiently, and providing a security buffer between the internet and your application containers. Together, Docker and Nginx form a deployment architecture that is lightweight, maintainable, and scales cleanly — from a single VPS to a multi-server setup.


Our Production Stack Overview

Here’s the high-level picture of how our production environment is structured:

Internet
    │
    ▼
[Nginx Container] — SSL termination, reverse proxy, static file serving
    │
    ├──▶ [App Container 1] — e.g., Next.js frontend (port 3000)
    ├──▶ [App Container 2] — e.g., Node.js API (port 4000)
    └──▶ [App Container 3] — e.g., Python service (port 5000)
         │
         ▼
    [Database Container] — PostgreSQL / MongoDB (internal network only)

Key principles:

  • Nginx is the only publicly exposed service. All application containers communicate internally and are never directly accessible from the internet.
  • Each service is its own container. Frontend, backend API, background workers, and databases all run independently.
  • All configuration is code. Dockerfiles, Compose files, and Nginx configs live in the repository alongside application code.
  • Secrets are injected at runtime. No hardcoded credentials anywhere in the codebase.

Step 1: Structuring the Project

A clean project structure makes deployments significantly easier to reason about. Here’s how we typically organize a multi-service project:

project-root/
├── frontend/
│   ├── Dockerfile
│   └── ... (Next.js app)
├── api/
│   ├── Dockerfile
│   └── ... (Node.js/Express app)
├── nginx/
│   ├── Dockerfile
│   ├── nginx.conf
│   └── conf.d/
│       └── default.conf
├── docker-compose.yml
├── docker-compose.prod.yml
└── .env.example

Separating docker-compose.yml (base configuration) from docker-compose.prod.yml (production overrides) keeps environment-specific configuration clean and avoids accidentally applying production settings in development.


Step 2: Writing Production Dockerfiles

A production Dockerfile should be lean, secure, and deterministic. Here’s an example for a Next.js frontend application:

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Stage 2: Production image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Several intentional decisions here worth noting: Multi-stage builds separate the build environment from the production runtime. The final image contains only what’s needed to run the application — not build tools, dev dependencies, or source files. This typically reduces image size by 60–80% and shrinks the attack surface. Non-root user is a security best practice. Containers running as root are a liability if there’s ever a container escape vulnerability. Running as a dedicated non-root user limits the blast radius. npm ci instead of npm install installs exact versions from package-lock.json, ensuring reproducible builds.


Step 3: Configuring Nginx as Reverse Proxy

The Nginx configuration is where a lot of production setups either get it right or accumulate subtle issues. Here’s our baseline default.conf for a multi-service setup:

upstream frontend {
    server frontend:3000;
}

upstream api {
    server api:4000;
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;
    return 301 https://$host$request_uri;
}

# HTTPS server
server {
    listen 443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml;

    # Frontend routes
    location / {
        proxy_pass http://frontend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        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_cache_bypass $http_upgrade;
    }

    # API routes
    location /api/ {
        proxy_pass http://api;
        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;
    }
}

A few important details: upstream blocks define named groups of backend services. Using service names (frontend, api) rather than IP addresses means Docker’s internal DNS handles routing — containers can be recreated with new IPs without touching the Nginx config. HTTP/2 (http2 directive on the HTTPS server block) is enabled by default in our setup. It significantly improves performance for modern browsers through multiplexed connections. Security headers are added at the Nginx layer, meaning they apply consistently across all application responses regardless of what the application itself sets. X-Forwarded-For and X-Real-IP ensure your application containers receive the actual client IP address rather than the Nginx container’s internal IP — important for logging, rate limiting, and geo-targeting.


Step 4: Docker Compose for Production

The production Compose file ties everything together:

version: "3.9"

services:
  nginx:
    build: ./nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /etc/letsencrypt:/etc/letsencrypt:ro
      - /var/www/certbot:/var/www/certbot:ro
    depends_on:
      - frontend
      - api
    restart: always
    networks:
      - web

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    environment:
      - NODE_ENV=production
      - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
    restart: always
    networks:
      - web

  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    environment:
      - NODE_ENV=production
      - DATABASE_URL=${DATABASE_URL}
      - JWT_SECRET=${JWT_SECRET}
    depends_on:
      - db
    restart: always
    networks:
      - web
      - internal

  db:
    image: postgres:16-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=${POSTGRES_DB}
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    restart: always
    networks:
      - internal

volumes:
  postgres_data:

networks:
  web:
    driver: bridge
  internal:
    driver: bridge
    internal: true

The dual network setup is deliberate and important:

  • The web network connects Nginx, frontend, and API containers — traffic flows through this network.
  • The internal network connects the API to the database — and is marked internal: true, which means containers on this network cannot reach the internet or be reached from outside Docker. The database is completely isolated.

This network segmentation is a simple but effective security layer. Even if an application container is compromised, the attacker has no direct route to the database from an external network.


Step 5: SSL with Let’s Encrypt and Certbot

We use Certbot in a separate container to handle SSL certificate issuance and renewal:

certbot:
  image: certbot/certbot
  volumes:
    - /etc/letsencrypt:/etc/letsencrypt
    - /var/www/certbot:/var/www/certbot
  entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"

Certbot runs in a loop, checking for certificate renewal every 12 hours. Nginx is configured to serve the ACME challenge files from /var/www/certbot, allowing Certbot to complete domain validation without taking Nginx offline. Initial certificate issuance is run once manually before the full stack is brought up:

docker run --rm \
  -v /etc/letsencrypt:/etc/letsencrypt \
  -v /var/www/certbot:/var/www/certbot \
  certbot/certbot certonly --webroot \
  --webroot-path=/var/www/certbot \
  --email [email protected] \
  --agree-tos \
  --no-eff-email \
  -d yourdomain.com -d www.yourdomain.com

Step 6: Zero-Downtime Deployments

One of the most common pain points with self-hosted deployments is downtime during updates. Here’s the approach we use to deploy new versions without dropping requests:

#!/bin/bash
set -e

echo "Pulling latest images..."
docker compose -f docker-compose.prod.yml pull

echo "Building new images..."
docker compose -f docker-compose.prod.yml build --no-cache

echo "Recreating services with zero downtime..."
docker compose -f docker-compose.prod.yml up -d --no-deps --scale frontend=2 frontend
sleep 10
docker compose -f docker-compose.prod.yml up -d --no-deps --scale frontend=1 frontend

echo "Cleaning up old images..."
docker image prune -f

echo "Deploy complete."

The --no-deps flag updates a single service without restarting its dependencies. Scaling to 2 temporarily during the deploy ensures at least one instance is always available to serve traffic while the new container starts up. For more complex setups, tools like Docker Swarm or Kubernetes provide more sophisticated rolling update mechanisms — but for most business applications running on a single server or small cluster, this script-based approach is reliable and easy to understand.


Monitoring and Observability

A production deployment without monitoring is flying blind. Our baseline observability stack alongside the Docker + Nginx setup includes:

  • Nginx access logs piped to a log aggregator (we use Grafana Loki) for request-level visibility
  • Docker container metrics via cAdvisor, feeding into Prometheus and Grafana dashboards
  • Uptime monitoring via an external service (UptimeRobot or Better Uptime) for independent health checks
  • Error tracking via Sentry integrated at the application level

None of these are optional for production workloads. The first time you catch a silent failure through a monitoring alert — before a client or user reports it — the investment pays for itself.


Common Mistakes We’ve Seen (and Made)

Running containers as root. It’s faster to set up but creates unnecessary security exposure. Always define non-root users in your Dockerfiles. Storing secrets in environment variables in the Compose file. Use .env files that are never committed to version control, or better yet, a secrets manager like HashiCorp Vault or Docker Secrets. Not setting restart: always on critical services. Without this, a container crash or server reboot leaves your application offline until someone manually intervenes. Using latest tags for base images. node:latest will change without warning and can break your build. Always pin to specific versions: node:20-alpine. Skipping health checks. Docker’s HEALTHCHECK instruction tells the orchestrator whether a container is actually ready to serve traffic — not just whether the process started. Without it, a container that starts but immediately errors will still receive traffic.


Scaling Beyond a Single Server

The Docker + Nginx setup described here runs well on a single VPS for most business applications. When you start hitting the limits of a single server — whether due to traffic volume, redundancy requirements, or geographic distribution — the natural evolution paths are:

  • Docker Swarm for multi-node container orchestration with minimal additional complexity
  • Kubernetes for large-scale, highly available deployments where the operational overhead is justified
  • CDN integration in front of Nginx for static asset distribution and edge caching

Building on a solid foundation from the start — clean container boundaries, network isolation, infrastructure as code — makes these transitions significantly smoother than retrofitting a tangled deployment. This is exactly the philosophy behind how we approach scalable web infrastructure: starting with architecture decisions that grow with your business, rather than choices that need to be undone later.


Conclusion

Docker and Nginx together form one of the most reliable, flexible, and cost-effective production deployment stacks available in 2026. The combination gives you environment consistency, network isolation, SSL automation, zero-downtime deployments, and a clear path to scaling — all defined in code that can be versioned, reviewed, and reproduced. The setup we’ve described here isn’t the only way to deploy production applications, but it reflects years of refinement toward something we trust under real traffic with real stakes. The key principles that make it work are worth repeating: everything is code, nothing is exposed that doesn’t need to be, secrets are never hardcoded, and monitoring is non-negotiable. Whether you’re migrating an existing application to containers or starting a new project from scratch, these foundations will serve you well.


Building production-grade infrastructure requires the same level of thought and expertise as building the application itself. Learn more about how we architect and deliver scalable web infrastructure for businesses that need reliability from day one.

How useful was this post?

Click on a star to rate it!

Average rating 5 / 5. Vote count: 13

No votes so far! Be the first to rate this post.

Leave a Reply

Your email address will not be published. Required fields are marked *


Want Free Consultation?

Need a high-performing website?

Free consultation • Transparent pricing • No obligations

Avg. reply on WhatsApp < 1 hour (business days)

Secure • GDPR-ready • No spam