Docker + Nginx Reverse Proxy: The Setup I Use for Every VPS Project

Docker + Nginx Reverse Proxy: The Setup I Use for Every VPS Project

Every self-hosted project I run—whether it’s a map tile server, an API, or a small internal tool—ends up with the same two-layer pattern: Nginx sitting in front, and the actual application bound to 127.0.0.1 behind it. It’s not a fancy setup, but it’s the one decision that saves you from most of the security and TLS headaches that come with running your own infrastructure.

If you followed the self-hosted MapLibre tile server guide, this is the piece that made the tileserver container safe to expose in the first place. This article stands on its own too—the pattern applies to almost anything you self-host.

The core idea

Never expose an application container directly to the internet. Instead:

  1. The application binds only to 127.0.0.1 (localhost), invisible outside the machine.
  2. Nginx runs in its own container, is the only thing with a public port open, and forwards requests to the application over the internal Docker network or via 127.0.0.1.
  3. TLS termination happens once, at Nginx—your application never has to think about certificates.

This buys you three things: a single place to manage TLS, a single place to add rate limiting or IP restrictions, and an application that’s structurally incapable of being reached directly, even if you misconfigure something downstream.

Docker Compose structure

version: "3.8"
services:
  app:
    build: ./app
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000"
    networks:
      - internal

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs:/etc/nginx/certs:ro
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - app
    networks:
      - internal

networks:
  internal:
    driver: bridge

Two things worth noticing: the app service publishes its port bound explicitly to 127.0.0.1, not 0.0.0.0 (the default if you just write "3000:3000"). That one detail is the difference between “only this machine can reach it” and “the entire internet can reach it directly, bypassing Nginx entirely.” Get this wrong, and every security decision you make in Nginx is theater.

The Nginx config

server {
    listen 443 ssl;
    server_name yourdomain.com;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

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

server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$host$request_uri;
}

The three proxy_set_header lines matter more than they look. Without them, your application sees every request as coming from Nginx’s internal IP, not the real client—which breaks logging, rate limiting, and any geo-based logic you might add later. This is a mistake that doesn’t error out, it just quietly gives you wrong data forever.

TLS certificates: Let’s Encrypt via Certbot

Don’t pay for certificates or manage them by hand. Run Certbot in its own short-lived container, or as a cron job on the host, targeting the same volume Nginx reads from:

docker run --rm \
  -v ./certs:/etc/letsencrypt \
  -v ./certbot-webroot:/var/www/certbot \
  certbot/certbot certonly --webroot \
  -w /var/www/certbot -d yourdomain.com

Set this to renew automatically (a cron entry running the same command with renew instead of certonly covers it) — expired certificates are one of the most common self-inflicted outages in self-hosted setups, and they’re entirely avoidable with a two-line cron job.

Running multiple services behind one Nginx

Once you have this pattern, adding a second service is just another location block or another server_name:

server {
    listen 443 ssl;
    server_name api.yourdomain.com;
    ssl_certificate     /etc/nginx/certs/api-fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/api-privkey.pem;

    location / {
        proxy_pass http://api-app:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

This is how a single small VPS ends up comfortably running a tile server, an API, and a couple of internal tools at once—each in its own container, none of them directly exposed, all of them behind the same Nginx layer with independent TLS certs and subdomains.

Common mistakes worth avoiding

  • Publishing app ports to 0.0.0.0 instead of 127.0.0.1 — silently defeats the entire point of this setup.
  • Forgetting X-Forwarded-For — your application logs will show every request coming from the same internal IP, making abuse or debugging impossible to trace.
  • Letting certificates expire — automate renewal from day one, don’t rely on remembering.
  • Putting the application container on the same network as unrelated services without a reason — keep your Docker networks scoped to what actually needs to talk to each other.

Why this is worth the extra layer

It’s tempting, especially early on, to just expose your application’s port directly and skip Nginx. It works, until it doesn’t — the day you need a second domain, a rate limit, or a TLS cert, you’re retrofitting a proxy layer onto a live system instead of having designed for it from the start. Setting this up costs you maybe twenty extra minutes on day one. Not having it costs you a migration under pressure later.

Leave a Comment

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

Scroll to Top