Skip to main content
DevOps from zero · Lesson 7 of 8 · 13 min

Nginx Reverse Proxy With Docker Compose, Behind Cloudflare

Put nginx in front of a Docker Compose app and Cloudflare in front of nginx, then decide header by header what the edge may cache and what it never should.

By Matthews WongReviewed on

Key takeaway

A reverse proxy is a server that receives every request first and passes it on to your app. Here nginx terminates TLS and proxies to a container bound to loopback, and Cloudflare sits in front as a CDN. The app's Cache-Control headers and the edge's rules decide what Cloudflare may keep: hashed files for a year, HTML not at all.

You will be able to

  • Configure nginx to proxy_pass to a container on loopback, passing the original Host and the visitor's real IP.
  • Read cf-cache-status and Cache-Control to tell what the edge served from cache and what reached your server.
  • Explain why hashed assets can be cached for a year while HTML that varies by Accept must stay off the edge.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

One request through Cloudflare, nginx and the app

A page request passes Cloudflare without a cache lookup and nginx hands it to the app on loopback; a hashed file is fetched once, kept at the edge, and every repeat is a HIT.

Step 1 of 6. A visit passes Cloudflare's edge, then nginx on the VPS, then the app's container, which listens on loopback only. The edge has a cache of its own.

Keys: ← → step · Home / End · K play or pause

  1. A visit passes Cloudflare's edge, then nginx on the VPS, then the app's container, which listens on loopback only. The edge has a cache of its own.

  2. A page request reaches the edge. HTML is not eligible for this site's edge cache, so it goes on to nginx without a lookup: cf-cache-status DYNAMIC.

    cf-cache-status: DYNAMIC cache-control: s-maxage=21600, stale-while-revalidate=31514400
  3. nginx terminates TLS and proxy_passes the request to the app on 127.0.0.1. With the realip module, it also restores the visitor's IP from CF-Connecting-IP.

    proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; real_ip_header CF-Connecting-IP;Predict the next step The same URL is requested with Accept: text/markdown. What comes back? (The same HTML page · Markdown, from the same URL)
  4. The same URL returns Markdown when Accept asks for it. The HTML response carries no Vary: Accept, so an edge that cached it could serve HTML to a Markdown client.

    Accept: text/html → no Vary: Accept Accept: text/markdown → Vary: Accept
  5. A /_next/static chunk is different: its name carries a content hash. The first request is a MISS; the edge fetches it from the app and keeps the response.

    cache-control: public, max-age=31536000, immutablePredict the next step The same chunk is requested again. Who answers this time? (The edge, from its cache · nginx, after asking the app again)
  6. Every later request is answered by the edge: cf-cache-status HIT. nginx and the app never see it, and a new deploy ships new file names instead.

    cf-cache-status: HIT

What does an nginx reverse proxy do in front of a Docker Compose app?

A reverse proxy receives every request first and forwards it to your app. In nginx that is one directive, proxy_pass, which sets the protocol and address of the proxied server. This site's docker-compose.yml binds the app to 127.0.0.1 on the host, with a comment saying nginx terminates TLS and proxies to that port, so nothing outside the server can reach the container directly.

Read more (2)

Two headers need care. By default nginx sends Host as $proxy_host, the upstream's own address, so an app behind proxy_pass http://web:3000 sees web:3000 instead of your domain. proxy_set_header Host $host passes the name the visitor asked for. X-Forwarded-For set to $proxy_add_x_forwarded_for appends the address nginx saw to whatever the client already sent.

The site's own nginx config is not in the repository, so the block in this lesson is the generic one from nginx.org, tested locally. Check every change with nginx -t before nginx -s reload. On reload, nginx checks the new config, starts new workers, and lets the old ones finish serving their current clients.

How do you get the visitor's real IP behind Cloudflare?

Behind Cloudflare, every connection to nginx comes from a Cloudflare address, so logs and rate limits see the proxy, not the visitor. Cloudflare adds a CF-Connecting-IP header with the visitor's address, and 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.

Read more (1)

