Skip to main content

Nginx Reverse Proxy with Docker Compose and HTTPS (2026)

A reverse proxy with Nginx is a server block with proxy_pass http://app:80;. Nginx receives the request on port 80 or 443 and forwards it to your app. In Docker Compose, run the nginx:1.30 image, publish ports 80 and 443, and refer to the app by its service name. The official Nginx image now includes an ACME module, so Nginx can get and renew Let's Encrypt certificates itself. You do not need Certbot.

Every file on this page was tested with Nginx 1.30 and Docker 29.

Comparing proxies first? See Traefik alternatives, HAProxy alternatives and Caddy alternatives.

Before you start​

  • A Linux server with Docker and Compose. See Install Docker Compose.
  • A domain with an A record that points to the server. This guide uses example.com.
  • Ports 80 and 443 open in the firewall. Let's Encrypt connects to port 80 to check that you own the domain.

Nginx with Docker Compose and HTTPS​

Create a folder such as /srv/proxy with this layout:

/srv/proxy/
├── compose.yaml
├── nginx.conf
└── conf.d/
├── default.conf
└── example.com.conf

compose.yaml:

services:
nginx:
image: nginx:1.30
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./conf.d:/etc/nginx/conf.d:ro
- acme:/var/lib/nginx-acme
depends_on:
- app
restart: unless-stopped

app:
image: traefik/whoami
restart: unless-stopped

volumes:
acme:

nginx.conf loads the ACME module and includes the site files. You rarely change it.

load_module modules/ngx_http_acme_module.so;

user nginx;
worker_processes auto;

events {
worker_connections 1024;
}

