Nginx reverse proxy for Docker containers: a practical guide

Configure Nginx as a reverse proxy routing multiple Docker containers by domain, with TLS, correct headers and isolated network.

When you have more than one HTTP container running on the same VPS, publishing ports on the host quickly turns into chaos: each service on a different port, firewall with 8 holes, replicated TLS certificate, scattered logs. The industry-standard solution is to put Nginx in front doing reverse proxy — one public port (443), multiple backends routed by hostname.

This tutorial shows how to run Nginx in a container, connected to backends via a dedicated Docker network, without exposing any backend port to the host. We’ll cover domain-based routing, correct proxy headers, automatic TLS via Let’s Encrypt and reload without downtime.

The target is a sysadmin with 2-3 containers already running (an API, an admin, a public app) who wants to serve everything at api.example.com, admin.example.com and example.com from the same IP. Estimated time: 30-40 minutes for the initial config, including certificate issuance.

Prerequisites

What you need before starting

VPS with Ubuntu 22.04+ or Debian 12+, Docker Engine 24+ and Docker Compose v2 installed. Root access or user in the docker group. DNS of the domains already pointing to the VPS IP (A records with low TTL during setup). Ports 80 and 443 open on the firewall and no other process listening on them.

HTTP Port 80
HTTPS Port 443
Docker Network proxy-net
Config Directory /opt/nginx-proxy

Before continuing, validate that nothing is using the ports:

sudo ss -tlnp | grep -E ':80|:443'

If Apache, another host Nginx or some already-bound container shows up, stop before continuing — two things listening on 443 don’t coexist.

Solution architecture

The design is: a user-defined Docker network called proxy-net where Nginx and all backends live. The backends don’t publish any port to the host (no -p in compose). Nginx is the only container with ports: 80:80 and 443:443. Inside the network, it resolves each backend by container name via Docker’s internal DNS.

Advantages: an attacker on the public internet only sees 80/443. The backends stay isolated — even if the application has an admin endpoint without auth, it isn’t exposed. And adding a new service is a compose change plus a server block in Nginx, without touching the firewall.

Creating the dedicated network

Containers on different networks don’t see each other by name (the default bridge is the exception, but DNS there only works by IP, not by name). Our entire stack needs to be on the same user-defined network.

01

Create the proxy-net network:

docker network create proxy-net

This shows up in docker network ls as a bridge driver. You can check it with docker network inspect proxy-net — it will be empty for now.

02

Create the reverse proxy working directory:

sudo mkdir -p /opt/nginx-proxy/{conf.d,certs,vhost,html}
sudo chown -R $USER:$USER /opt/nginx-proxy
cd /opt/nginx-proxy

conf.d holds the server blocks per domain. certs is where issued certificates will live. vhost allows per-host override. html serves the Let’s Encrypt ACME challenge.

Bringing up Nginx in a container

We’ll use the official nginx:1.27-alpine image with config mounted via bind mount. That way you edit files on the host and reload Nginx without rebuilding the image.

03

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

services:
  nginx:
    image: nginx:1.27-alpine
    container_name: nginx-proxy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./conf.d:/etc/nginx/conf.d:ro
      - ./certs:/etc/nginx/certs:ro
      - ./vhost:/etc/nginx/vhost.d:ro
      - ./html:/usr/share/nginx/html:ro
    networks:
      - proxy-net

networks:
  proxy-net:
    external: true

external: true tells Compose the network already exists (we created it in step 01) — without it, Compose creates a new one prefixed with the project name and your Nginx won’t see the backends.

04

Create a default server block at conf.d/default.conf:

server {
    listen 80 default_server;
    server_name _;

    location /.well-known/acme-challenge/ {
        root /usr/share/nginx/html;
    }

    location / {
        return 444;
    }
}

return 444 is Nginx’s non-standard code that closes the connection without a response — useful for rejecting requests with unknown Host (bots scanning the IP directly).

05

Bring up Nginx:

docker compose up -d
docker compose logs nginx

The logs should show start worker processes without errors. If bind() to 0.0.0.0:80 failed appears, something on the host is on the port — go back and investigate before continuing.

Connecting backends to the proxy

The containers you already have running are probably on the default network (bridge) or on one created by their Compose. They also need to join proxy-net so Nginx can resolve them by name.

06

Add proxy-net to each backend’s compose. Example for an API:

services:
  api:
    image: my-api:latest
    container_name: api-backend
    restart: unless-stopped
    networks:
      - default
      - proxy-net
    # NOTE: no `ports:` — does not publish on the host

networks:
  proxy-net:
    external: true

Recreate the container: docker compose up -d --force-recreate. Keep the default network for internal dependencies (database, redis) and add proxy-net to be reachable by Nginx.

07

Validate that Nginx sees the backend by name:

docker exec nginx-proxy ping -c 2 api-backend

A response with IP 172.x.x.x means the network DNS is working. bad address means the backend isn’t on proxy-net — go back and check docker network inspect proxy-net.

Configuring the reverse proxy by domain

Each domain becomes a separate file in conf.d/. This makes versioning and debugging easier — you read only the relevant vhost.

08

Create conf.d/api.example.com.conf:

upstream api_backend {
    server api-backend:3000;
    keepalive 32;
}