The trust list is the security part. List each range Cloudflare publishes at cloudflare.com/ips and nothing else, because a header trusted from any address lets anyone choose their own IP. Cloudflare says the list needs updating regularly. The module is not built by default, so check nginx -V for --with-http_realip_module before you rely on it.

What should Cloudflare cache, and what should it never cache?

Cloudflare caches by file extension, not MIME type, and does not cache HTML or JSON by default. Within that, the origin's Cache-Control decides. This site's next.config.ts sends public, max-age=31536000, immutable for /images/* and root-level image files, and for /_next/static/* in production only.

Read more (2)

A year is safe only because those URLs are cache-busted: a hashed name changes whenever the content does, so an old URL never needs a new body. Where a name cannot change, the config refuses to promise forever. Films are immutable only when the URL carries the ?v= render stamp, and the game's manifest.json, the one file whose name never changes, is left out.

HTML is the opposite case. It lives at a fixed URL that changes with every deploy, so the site keeps it off the edge: one more origin request per page view, in exchange for never serving a stale deploy. The table below shows the live headers on 2026-10-13. Repeat any row with curl -sI against the site.

Why does Vary: Accept keep HTML off the edge?

Every page on this site answers in two representations at one URL. middleware.ts rewrites a request that sends Accept: text/markdown to a Markdown route, so an agent gets Markdown and a browser gets HTML. Vary is how a response tells caches which request headers shaped it, so they keep a separate copy per variant.

Read more (1)

The Markdown response sends Vary: Accept. The HTML response cannot, because Next's App Router page handler replaces the Vary header at render time, as the comment in middleware.ts records. The live HTML response's Vary lists the router headers and Accept-Encoding, but not Accept. An edge that cached that HTML could hand it to the next Markdown request, or the reverse.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

docker-compose.yml:24-26
    ports:
      # Bind to loopback only — nginx terminates TLS and proxies to this port.
      - "127.0.0.1:${APP_PORT:-3002}:3000"

The whole contract between the proxy and the app. Container port 3000 is published on the host's loopback only, at APP_PORT, so the only way in from outside is through nginx. The port number comes from .env, which the deploy writes.

next.config.ts:157-165
      {
        source: "/images/(.*)",
        headers: [
          {
            key: "Cache-Control",
            value: "public, max-age=31536000, immutable",
          }, // Cache images for 1 year
        ],
      },

The app, not nginx, decides the cache policy, and the proxy and the CDN pass it along. Note what the rule matches: every path under /images/, including one that turns out to be a 404. What breaks, below, covers why that matters.

next.config.ts:178-196
      // Production only. Build chunks are content-hashed there, so a year of
      // immutable caching is safe. Turbopack's dev chunks keep the same name
      // across edits, and this header in development made browsers keep
      // running stale game code for as long as their cache lived — the
      // "sometimes WASD stops working" reports were a mix of old and new
      // modules, not the controller.
      ...(process.env.NODE_ENV === "production"
        ? [
            {
              source: "/_next/static/(.*)",
              headers: [
                {
                  key: "Cache-Control",
                  value: "public, max-age=31536000, immutable",
                }, // Cache static assets for 1 year
              ],
            },
          ]
        : []),

immutable is a promise that the bytes behind a URL never change. In production that is true, because chunk names carry a content hash. In development it was false, and browsers kept running old code; the comment records the bug that taught it.

next.config.ts:197-211
      {
        // The films, their posters and captions. A re-render keeps the file
        // names, so only a request carrying the render stamp
        // (lib/film-manifest.ts prints `?v=` on every URL) may be cached as
        // immutable; Cloudflare then serves the films from its edge. A bare
        // URL, from an old link, keeps revalidating against the origin.
        source: "/videos/:path*",
        has: [{ type: "query", key: "v" }],
        headers: [
          {
            key: "Cache-Control",
            value: "public, max-age=31536000, immutable",
          },
        ],
      },

Cache busting when the file name cannot change. The has condition makes the year-long header apply only to URLs with a ?v= stamp, so a re-render gets a new URL and an old bare link still revalidates.

middleware.ts:11-33
/**
 * Every page answers in two representations at the same URL: HTML for
 * browsers, Markdown for agents that ask for it with `Accept: text/markdown`
 * (rendered by app/agent-md from the site's own data). Markdown requests are
 * rewritten before the locale middleware, so they get 200 at the URL they
 * asked for instead of a locale redirect, and carry `Vary: Accept`.
 *
 * Everything else goes through next-intl as before. Its locale redirects get
 * `Vary: Accept` here too; a rendered HTML page does not, because Next's App
 * Router page handler replaces the Vary header at render time (setHeader in
 * build/templates/app-page.js). Nothing caches in front of the site today;
 * if a shared cache is ever added, add `Vary: Accept` in nginx instead.
 */