http {
include /etc/nginx/mime.types;
access_log /var/log/nginx/access.log;
include /etc/nginx/conf.d/*.conf;
}

conf.d/default.conf sets up Let's Encrypt and redirects all HTTP to HTTPS:

# Docker's internal DNS. The ACME module and "resolve" need it.
resolver 127.0.0.11 valid=10s;

acme_issuer letsencrypt {
uri https://acme-v02.api.letsencrypt.org/directory;
contact you@example.com;
state_path /var/lib/nginx-acme;
accept_terms_of_service;
}

# Redirect HTTP to HTTPS. The ACME module answers Let's Encrypt on port 80 first.
server {
listen 80 default_server;
return 301 https://$host$request_uri;
}

conf.d/example.com.conf is the reverse proxy for one site:

# "resolve" re-resolves the name when the app container gets a new IP.
upstream app {
zone app 64k;
server app:80 resolve;
}

server {
listen 443 ssl;
server_name example.com;

acme_certificate letsencrypt;
ssl_certificate $acme_certificate;
ssl_certificate_key $acme_certificate_key;
ssl_certificate_cache max=2;

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

Start it:

docker compose up -d
docker compose logs -f nginx

Open https://example.com. The first request can take a few seconds while Nginx gets the certificate. Replace traefik/whoami with your own image and app:80 with your service name and port.

What each part does​

SettingWhat it does
load_module ...acme_module.soTurns on the ACME module that ships in the official image.
resolver 127.0.0.11Docker's internal DNS server. Nginx uses it to find containers by service name.
acme_issuerA Let's Encrypt account. state_path stores the account key and certificates in the acme volume.
acme_certificate letsencryptGet a certificate for every name in server_name. Nginx renews it before it expires.
upstream + resolveNginx looks up app again when the container restarts with a new IP.
proxy_set_headerPasses the real host, client IP and protocol to your app.

Without resolve, Nginx looks up app once at start. When you recreate the app container, it gets a new IP, and Nginx keeps sending traffic to the old one until you reload.

Add another site​

Add one file per site to conf.d/, for example conf.d/api.example.com.conf:

upstream api {
zone api 64k;
server api:8000 resolve;
}

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

acme_certificate letsencrypt;
ssl_certificate $acme_certificate;
ssl_certificate_key $acme_certificate_key;
ssl_certificate_cache max=2;

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

Then test and reload. The new site gets its own certificate on the first request.

Apps in a different Compose project are on a different Docker network. Create a shared network with docker network create proxy, attach Nginx and the app to it with networks: [proxy], and declare it as external: true in both files. See Docker Compose networks.

Test and reload the config​

docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload

nginx -t checks the config. nginx -s reload applies it with no dropped connections. If the new config is invalid, Nginx keeps the old one.

The layout mounts the conf.d folder, not single files, on purpose. Many editors save a file by replacing it. A single-file bind mount then still points to the old file, and a reload does nothing. If you edit nginx.conf itself, run docker compose restart nginx afterwards.

WebSockets and large uploads​

Add these lines inside location / when your app needs them:

# WebSockets
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

# Uploads up to 100 MB (default is 1 MB)
client_max_body_size 100m;

# Long requests
proxy_read_timeout 300s;

Test with the Let's Encrypt staging server​

While you test, use Let's Encrypt staging to stay clear of rate limits. Change the uri in acme_issuer:

uri https://acme-staging-v02.api.letsencrypt.org/directory;

Browsers do not trust staging certificates. When everything works, change it back, delete the acme volume (docker compose down and docker volume rm proxy_acme; the name is <folder>_acme), and start again.

Nginx with Certbot instead​

The ACME module is new. If you already run Certbot, or use an Nginx build without the module, Certbot still works: it writes certificates to /etc/letsencrypt, and you point ssl_certificate at /etc/letsencrypt/live/example.com/fullchain.pem. You then have to reload Nginx after each renewal. The HAProxy guide shows a tested Certbot container setup with a renewal hook.

Troubleshooting​

502 Bad Gateway​

Nginx cannot reach the app. Run docker compose logs nginx | grep error:

  • connect() failed (111: Connection refused): the port in server app:80 is wrong, or the app listens only on 127.0.0.1 inside its container. Make it listen on 0.0.0.0.
  • no live upstreams: the app container is stopped or restarting. With resolve, Nginx picks it up again within about 15 seconds after it starts.

host not found in upstream "app"​

Nginx cannot find the service name. The app is not in the same Compose file or Docker network, or the name has a typo. Without resolve, Nginx also refuses to start when the app container is down. Use the upstream block with resolve shown above.

unknown directive "acme_issuer"​

The ACME module is not loaded. Check the load_module line at the top of nginx.conf. It must be in the main file, not in conf.d/.

ERR_TOO_MANY_REDIRECTS​

Cloudflare is in Flexible SSL mode and talks to Nginx over HTTP. Nginx redirects to HTTPS, Cloudflare requests HTTP again, and so on. Set Cloudflare's SSL mode to Full (strict).

Changes do nothing​

You edited a single-file mount, or you did not reload. Run nginx -t and nginx -s reload. See Test and reload the config.

Install Nginx without Docker (Ubuntu / Debian)​

sudo apt update
sudo apt install nginx

Put the site in /etc/nginx/sites-available/example.com, link it into sites-enabled, and proxy to the app on the same server with proxy_pass http://127.0.0.1:3000;. Then:

sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Ubuntu's nginx package does not include the ACME module. Use Certbot for HTTPS there (sudo apt install certbot python3-certbot-nginx and sudo certbot --nginx -d example.com), or install Nginx from the nginx.org repository and the nginx-module-acme package.

When to use Nginx​

  • You need caching, rewrites, rate limits or static file serving next to the proxy.
  • You want full control of every header and timeout.
  • Your team already knows the Nginx config format.

When not to use it:

Frequently asked questions

How do I set up Nginx as a reverse proxy in Docker Compose?

Run the nginx:1.30 image with ports 80 and 443 published and mount a config folder at /etc/nginx/conf.d. In a server block, use proxy_pass http://app:80; where app is the Compose service name of your application. Both services in one compose file share a network, so Nginx reaches the app by name.

How do I get HTTPS for Nginx in Docker without Certbot?

The official Nginx image includes the ngx_http_acme_module. Load it with load_module, define an acme_issuer for Let's Encrypt, and add acme_certificate plus ssl_certificate $acme_certificate to the server block. Nginx gets and renews the certificate itself. Keep its state_path in a volume.

Why does Nginx return 502 Bad Gateway in Docker?

Nginx cannot connect to the app. The port in proxy_pass is wrong, the app listens on 127.0.0.1 instead of 0.0.0.0 inside its container, the app is on another Docker network, or the app was recreated with a new IP and Nginx still uses the old one. Use an upstream with server app:80 resolve and resolver 127.0.0.11 to fix the last case.

What does 'host not found in upstream' mean?

Nginx could not resolve the host name in proxy_pass when it started. The service is not running, is on another Docker network, or the name is wrong. An upstream with the resolve parameter lets Nginx start anyway and find the service once it is up.

How do I reload Nginx in Docker without downtime?

Run docker compose exec nginx nginx -t to check the config, then docker compose exec nginx nginx -s reload. Mount a config folder instead of single files, because some editors replace the file and the container keeps seeing the old one.

Nginx or Caddy as a reverse proxy?

Nginx if you need caching, rewrites, rate limits or detailed control. Caddy if you want automatic HTTPS and the shortest config. With the ACME module, Nginx now also gets certificates by itself, but the config is longer.



Deploy without managing Nginx​

Hostim.dev gives every app HTTPS, a domain, logs and metrics. No proxy config, no certificates to renew.

👉 Deploy an app with automatic HTTPS