# How to Set Up Nginx Reverse Proxy for Docker and Cloudflare

> Beginner guide to nginx reverse proxy setup for a Docker app behind Cloudflare: proxy_pass, headers, SSL mode, real IP, and a table of 502 and 504 fixes.

URL: https://www.matthewswong.com/en/blog/cara-setting-nginx-reverse-proxy-docker-cloudflare/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-10-17
- Updated: 2026-10-17
- Category: DevOps

> **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.

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.

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.

Always 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: 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.

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.

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.

That 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: 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.

Expected output

## 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

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.

> If 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

- nginx docs: ngx_http_proxy_module (proxy_pass, proxy_set_header, timeouts)
- nginx docs: beginner's guide (reload and configuration)
- nginx docs: ngx_http_realip_module
- Cloudflare docs: SSL/TLS encryption modes
- Cloudflare docs: Error 502 and 504
- Cloudflare: IP address ranges

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.

## Source links

- https://nginx.org/en/docs/http/ngx_http_proxy_module.html
- https://nginx.org/en/docs/beginners_guide.html
- https://nginx.org/en/docs/http/ngx_http_realip_module.html
- https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/
- https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-502-504/
- https://www.cloudflare.com/ips/

## FAQ

### What is an nginx reverse proxy and why do I need one for Docker?

A reverse proxy receives every request first and forwards it to your app, which in nginx is the proxy_pass directive. With Docker it lets the container listen only on 127.0.0.1 while nginx handles the domain and HTTPS. That way nobody can reach the container without going through nginx.

### Why does my nginx reverse proxy return 502 Bad Gateway?

nginx is running but cannot connect to the app behind proxy_pass. The usual causes are a stopped container, a wrong port in proxy_pass, or an app that is not ready yet. Run curl -I http://127.0.0.1:3000 on the server and read the end of error.log to see which one it is.

### Why do I get ERR_TOO_MANY_REDIRECTS after putting Cloudflare in front of nginx?

Usually Cloudflare is in Flexible mode, so it connects to your origin over HTTP while nginx redirects HTTP to HTTPS. The request bounces forever. Install a certificate on nginx and switch to Full (strict), or remove the redirect from nginx if you keep Flexible.

### Do I need proxy_set_header Host in nginx?

Yes, for most apps. By default nginx sends Host set to the upstream's own address, such as 127.0.0.1:3000, so the app builds links and redirects to the wrong place. proxy_set_header Host $host passes the domain the visitor actually asked for.

### How do I see the real visitor IP in nginx behind Cloudflare?

Use the realip module with real_ip_header CF-Connecting-IP and one set_real_ip_from line for each range Cloudflare publishes. Trust only those ranges, because a header trusted from any address lets anyone choose their own IP. The module is not built by default, so check nginx -V for with-http_realip_module.

---

- HTML version: https://www.matthewswong.com/en/blog/cara-setting-nginx-reverse-proxy-docker-cloudflare/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/cara-setting-nginx-reverse-proxy-docker-cloudflare/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