server {
    listen 80;
    server_name api.example.com;

    location /.well-known/acme-challenge/ {
        root /usr/share/nginx/html;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

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

    ssl_certificate     /etc/nginx/certs/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/api.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    client_max_body_size 50m;

    location / {
        proxy_pass http://api_backend;
        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;
        proxy_set_header Connection        "";
        proxy_read_timeout 60s;
    }
}

A named upstream with keepalive 32 maintains a pool of reusable connections to the backend — it reduces latency by 5-15ms per request compared to opening a new TCP every time. proxy_http_version 1.1 + Connection "" is mandatory for keepalive to work.

Replicate for each domain

For admin.example.com and example.com, copy the file changing server_name, upstream and the certificate path. Don’t try to put multiple server_name values in the same block — Let’s Encrypt issues per name, and that complicates renewal.

Issuing TLS certificates

We’ll use certbot standalone via container, avoiding installing Python on the host. Nginx serves the challenge at /.well-known/acme-challenge/ that we configured in step 04.

09

Issue the certificate for each domain:

docker run --rm \
  -v /opt/nginx-proxy/certs:/etc/letsencrypt \
  -v /opt/nginx-proxy/html:/var/www/html \
  certbot/certbot certonly \
  --webroot -w /var/www/html \
  -d api.example.com \
  --email [email protected] \
  --agree-tos --no-eff-email

The fullchain.pem and privkey.pem files appear at /opt/nginx-proxy/certs/live/api.example.com/. Adjust the ssl_certificate in the server block to point to live/ instead of the path I used in the example (which was simplified).

10

Validate the config and reload:

docker exec nginx-proxy nginx -t
docker exec nginx-proxy nginx -s reload

nginx -t parses everything without applying — if a syntax error or a missing cert file appears, it refuses the reload and keeps the previous config running. Zero downtime guaranteed.

Automatic renewal

Add a daily cron running certbot renew --webroot -w /var/www/html followed by docker exec nginx-proxy nginx -s reload. Let’s Encrypt expires in 90 days — renewing every 60 gives you margin if some DNS provider lags.

Verification

Test each domain externally:

curl -I https://api.example.com/healthz

You should see HTTP/2 200, server: nginx and the headers from your backend. If 502 Bad Gateway comes up, Nginx reached but the upstream went down — docker logs api-backend shows what happened. If 504 Gateway Timeout comes up, the backend is alive but didn’t respond in 60s — adjust proxy_read_timeout or investigate slowness in the application.

To confirm that the client’s real IP is reaching the backend, log X-Forwarded-For in the application and hit a curl from another host. If 172.x.x.x shows up instead of the client’s public IP, some intermediate proxy is rewriting it — adjust the proxy trust setting in the backend framework.

Next steps

From here, it’s worth enabling rate limiting with limit_req_zone for sensitive endpoints (/login, /api/admin), adding Brotli as a compression module to replace gzip, separating logs per domain with a specific access_log per server block, and moving the config to a versioned git repository.

If you’re putting this into serious production, a Hostini VPS already comes with Docker pre-installed, DDoS protection at the edge filtering attacks before they even reach Nginx, and automatic snapshots to revert any broken config in seconds — explore the plans at /vps.

Frequently asked questions

Why use Nginx in front of Docker instead of publishing ports directly?

Publishing ports (-p 8080:80) works for one container, but it doesn't scale. With Nginx in front you use a single port 80/443, route multiple domains to the same IP, terminate TLS in one place, centralize access logs and gain rate limiting, cache and WAF if you want. The containers stay on an isolated network without exposing ports on the host.

Do I need Nginx running on the host or can I run it in a container too?

Both work. Nginx in a container is cleaner (config versioned in compose, easy to bring up on another machine) but it requires Nginx to be on the same Docker network as the backends to resolve their names. Nginx on the host is easier to debug but you need to point to 127.0.0.1:published_port of each container. This guide uses Nginx in a container.

How does Nginx discover the backend container's IP?

Docker has internal DNS in every user-defined network. When you define a container named 'app' on the 'proxy-net' network, other containers on the same network resolve 'app' to the current IP via embedded DNS. That's why the Nginx upstream uses the container name, not a fixed IP — Docker rewrites it every time the container restarts.

Why do I need proxy_set_header Host and X-Forwarded-For?

Without Host, the backend receives Host: localhost (or internal name) and breaks any domain-based logic (cookies, redirects, multi-tenant). Without X-Forwarded-For, the backend sees Nginx's IP (172.x.x.x) on every request — logs, rate limiting and geoip become useless. These are the 2 mandatory headers in any reverse proxy.

How do I reload Nginx config without dropping active connections?

docker exec nginx-proxy nginx -s reload does a hot reload — the master process recompiles the config and old workers finish in-flight requests before dying. Zero downtime. Before reloading, always run docker exec nginx-proxy nginx -t to validate syntax — a broken config with reload keeps Nginx serving the previous one, but if a worker restarts from scratch it fails.

Does it work with WebSocket and HTTP/2?

WebSocket requires proxy_set_header Upgrade and Connection on the location — without it the handshake fails and the client falls back to long polling. HTTP/2 works out-of-the-box on listen 443 ssl http2, but only between client and Nginx; from Nginx to the backend it stays HTTP/1.1 (which is fine for 99% of cases). For gRPC, you need grpc_pass instead of proxy_pass.

Topics:
Next steps Ryzen cloud with NVMe storage and always-on DDoS protection.Go live on a Hostini VPS →
Was this tutorial helpful?
Chat on WhatsApp