export default function middleware(request: NextRequest): NextResponse {
  if (prefersMarkdown(request.headers.get("accept"))) {
    const url = request.nextUrl.clone();
    url.pathname = `${MARKDOWN_ROUTE}${request.nextUrl.pathname}`;
    return NextResponse.rewrite(url);
  }
  const response = intlMiddleware(request);
  response.headers.append("Vary", "Accept");
  return response;
}

Content negotiation at one URL. Markdown requests are rewritten to their own route and carry Vary: Accept; HTML pages cannot carry it. One line here is out of date: Cloudflare has proxied www since 2026-10-12, so something does cache in front of the site now. The conclusion still holds because the edge never stores HTML.

What breaks

Mistakes that cost real time

  • Cache everything on a site that varies by Accept

    A rule that makes HTML cacheable would honour Next's s-maxage and freeze pages across deploys. With no Vary: Accept on the HTML, it could also serve one representation to a client that asked for the other. Keep HTML off the edge unless every variant is declared.

  • A one-year header on a URL that can change

    immutable tells every cache the bytes at that URL never change. On this site the header once applied to /_next/static in development, where chunk names stay the same across edits, and browsers ran stale game code. Give long lifetimes only to hashed or stamped URLs.

  • Trusting CF-Connecting-IP from anyone

    real_ip_header without a tight set_real_ip_from lets any client set its own address by sending the header, which defeats logs, rate limits and bans. List only the ranges Cloudflare publishes, and refresh them.

  • A long cache header on an error

    The /images rule sends public, max-age=31536000, immutable even when the file is missing and the status is 404. On 2026-10-13 the edge answered that 404 with BYPASS, so it was not stored. An edge that stores errors would keep a file missing long after you upload it, so never cache 4xx or 5xx responses.

  • Exposing the app port and relying on the CDN

    If the container publishes its port on every interface, anyone who finds the server's address can skip Cloudflare and nginx entirely. Bind to 127.0.0.1, as this site's compose file does, or publish no port and let nginx reach the app over the Compose network.

  • What I'd change: two comments that went stale

    middleware.ts still says nothing caches in front of the site, and next.config.ts says Cloudflare does not cache /_next/image. Both were written before www went behind Cloudflare; today a /_next/image request returns HIT. A comment that explains caching needs the same review as the config it sits in.

What the edge does with each kind of response, checked live on 2026-10-13
/en/ (HTML)DYNAMICs-maxage=21600, stale-while-revalidate=31514400Next allows shared caching, but the edge never stores HTML
/en/ with Accept: text/markdownDYNAMICpublic, max-age=300, plus Vary: AcceptSame URL, different body
A /_next/static chunkHITpublic, max-age=31536000, immutableHashed files are served from the edge
A /_next/image optimised imageHITpublic, max-age=31536000, must-revalidateOptimised variants are edge-cached too
A missing /images file (404)BYPASSpublic, max-age=31536000, immutableThe error is not stored, despite the one-year header
/api/v1/DYNAMICpublic, max-age=3600The API is not edge-cached
Try it yourself

Do it on your own machine

