Setting Up Nginx Reverse Proxy with Docker Containers

Setting Up Nginx Reverse Proxy with Docker Containers

When I first started running multiple self-hosted services on my homelab, I quickly realized that managing port mappings for each application was a nightmare. Every service needed its own port: Nextcloud on 8080, Vaultwarden on 8081, Jellyfin on 8082. Then came the problem of exposing them cleanly to my LAN and managing SSL certificates for each. That's when I deployed Nginx in Docker as a reverse proxy, and it transformed my entire infrastructure.

In this tutorial, I'll walk you through setting up Nginx as a containerized reverse proxy to elegantly route traffic from multiple services through a single port. You'll learn how to configure it in Docker Compose, set up SSL certificates, and handle complex routing scenarios—all without touching your host's iptables or fiddling with port-forward chaos.

Why Nginx in Docker for Reverse Proxying?

I prefer Nginx in Docker because it's lightweight, battle-tested, and runs in isolation. Unlike Traefik or Caddy, Nginx doesn't automatically reload configuration on every docker event—I have full control. The container itself is tiny (roughly 200MB), and I can version-lock my config alongside my other services in Docker Compose.

The reverse proxy sits at the edge of your service mesh. All incoming traffic (http://your-domain/ or http://192.168.1.50/) hits Nginx first. Nginx then routes each request to the correct internal service based on the hostname or path you define. This means:

Prerequisites

You need Docker and Docker Compose installed. If you're running this on a VPS (I recommend checking out RackNerd for around $40/year for a basic public VPS with a usable spec), ensure Docker is set up. You'll also need:

Step 1: Create the Nginx Configuration

I keep my Nginx config in a separate directory, mounted as a volume into the container. This lets me reload Nginx without restarting the entire container.

Create a folder structure:

mkdir -p ~/docker/nginx/conf.d ~/docker/nginx/certs

Now create the main Nginx config at ~/docker/nginx/nginx.conf:

user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /var/log/nginx/access.log main;

    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;
    client_max_body_size 100M;

    gzip on;
    gzip_vary on;
    gzip_min_length 1000;
    gzip_types text/plain text/css text/xml text/javascript
               application/x-javascript application/xml+rss
               application/rss+xml application/javascript;

    # Rate limiting
    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=strict:10m rate=2r/s;

    # Include service-specific configs
    include /etc/nginx/conf.d/*.conf;
}

Now create ~/docker/nginx/conf.d/default.conf for your first service:

# Nextcloud upstream
upstream nextcloud {
    server nextcloud:80;
}

# Vaultwarden upstream
upstream vaultwarden {
    server vaultwarden:80;
}

# HTTP to HTTPS redirect
server {
    listen 80;
    server_name _;
    return 301 https://$host$request_uri;
}

# HTTPS server block - Nextcloud
server {
    listen 443 ssl http2;
    server_name nextcloud.home.lab;

    ssl_certificate /etc/nginx/certs/cert.pem;
    ssl_certificate_key /etc/nginx/certs/key.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    limit_req zone=general burst=20 nodelay;

    location / {
        proxy_pass http://nextcloud;
        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_buffering off;
    }
}

# HTTPS server block - Vaultwarden
server {
    listen 443 ssl http2;
    server_name vaultwarden.home.lab;

    ssl_certificate /etc/nginx/certs/cert.pem;
    ssl_certificate_key /etc/nginx/certs/key.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;

    limit_req zone=general burst=20 nodelay;

    location / {
        proxy_pass http://vaultwarden;
        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;
    }
}
Tip: If you don't have real SSL certificates yet, generate self-signed certs with: openssl req -x509 -newkey rsa:2048 -keyout ~/docker/nginx/certs/key.pem -out ~/docker/nginx/certs/cert.pem -days 365 -nodes. For production, use Let's Encrypt with Certbot.

Step 2: Docker Compose Configuration

Create ~/docker/docker-compose.yml. I include placeholder services so you can test immediately:

version: '3.8'

services:
  nginx:
    image: nginx:1.25-alpine
    container_name: nginx-proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/certs:/etc/nginx/certs:ro
      - nginx_logs:/var/log/nginx
    networks:
      - reverse-proxy
    restart: unless-stopped
    depends_on:
      - nextcloud
      - vaultwarden

  nextcloud:
    image: nextcloud:27-apache
    container_name: nextcloud
    environment:
      NEXTCLOUD_ADMIN_USER: admin
      NEXTCLOUD_ADMIN_PASSWORD: changeme123
      NEXTCLOUD_TRUSTED_DOMAINS: "nextcloud.home.lab"
    volumes:
      - nextcloud_data:/var/www/html
    networks:
      - reverse-proxy
    restart: unless-stopped

  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    environment:
      DOMAIN: "https://vaultwarden.home.lab"
      SIGNUPS_ALLOWED: "false"
    volumes:
      - vaultwarden_data:/data
    networks:
      - reverse-proxy
    restart: unless-stopped

volumes:
  nextcloud_data:
  vaultwarden_data:
  nginx_logs:

networks:
  reverse-proxy:
    driver: bridge

Start the stack:

cd ~/docker
docker-compose up -d

Verify Nginx is running:

docker-compose logs nginx

Step 3: Testing and Routing

If you're on the same local network, add entries to your /etc/hosts file (on your client machine, not the server):

192.168.1.50 nextcloud.home.lab
192.168.1.50 vaultwarden.home.lab

Replace 192.168.1.50 with your server's IP. Then open your browser to https://nextcloud.home.lab. You'll likely see a certificate warning (since we're using self-signed certs), but the important part is that Nginx is routing the request correctly.

Watch out: Make sure your upstream service names match your Docker Compose service names exactly. If you define service: nextcloud in Compose, your upstream must be upstream nextcloud { server nextcloud:80; }. Docker's internal DNS resolves these automatically on the same network.

Step 4: Adding More Services

Once you've tested the setup, adding new services is straightforward. For example, to add Jellyfin, create a new upstream and server block in conf.d/default.conf:

upstream jellyfin {
    server jellyfin:8096;
}

server {
    listen 443 ssl http2;
    server_name jellyfin.home.lab;

    ssl_certificate /etc/nginx/certs/cert.pem;
    ssl_certificate_key /etc/nginx/certs/key.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;

    limit_req zone=general burst=20 nodelay;

    location / {
        proxy_pass http://jellyfin;
        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_buffering off;
    }
}

Then reload Nginx without restarting:

docker-compose exec nginx nginx -t && docker-compose exec nginx nginx -s reload

Path-Based Routing (Optional)

Instead of subdomains, you can route by path. Modify your server block:

server {
    listen 443 ssl http2;
    server_name example.com;

    location /nextcloud/ {
        proxy_pass http://nextcloud/;
        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;
    }

    location /vaultwarden/ {
        proxy_pass http://vaultwarden/;
        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;
    }
}

Path-based routing is simpler if you only have one domain, but some applications (especially those that generate absolute URLs) expect to be at the root, so subdomains are more reliable for a homelab.

Production Considerations

If you're running this on a public VPS (try RackNerd—around $40/year gets you a solid starter VPS with enough specs for this), you'll want Let's Encrypt certificates. Use Certbot inside a container or on the host:

sudo certbot certonly --standalone -d nextcloud.example.com -d vaultwarden.example.com

Then mount those certs into Nginx:

volumes:
  - /etc/letsencrypt/live/example.com/fullchain.pem:/etc/nginx/certs/cert.pem:ro