← Lab Notes
July 2, 2026 homelabdockernetworking

Networking Basics #4: Reverse Proxies — One nginx Front Door for a Dozen Containers

Learn how to use a single nginx instance as a reverse proxy to route traffic to multiple Docker containers.


Overview

In this installment of our Networking Basics series, I'll walk you through setting up a reverse proxy using Nginx in my homelab environment. The goal is to manage and route traffic to several web services running inside Docker containers via a single entry point.

flowchart LR
  I[Internet] --> N[nginx reverse proxy]
  N --> H[homepage]
  N --> B[blog backend]
  N --> A[api proxy]
  N --> S[support desk]

Background

Running multiple web services in Docker containers can sometimes lead to port conflicts, especially when all services need to listen on the same public ports like 80 and 443. A reverse proxy acts as a unified front door that handles incoming requests and routes them appropriately based on the hostname or URL path.

In my setup, I have a dozen web services running in Docker containers: a homepage, blog backend, support desk, whiteboard, API backends, etc. Each service needs to be accessible via its own unique endpoint, but they can't all listen on port 443 simultaneously. This is where Nginx comes into play as the reverse proxy.

How It Works

Unified Nginx Container

I use a single Nginx container named unified-nginx as the reverse proxy. This container receives all incoming requests and routes them to the appropriate backend service based on the hostname or URL path. Here's an example of how the routing is configured in the Nginx configuration file:

server {
    listen 80;
    server_name breadtoasting.com;

    location / {
        proxy_pass http://breadtoasting-homepage:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /blog {
        proxy_pass http://blog-backend:3001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # More locations for other services...
}

server {
    listen 80;
    server_name draw.breadtoasting.com;

    location / {
        proxy_pass http://excalidraw:3002;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# Additional server blocks for other services...

Container Configuration

Each service container is configured to listen on a specific port. For example, the breadtoasting-homepage service listens on port 3000:

docker run -d --name breadtoasting-homepage \
    -p 3000:3000 \
    -v /path/to/static/files:/app/public \
    my-homepage-service

Nginx Configuration File

The Nginx configuration file is bind-mounted into the container as a single file. This setup ensures that any changes to the configuration are immediately reflected in the running instance of Nginx. Here's an example of how the Nginx config file might look:

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

    sendfile on;
    tcp_nopush on;
    keepalive_timeout 65;

    gzip on;
    gzip_types text/plain application/javascript application/json application/x-javascript text/css text/xml application/xml application/xml+rss text/javascript;

    upstream breadtoasting-homepage {
        server localhost:3000;
    }

    upstream blog-backend {
        server localhost:3001;
    }

    # More upstream blocks for other services...

    server {
        listen 80;
        server_name breadtoasting.com;

        location / {
            proxy_pass http://breadtoasting-homepage;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        # More locations for other services...
    }
}

Gotchas

One hard-won lesson in setting up the reverse proxy is related to how Nginx handles configuration file updates. The Nginx config file is bind-mounted into the container as a single file, which means that editing it on the host replaces the file's inode. This can cause issues because an nginx reload inside the container still sees the old file.

To ensure that changes take effect, I had to restart the unified-nginx container after making any changes to the Nginx configuration:

docker restart unified-nginx

This issue cost me a confused hour once when I thought an nginx reload was sufficient. It's worth noting that this is specific to how Docker handles file mounts and might not be an issue in other environments.

Results

With the reverse proxy setup, all my web services are now accessible via a single entry point. This simplifies management and ensures that each service can listen on its own port without conflicting with others. Additionally, I've added some useful features like gzip compression, cache headers for static assets, and timeouts per route to improve performance and reliability.

Lessons Learned

  1. Nginx Configuration File Updates: Be aware of how Nginx handles configuration file updates when using bind mounts in Docker.
  2. Single Entry Point: A reverse proxy can significantly simplify managing multiple web services by providing a unified front door for incoming traffic.
  3. Custom Headers: Setting custom headers like Host, X-Real-IP, and X-Forwarded-Proto is crucial for proper routing and security.

By setting up this reverse proxy, I've streamlined my homelab environment and made it easier to manage multiple web services.

Was this useful?