Key Takeaway
To set up an nginx reverse proxy for a Docker app: bind the container to 127.0.0.1, write a server block whose proxy_pass points at that port, forward the Host and X-Forwarded-For headers, and run nginx -t before reloading. Behind Cloudflare, use Full (strict) SSL mode and set_real_ip_from so there is no redirect loop and visitor IPs stay real.
Your app runs in a container and opens at 203.0.113.10:3000. Then you point your domain at the server, and what appears is the Welcome to nginx! page, a 502 Bad Gateway, or a page that loops until the browser gives up. This is where most beginners learn that installing nginx is only half the job. The other half is connecting nginx to the container, the domain and Cloudflare correctly.
This guide uses the same pattern as this site: docker-compose.yml binds the app to 127.0.0.1 with a comment saying nginx terminates TLS and proxies to that port. This site's own nginx config is not in the repository, so every block below comes from the nginx.org and Cloudflare documentation, not from a copy of my server's file. Follow the six steps in order, then use the error table at the end if something does not work.
Want the concepts first?
This guide is about steps and errors. If you prefer to learn from concept to practice, our free lesson covers reverse proxies, headers and caching behind Cloudflare: Nginx Reverse Proxy With Docker Compose, Behind Cloudflare. That lesson also has an exercise you can run on a laptop, no server needed.
What you need
Have these four things ready before you start. Every name below is a placeholder, so swap in your own.
- A Linux server (Debian or Ubuntu) with sudo access, for example a user named deploy and the address 203.0.113.10.
- Docker and Docker Compose installed, and an image of your app you can run (called my-app:latest here, listening on port 3000).
- A domain, example.com here, whose DNS is managed in Cloudflare.
- Ports 80 and 443 open in the server firewall.
Step 1: Run the app in a container, bound to loopback only
The first rule: the container must not be reachable straight from the internet. In this site's docker-compose.yml the app port is bound to 127.0.0.1, so the only way in from outside is through nginx. The same pattern works for your app. In ports, the left side of the colon sets the address and port on the host, and the right side is the port inside the container.
# docker-compose.yml
services:
web:
image: my-app:latest
restart: unless-stopped
ports:
# Wrong: "3000:3000" publishes on every interface, so anyone can skip nginx
# Right: left side = host address + port, right side = port in the container
- "127.0.0.1:3000:3000"
# Start it, then check the PORTS column and the app itself
docker compose up -d
docker compose ps
curl -I http://127.0.0.1:3000Expected output: Expected output from docker compose ps: the PORTS column shows 127.0.0.1:3000->3000/tcp, not 0.0.0.0:3000. Then curl -I to that address answers with status 200 (or 3xx if your app redirects).
The most common mistake in this step is writing 3000:3000 with no address. The port opens on every interface, anyone can skip nginx and Cloudflare, and none of the protection you set up in later steps applies to that path.
Step 2: Install nginx and check that it runs
On Debian or Ubuntu, nginx is available from the default repositories. Once installed, the service starts immediately and serves a welcome page on port 80. That is useful: if the page appears, nginx is healthy and any remaining problem is in your configuration.
sudo apt install -y nginx
nginx -v
sudo systemctl status nginx
curl -I http://127.0.0.1Expected output: Expected output: nginx -v prints nginx version: nginx/ followed by the installed version number, systemctl status shows active (running), and curl to 127.0.0.1 answers 200 with the Welcome to nginx! page.
Step 3: Write a server block with proxy_pass
A reverse proxy in nginx is basically one directive: proxy_pass, which sets the protocol and address of the proxied server. Put it inside a location in a server block. The main config file nginx.conf is usually in /etc/nginx, and the Debian and Ubuntu packages load the files in conf.d from there. Confirm it with nginx -T as shown below, because the layout can differ between distros.
# See which files this nginx really loads (layout differs per distro)
sudo nginx -T | grep include
# /etc/nginx/conf.d/example.com.conf
server {
listen 80;
server_name example.com www.example.com;
location / {
# No trailing slash and no path: the client's URI is passed on unchanged
proxy_pass http://127.0.0.1:3000;
}
}
# Test first, reload second. A failed reload keeps the old config running.
sudo nginx -t
sudo nginx -s reload
# Fake the domain locally to prove the server block matches
curl -I -H "Host: example.com" http://127.0.0.1Always run nginx -t before reloading. It checks the syntax and then tries to open the files the configuration refers to. On reload, the master process checks the new configuration first; if that fails, nginx rolls the change back and keeps running the old configuration, so a bad reload does not take your site down.
Expected output: Expected output: nginx -t prints two lines ending in syntax is ok and test is successful. After the reload, curl with the Host header example.com to 127.0.0.1 answers exactly like your app, not the welcome page.
Step 4: Forward the right headers
By default nginx does not pass the original Host header on. It sends Host set to its own upstream address, which is 127.0.0.1:3000. The app then builds absolute links, cookies or redirects pointing at that address, and all of them break in the visitor's browser. Add four proxy_set_header lines so the app learns the real domain, IP and protocol.
location / {
proxy_pass http://127.0.0.1:3000;
# Wrong: leave these out and the app sees Host: 127.0.0.1:3000
# Right: pass what the visitor asked for
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;
}
# Trailing slash trap
# Wrong (if the app expects /api/users): /api/users becomes /users
location /api/ {
proxy_pass http://127.0.0.1:3000/;
}
# Right: no URI after the port, the path is forwarded as sent
location /api/ {
proxy_pass http://127.0.0.1:3000;
}Two more traps. First, proxy_set_header is inherited from the level above only if the current level has no proxy_set_header at all, so do not scatter headers across server and location at once. Second, a trailing slash on proxy_pass changes its meaning: with no path, the client's URI is passed through as is; with a path or a slash, the part that matched the location is replaced.
Step 5: Connect the domain through Cloudflare and pick the right SSL mode
In the Cloudflare dashboard, create an A record for example.com pointing at 203.0.113.10 with the proxy on. Then open the SSL/TLS settings and choose a mode. Per Cloudflare's docs, Flexible encrypts visitor-to-Cloudflare only, while Cloudflare-to-origin stays HTTP. Full (strict) validates the certificate on the origin, which can come from a public CA such as Let's Encrypt or from Cloudflare's Origin CA. Cloudflare recommends Full or Full (strict) where possible.
That means nginx needs to listen on 443 with a certificate. Getting one automatically is covered in the Caddy vs nginx comparison for automatic HTTPS. If you would rather not open a port to the internet at all, there is another route through Cloudflare Tunnel. Here we stay with nginx and an open port.
# Cloudflare dashboard
# DNS: A example.com -> 203.0.113.10 (proxy on)
# SSL/TLS: Full (strict)
# /etc/nginx/conf.d/example.com.conf
server {
listen 80;
server_name example.com www.example.com;
# Safe with Full (strict). With Flexible this line causes a redirect loop.
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name example.com www.example.com;
# Placeholder paths: use the certificate you issued (public CA or Cloudflare Origin CA)
ssl_certificate /etc/ssl/example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1: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;
}
}Flexible mode plus an HTTP-to-HTTPS redirect in nginx is the classic cause of ERR_TOO_MANY_REDIRECTS. Cloudflare sends HTTP, nginx answers with a redirect to HTTPS, Cloudflare sends HTTP again, and so on. Pick one: Full (strict) with the redirect, or no redirect in nginx.
Step 6: Restore the visitor's real IP
Behind Cloudflare, every connection to nginx comes from a Cloudflare address, so logs, rate limits and firewalls only see the proxy. Cloudflare puts the visitor's real IP in the CF-Connecting-IP header. nginx's realip module swaps it in: real_ip_header names the header, and set_real_ip_from lists the addresses trusted to send it.
# 1. Is the realip module built in? (it is not on by default)
nginx -V 2>&1 | grep -o with-http_realip_module
# 2. /etc/nginx/conf.d/cloudflare-realip.conf (http context)
# One line per range from https://www.cloudflare.com/ips/ , nothing else.
set_real_ip_from <range-1-from-cloudflare>;
set_real_ip_from <range-2-from-cloudflare>;
real_ip_header CF-Connecting-IP;
# Wrong: trusts the header from the whole internet, so anyone can fake their IP
# set_real_ip_from 0.0.0.0/0;
# 3. Reload, then watch the log
sudo nginx -t && sudo nginx -s reload
sudo tail -n 5 /var/log/nginx/access.logThat trust list is the security part. Fill it only with the ranges Cloudflare publishes on its IP page, and refresh them regularly, as its documentation advises. The realip module is not built by default, so first check that your nginx has with-http_realip_module.
Expected output: Expected output: the check command prints with-http_realip_module. After the reload, new lines in access.log show the visitor's IP, not an address from a Cloudflare range.
Common errors and how to fix them
Almost every reverse proxy problem falls into these nine symptoms. Find yours in the first column, then check the cause before you change anything.
| Symptom | Cause | Fix |
|---|---|---|
| 502 Bad Gateway on a page that says nginx | nginx is up but cannot reach the app: the container is down, not ready, or the port in proxy_pass is wrong | Run docker compose ps, then curl -I http://127.0.0.1:3000. Make the proxy_pass port match the number before the colon in ports. Read the last lines of error.log for the upstream message. |
| 502 or 504 on a Cloudflare-branded page | Cloudflare's docs say this is the most common case: the error came from the origin and Cloudflare passed it along | Debug it like the first row: the problem is nginx or the app on your server, not Cloudflare. A blank page with no Cloudflare branding is a different case, for example broken gzip at the origin. |
| 504 Gateway Timeout after a long wait | The app takes longer than 60 seconds to answer; nginx's default proxy_read_timeout is 60s | Fix the slow endpoint first. If it really must be slow, raise proxy_read_timeout in that one location, not for the whole server. |
| ERR_TOO_MANY_REDIRECTS in the browser | Cloudflare's Flexible SSL mode sends HTTP to the origin, nginx redirects HTTP to HTTPS, and the two sides bounce the request back and forth | Install a certificate on nginx and switch to Full (strict). Or, if you stay on Flexible, remove the HTTP-to-HTTPS redirect from nginx. |
| Error 521: web server is down | Cloudflare cannot connect to the origin; usually nginx is stopped, or ports 80 and 443 are closed by a firewall | On the server run sudo systemctl status nginx and curl -I http://127.0.0.1. Make sure the firewall opens ports 80 and 443. Cloudflare's docs also suggest checking the logs of any proxy or firewall in between. |
| The Welcome to nginx! page shows up | Your config file is not loaded, server_name does not match the domain, or you forgot to reload | Run sudo nginx -T and search the output for your domain. If it is missing, the file sits in a folder that is not included. After fixing it, run sudo nginx -s reload. |
| Links or redirects in the app point to 127.0.0.1:3000 | The Host header is not forwarded, so the app receives nginx's default, which is the upstream's own address | Add proxy_set_header Host $host; in the location that uses proxy_pass, then reload. |
| nginx logs show only Cloudflare IP addresses | Every connection comes from Cloudflare, and the visitor's real IP is in the CF-Connecting-IP header | Enable the realip module: set_real_ip_from for every Cloudflare range plus real_ip_header CF-Connecting-IP (Step 6). |
| 404 on a path that clearly exists in the app | proxy_pass carries a URI (a trailing slash), so nginx replaces the part of the path that matched the location | Write proxy_pass with no trailing slash and no path when the client's path must be passed through unchanged. |
If your symptom is not in the table, debug from the inside out. This order separates the app, nginx and Cloudflare one by one, so you know which layer is wrong.
# 1. Is the app alive on its own?
docker compose ps
curl -I http://127.0.0.1:3000
# 2. Is nginx healthy, and which config did it load?
sudo systemctl status nginx
sudo nginx -t
# 3. Does nginx reach the app? Read what it says about the upstream
curl -I -H "Host: example.com" http://127.0.0.1
sudo tail -n 20 /var/log/nginx/error.log
# 4. Only now look outside: DNS record, SSL mode, then the real URL
curl -I https://example.comIf step 1 fails, the problem is the app or the container. If step 1 passes but step 3 fails, the problem is the nginx config. If both pass and the domain still errors, look at the DNS and SSL mode settings in Cloudflare.
Checklist before you call it done
- The container port is bound to 127.0.0.1 only, and docker compose ps proves it.
- sudo nginx -t passes before every reload.
- proxy_set_header Host $host and X-Forwarded-Proto are in the location that uses proxy_pass.
- Cloudflare's SSL mode is Full (strict) and there is no redirect loop.
- set_real_ip_from holds only Cloudflare ranges, and the logs show visitor IPs.
- You know the debug order: app, nginx, then Cloudflare.
Go deeper
This guide is deliberately a recipe. If you want to know why Cloudflare keeps hashed files for a year but never stores HTML, and what DYNAMIC means in the cf-cache-status header, continue to the free lesson:
Nginx Reverse Proxy With Docker Compose, Behind Cloudflare
That lesson runs nginx and an app on your laptop with Docker Compose, so you can repeat the experiment without touching a production server.
Sources and official documentation

