How to Set Up an Nginx Reverse Proxy: Route All Your Self-Hosted Apps Through One Door

There’s a quiet moment that happens in every homelab journey. You’ve spun up a dozen services — your RSS reader, your笔记 tool, your media server, your dashboard — and suddenly every app wants its own port. localhost:8080, localhost:3000, localhost:9090. Your browser’s address bar becomes a phone book of numbers.

That’s when you discover the reverse proxy.

An Nginx reverse proxy sits in front of all your services and routes traffic by domain name. Instead of remembering ports, you type notes.yourdomain.com and Nginx forwards the request to the right container. Clean. Professional. The way the web was meant to feel.

What You’ll Need

  • A Linux server (Ubuntu 22.04+ recommended) with root or sudo access
  • Docker and Docker Compose installed
  • A domain name you control (even a free DuckDNS one works)
  • Port 80 and 443 open on your router/firewall
  • Basic familiarity with the terminal

Step 1: Create the Docker Network

First, create a shared Docker network so all your containers can talk to each other:

docker network create proxy-network

Why a custom network? Because Docker’s default bridge doesn’t support DNS resolution between containers by name. A user-defined bridge gives us that for free, plus better isolation.

Step 2: Create the Directory Structure

Let’s stay organized from the start:

mkdir -p ~/nginx-proxy/{conf.d,ssl,logs}
cd ~/nginx-proxy

Step 3: Write the Docker Compose File

Create ~/nginx-proxy/docker-compose.yml:

version: "3.8"

services:
  nginx:
    image: nginx:alpine
    container_name: nginx-proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./conf.d:/etc/nginx/conf.d:ro
      - ./ssl:/etc/nginx/ssl:ro
      - ./logs:/var/nginx/logs
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    networks:
      - proxy-network
    restart: unless-stopped

networks:
  proxy-network:
    external: true

We’re using the official nginx:alpine image — tiny (~23MB) and rock solid. The restart: unless-stopped policy means it’ll survive reboots and crashes automatically.

Step 4: Write the Base Nginx Config

Create ~/nginx-proxy/nginx.conf:

user  nginx;
worker_processes  auto;

error_log  /var/nginx/logs/error.log notice;
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/nginx/logs/access.log  main;

    sendfile        on;
    tcp_nopush      on;
    keepalive_timeout  65;

    # Include all site configs
    include /etc/nginx/conf.d/*.conf;
}

Step 5: Add Your First Proxy Host

Let’s say you’re running a service on port 3000 (maybe a Node.js app or a Grafana dashboard). Create ~/nginx-proxy/conf.d/app.example.com.conf:

server {
    listen 80;
    server_name app.YOURDOMAIN.com;

    # Tell the backend who we are
    location / {
        proxy_pass http://app-container: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;
    }
}

Replace YOURDOMAIN.com with your actual domain and app-container with your Docker service name. The magic is in the proxy_set_header lines — they ensure your backend app knows the real client IP and the protocol used, not the proxy’s internal details.

Step 6: Add More Services

That’s it! For each new service, just drop another .conf file into conf.d/:

# Grafana
server {
    listen 80;
    server_name grafana.YOURDOMAIN.com;
    location / {
        proxy_pass http: //grafana: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;
    }
}

Notice the pattern: each file is one server block, one domain, one proxy_pass. Beautifully repetitive.

Step 7: Start It Up

cd ~/nginx-proxy
docker compose up -d

# Verify it's running
docker compose ps

# Check the logs if something looks wrong
docker compose logs nginx

Step 8: Point Your DNS

For each subdomain, add an A record pointing to your server’s IP. Most DNS providers support wildcard records, so you can add *.yourdomain.com → YOUR_SERVER_IP to cover all subdomains at once.

Step 9: Add HTTPS (Free, Automatic)

Once you have the HTTP proxy working, adding HTTPS is a small tweak. Extend your docker-compose.yml to include Certbot:

  certbot:
    image: certbot/certbot
    container_name: certbot
    volumes:
      - ./ssl:/etc/letsencrypt
      - ./conf.d:/etc/nginx/conf.d
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"

Then update your server blocks to listen on 443. But honestly? Get the HTTP version working first. HTTPS is easier to add later than debugging why services won’t connect.

Common Pitfalls

  • 502 Bad Gateway: Nginx can’t reach your backend. Check that the container is on the same Docker network and the service name matches exactly.
  • Docker volume paths: Use relative paths in conf.d/ and absolute paths for your nginx.conf. Mixing these up is the #1 cause of “config test failed” errors.
  • WebSocket services: If you’re proxying something like Nextcloud Talk or a chat app that uses WebSockets, add these lines inside your location / block:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
  • Forgotten headers: Missing X-Forwarded-Proto causes endless redirect loops in apps behind proxies. Always include all four header lines.

Why This Matters

An Nginx reverse proxy isn’t just a convenience — it’s an architectural pattern that scales. Start with two services, and you’ll find yourself adding ten more wondering how you ever lived without it. One domain, one port, zero memorized port numbers.

And there’s something deeply satisfying about typing a clean subdomain in your browser and seeing your own infrastructure answer back. That’s the homelab dream right there.

Leave a Reply

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