Skip to content

How to Run an Nginx Reverse Proxy with Docker

Set up an Nginx reverse proxy with Docker Compose: route multiple apps on one host, configure proxy_pass, use Docker networks, and add TLS.

SDSysadmin Desk June 27, 2026 9 min read
Diagram-style cover showing an Nginx reverse proxy routing traffic to multiple app containers on a shared Docker network

Running one container is easy — publish its port and you’re done. Running three web apps on a single server gets ugly fast. Do you really want users hitting :8080, :8081, and :8082, and a separate TLS certificate wired into each one? That doesn’t scale and it’s a pain to maintain.

A reverse proxy fixes it. One Nginx container sits on ports 80 and 443, accepts every incoming request, and forwards it to the right app based on the hostname or URL path. The apps themselves never publish a port to the host — they only talk to the proxy over an internal Docker network.

This guide builds that setup with Docker Compose: two example apps behind one Nginx proxy, routed by hostname, with the proxy_pass config explained line by line. Then a short section on adding TLS so the whole thing serves HTTPS. If you’re new to Compose files, the Compose beginner guide covers the basics this builds on.

Why a reverse proxy

Publishing ports directly is fine for a single service. Add more and the problems pile up:

  • Port sprawl. Every app needs its own host port, and your users have to remember them. Nobody wants to share a link ending in :8083.
  • TLS everywhere. Without a proxy, each app terminates HTTPS itself, so you’re managing certificates in multiple containers.
  • No central routing. You can’t send app.example.com and blog.example.com to different containers on the same port 443 without something in front making that decision.

The proxy solves all three. It owns 80 and 443, terminates TLS once, and routes by hostname or path to whichever container should handle the request. The apps go back to being simple — they listen on their own port, internally, and never worry about the outside world.

One proxy, many apps
flowchart LR
U["Browser"] -->|app1.example.com| N["nginx proxy<br/>:80 / :443"]
U2["Browser"] -->|app2.example.com| N
N -->|proxy_pass http://app1:3000| A1["app1 container"]
N -->|proxy_pass http://app2:8000| A2["app2 container"]
subgraph net["shared Docker network: web"]
  N
  A1
  A2
end
Nginx owns ports 80/443 and routes each request to the right app container over the internal Docker network. The apps publish nothing to the host.

The Compose setup

Here’s the full stack: one proxy and two apps, all on a shared network called web. Notice that the apps don’t have a ports: section at all — only the proxy publishes to the host.

services:
  proxy:
    image: nginx:1.27
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app1
      - app2
    networks:
      - web

  app1:
    image: my-app-one:latest   # listens on 3000 internally
    restart: unless-stopped
    networks:
      - web

  app2:
    image: my-app-two:latest   # listens on 8000 internally
    restart: unless-stopped
    networks:
      - web

networks:
  web:

Two details carry the whole design. First, the apps have no published ports — they’re reachable only inside the web network, through the proxy. Second, all three services join that same web network, which is what lets Nginx find them by name.

How Nginx finds the containers

This is the part that makes Docker reverse proxies click. On a user-defined Docker network, services reach each other by service name. Docker runs an internal DNS resolver, so the hostname app1 resolves to the app1 container’s IP automatically. No IP addresses to look up, no links to wire.

That’s why the proxy config can say proxy_pass http://app1:3000 and it just works — app1 is the service name, 3000 is the port the app listens on inside its container. The port doesn’t need publishing because the proxy is talking to it over the shared network, not through the host.

Writing the proxy_pass config