Put nginx in front of the devops-lab app from the Docker lesson, with Docker Compose, on your laptop. The app publishes no port at all; only nginx can reach it. You need Docker and nothing else.

  1. Make the app report what it receives, and send a year-long Cache-Control for anything under /assets/, the way next.config.ts does for hashed files. Rebuild the image.

    // src/server.js
    require("node:http")
      .createServer((req, res) => {
        if (req.url.startsWith("/assets/")) {
          // A hashed file name never changes content: cache it for a year.
          res.setHeader("cache-control", "public, max-age=31536000, immutable");
          res.end("console.log('hi');\n");
          return;
        }
        res.setHeader("cache-control", "no-cache");
        res.end(`host=${req.headers.host} real-ip=${req.headers["x-real-ip"]}\n`);
      })
      .listen(3000, "0.0.0.0");
    
    # then rebuild the image
    docker build -t devops-lab:proxy .
  2. Write the nginx config. The realip lines trust CF-Connecting-IP only from 127.0.0.1, the proxy container itself; in production that list would be Cloudflare's ranges.

    # nginx.conf
    # Trust CF-Connecting-IP only from addresses you list. In production that
    # is each Cloudflare range; in this lab it is the proxy container itself.
    set_real_ip_from 127.0.0.1;
    real_ip_header CF-Connecting-IP;
    
    server {
        listen 80;
    
        location / {
            proxy_pass http://web: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;
        }
    }
  3. Run both with Compose. web has no ports section, and nginx publishes only on 127.0.0.1:8081, the same loopback-only rule the site uses.

    # compose.proxy.yaml
    services:
      web:
        image: devops-lab:proxy
        healthcheck:
          test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
          interval: 5s
          retries: 3
      proxy:
        image: nginx:stable-alpine
        ports:
          - "127.0.0.1:8081:80"
        volumes:
          - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
        depends_on:
          web:
            condition: service_healthy
    
    # then start both
    docker compose -f compose.proxy.yaml up -d --wait
  4. Check what passes through. The page says no-cache and host=127.0.0.1, the asset says immutable, web has no published port, and nginx -t approves the config.

    curl -si http://127.0.0.1:8081/ | grep -iE '^(server|cache-control)|host='
    curl -sI http://127.0.0.1:8081/assets/app.3f9a1c.js | grep -i cache-control
    docker compose -f compose.proxy.yaml port web 3000
    docker compose -f compose.proxy.yaml exec proxy nginx -t
  5. Test the trust rule. From your laptop the header is ignored, because your address is not trusted; from inside the proxy it replaces the client address. Then delete the proxy_set_header Host line, restart the proxy, and the app sees host=web:3000.

    curl -s -H 'CF-Connecting-IP: 2001:db8::7' http://127.0.0.1:8081/
    docker compose -f compose.proxy.yaml exec proxy wget -qO- --header 'CF-Connecting-IP: 2001:db8::7' http://127.0.0.1/
    # delete the proxy_set_header Host line in nginx.conf, then
    docker compose -f compose.proxy.yaml restart proxy
    curl -s http://127.0.0.1:8081/
    docker compose -f compose.proxy.yaml down
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.To cut origin load, someone adds a Cloudflare rule that caches everything, HTML included. What goes wrong on this site?
  2. 2.You serve /logo.png with public, max-age=31536000, immutable, then replace the file under the same name. What do returning visitors see?
  3. 3.To log real visitor IPs, a teammate sets real_ip_header CF-Connecting-IP and trusts it from every address. What can a client now do?
Think it through

Answer in your head, then check

  1. 1.Why can a /_next/static chunk be cached for a year, but not the HTML page that loads it?Show the answer

    Answer:The chunk's name carries a content hash, so new content gets a new URL and the old one never needs updating. The HTML lives at a fixed URL that changes with every deploy, and on this site with the Accept header too.

  2. 2.Your nginx logs show only Cloudflare addresses. What fixes it safely?Show the answer

    Answer:The realip module with real_ip_header CF-Connecting-IP and one set_real_ip_from line per Cloudflare range. Trusting the header from any address would let anyone choose their own IP.

  3. 3.An app behind proxy_pass http://web:3000 builds links that point to web:3000. Which line is missing?Show the answer

    Answer:proxy_set_header Host $host. Without it nginx sends Host as $proxy_host, the upstream's own name, so the app never learns which domain the visitor asked for.

  4. 4.A page shows cf-cache-status: DYNAMIC. Is that a cache miss?Show the answer

    Answer:No. MISS means the response was eligible for cache but not stored yet. DYNAMIC means Cloudflare decided it was not eligible at all, so the request went to the origin without a cache lookup.

Go deeper

Related deep dives on this site