Now the nginx.conf that the proxy mounts. This routes by hostname: requests for app1.example.com go to the first app, requests for app2.example.com go to the second.

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

    location / {
        proxy_pass http://app1: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 app2.example.com;

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

Walking through what each piece does:

Key directives in the proxy config

listen 80 The port Nginx accepts requests on (published to the host).
server_name Which hostname this block answers for — how Nginx routes by domain.
proxy_pass http://app1:3000 Forward matched requests to the app1 container on its internal port.
proxy_set_header Host Pass the original Host header so the app sees the real domain.
X-Real-IP / X-Forwarded-For Tell the app the client's real IP, not the proxy's.
X-Forwarded-Proto Tell the app whether the original request was http or https.

Those proxy_set_header lines matter more than they look. Without them, the app sees every request as coming from the proxy’s IP over plain HTTP, which breaks logging, redirects, and anything that checks the client address. Forwarding the headers keeps the app aware of the real request.

After changing nginx.conf, reload the proxy without restarting the stack:

docker compose exec proxy nginx -s reload

Routing by path instead of hostname

Hostnames are the common pattern, but you can also route by URL path when you only have one domain. Use separate location blocks:

server {
    listen 80;
    server_name example.com;

    location /app1/ {
        proxy_pass http://app1:3000/;
    }

    location /app2/ {
        proxy_pass http://app2:8000/;
    }
}

The trailing slashes matter here. With a trailing slash on both the location and the proxy_pass target, Nginx strips the matched prefix before forwarding, so /app1/dashboard reaches the app as /dashboard. Drop the slash and the app receives the full /app1/dashboard, which usually isn’t what its router expects.

Adding TLS (HTTPS)

Terminating TLS at the proxy means you handle certificates in one place and the apps stay plain HTTP internally. The shape is a second server block on 443, with the certs mounted into the container, plus a redirect from 80 to 443.

server {
    listen 80;
    server_name app1.example.com;
    return 301 https://$host$request_uri;   # send everything to HTTPS
}

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

    ssl_certificate     /etc/nginx/certs/app1.crt;
    ssl_certificate_key /etc/nginx/certs/app1.key;

    location / {
        proxy_pass http://app1:3000;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Mount the certificate folder into the proxy container so Nginx can read those files:

  proxy:
    image: nginx:1.27
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs:/etc/nginx/certs:ro
    networks:
      - web

For real certificates you don’t want to renew by hand. The common approach is a companion container that obtains and renews certs from Let’s Encrypt automatically (ACME helpers and proxy images like nginx-proxy with an acme-companion, or Caddy as an Nginx alternative, all do this). That’s a topic of its own — the key idea to take away is that TLS terminates at the proxy, so the apps behind it never need certificates.

When it returns 502 Bad Gateway

A 502 from the proxy means Nginx is up but couldn’t get a good response from the upstream app. It’s the most common error in this setup, and the causes are short:

  • The app container isn’t running or hasn’t finished starting. Check docker compose ps.
  • The service name in proxy_pass doesn’t match the actual service name in the compose file.
  • The port in proxy_pass is wrong — it must be the port the app listens on inside the container, not a host port.
  • The proxy and the app aren’t on the same network, so DNS can’t resolve the name.

Work through them in that order and the cause is usually obvious.

Reverse proxy sanity check

  • Proxy and all apps share one Docker network
  • Only the proxy publishes ports (80 and 443); apps publish nothing
  • proxy_pass uses the service name and the app's internal port
  • proxy_set_header lines forward Host and X-Forwarded-* to the app
  • For HTTPS: 443 ssl block, certs mounted, port 80 redirects to 443
  • docker compose ps shows every service running before you test

Wrapping up

A reverse proxy turns “a pile of containers on random ports” into “one front door.” Nginx owns 80 and 443, routes each request to the right container by hostname or path using Docker’s service-name DNS, and terminates TLS in a single place. The apps stay simple, unpublished, and reachable only through the proxy.

The two rules that make it work: put everything on one shared network, and only publish the proxy’s ports. Get those right and adding a third or fourth app is just another server block. If you hit a port conflict bringing the proxy up, the port-already-allocated fix covers it, and the Docker & Containers guides go deeper on networking and storage.

Frequently asked questions

Why use a reverse proxy instead of just publishing each container's port?

Publishing ports works for one app, but it falls apart with several. A reverse proxy lets every app sit behind ports 80 and 443 and routes requests by hostname or path, so you don't expose a different port per service. It also centralizes TLS, so you terminate HTTPS in one place instead of per container.

How does Nginx reach the app containers?

Put the proxy and the apps on the same Docker network, then use the service name as the hostname in proxy_pass. Docker's built-in DNS resolves the service name to the container's IP, so proxy_pass http://app1:3000 finds the app1 container on port 3000. The app ports don't need to be published to the host at all.

Should I publish the app container ports to the host?

No. Once the proxy and apps share a network, only the proxy needs published ports (80 and 443). Leave the apps unpublished so they're reachable only through the proxy and not directly from the network. This is both cleaner and safer than exposing each app on its own host port.

What does proxy_pass actually do?

proxy_pass tells Nginx where to forward a matched request. In a Docker setup the target is the app's service name and internal port, like proxy_pass http://app1:3000. Nginx receives the client request on 80/443 and relays it to the upstream container, then passes the response back. Forwarding headers like Host and X-Forwarded-For keep the app aware of the original request.

How do I add HTTPS to the Nginx proxy?

Add a server block listening on 443 with ssl, point ssl_certificate and ssl_certificate_key at your certs mounted into the container, and redirect port 80 to 443. Most people automate certificate issuance with a companion container (such as an acme/Let's Encrypt helper) so renewals happen on their own. TLS is terminated at the proxy, so the apps behind it can stay plain HTTP internally.

Why am I getting a 502 Bad Gateway from the proxy?

A 502 means Nginx reached the proxy but couldn't get a valid response from the upstream. Usual causes: the app container isn't running yet, the service name in proxy_pass is wrong, the port is wrong, or the proxy and app aren't on the same Docker network. Check docker compose ps, confirm the name and port, and verify both services share a network.

Sources & further reading

Official vendor documentation referenced while writing this guide.

SD

Sysadmin Desk

Infrastructure & Cloud

Hands-on guidance for infrastructure, virtualization, and containers — Hyper-V, VMware, Docker, and the day-to-day operations work that keeps environments running.

MCSA Guru provides independent, educational IT guidance. Microsoft, Windows, Windows Server, Microsoft 365, Exchange, and Microsoft Teams are trademarks of Microsoft Corporation; Docker is a trademark of Docker, Inc. MCSA Guru is not affiliated with or endorsed by Microsoft or Docker. Always test changes in a safe environment before applying them in production.

Related guides

Diagram-style cover showing a temporary container archiving a Docker named volume to a tar file on the host

How to Back Up Docker Volumes

Back up Docker named volumes the reliable way: tar a volume via a temp container, restore it, dump databases properly, schedule it, and move data between hosts.

Sysadmin Desk Jul 19, 2026 8 min read

Fixing something right now?

Jump straight into the guide library or search for the exact error or task you are dealing with.