Skip to main content
DevOps dari nol · Pelajaran 7 dari 8 · 13 menit

Reverse Proxy Nginx dan Docker Compose di Balik Cloudflare

Pasang nginx di depan aplikasi Docker Compose dan Cloudflare di depan nginx, lalu tentukan per header apa yang boleh di-cache di edge dan apa yang tidak.

Oleh Matthews WongDitinjau pada

Poin utama

Reverse proxy adalah server yang menerima setiap request lebih dulu lalu meneruskannya ke aplikasi Anda. Di sini nginx menangani TLS dan meneruskan request ke container yang hanya terikat ke loopback, sementara Cloudflare berada di depannya sebagai CDN. Header Cache-Control dari aplikasi dan aturan di edge menentukan apa yang boleh disimpan Cloudflare: file ber-hash selama setahun, HTML sama sekali tidak.

Setelah pelajaran ini, Anda bisa

  • Mengatur nginx agar meneruskan request dengan proxy_pass ke container di loopback, sambil membawa Host asli dan IP asli pengunjung.
  • Membaca cf-cache-status dan Cache-Control untuk membedakan apa yang dilayani edge dari cache dan apa yang sampai ke server Anda.
  • Menjelaskan kenapa aset ber-hash boleh di-cache setahun, sedangkan HTML yang berbeda menurut header Accept tidak boleh disimpan di edge.
Lihat cara kerjanya

Ikuti diagramnya langkah demi langkah

Tekan Putar, atau melangkah dengan panah. Setiap langkah juga ditulis di bawah diagram.

Satu request melewati Cloudflare, nginx, dan aplikasi

Request halaman melewati Cloudflare tanpa pengecekan cache dan nginx meneruskannya ke aplikasi di loopback; file ber-hash diambil sekali, disimpan di edge, dan setiap request berikutnya berstatus HIT.

Langkah 1 dari 6. Sebuah kunjungan melewati edge Cloudflare, lalu nginx di VPS, lalu container aplikasi yang hanya mendengarkan di loopback. Edge punya cache sendiri.

Tombol: ← → langkah · Home / End · K putar atau jeda

  1. Sebuah kunjungan melewati edge Cloudflare, lalu nginx di VPS, lalu container aplikasi yang hanya mendengarkan di loopback. Edge punya cache sendiri.

  2. Request halaman sampai di edge. HTML tidak termasuk yang boleh di-cache di edge situs ini, jadi request langsung diteruskan ke nginx tanpa pengecekan cache: cf-cache-status DYNAMIC.

    cf-cache-status: DYNAMIC cache-control: s-maxage=21600, stale-while-revalidate=31514400
  3. nginx menangani TLS lalu meneruskan request dengan proxy_pass ke aplikasi di 127.0.0.1. Dengan modul realip, nginx juga memulihkan IP pengunjung dari CF-Connecting-IP.

    proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; real_ip_header CF-Connecting-IP;Tebak langkah berikutnya URL yang sama diminta dengan Accept: text/markdown. Apa yang dikembalikan? (Halaman HTML yang sama · Markdown, dari URL yang sama)
  4. URL yang sama mengembalikan Markdown kalau header Accept memintanya. Response HTML tidak membawa Vary: Accept, jadi edge yang meng-cache-nya bisa mengirim HTML ke klien yang meminta Markdown.

    Accept: text/html → no Vary: Accept Accept: text/markdown → Vary: Accept
  5. Chunk di /_next/static berbeda: namanya membawa hash dari isinya. Request pertama berstatus MISS; edge mengambilnya dari aplikasi lalu menyimpan response-nya.

    cache-control: public, max-age=31536000, immutableTebak langkah berikutnya Chunk yang sama diminta lagi. Siapa yang menjawab kali ini? (Edge, dari cache-nya · nginx, setelah bertanya lagi ke aplikasi)
  6. Setiap request berikutnya dijawab langsung oleh edge: cf-cache-status HIT. nginx dan aplikasi tidak pernah melihatnya, dan deploy baru mengirim nama file baru.

    cf-cache-status: HIT

Apa tugas reverse proxy nginx di depan aplikasi Docker Compose?

Reverse proxy menerima setiap request lebih dulu lalu meneruskannya ke aplikasi Anda. Di nginx, itu cukup satu directive, proxy_pass, yang menentukan protokol dan alamat server tujuan. docker-compose.yml situs ini mengikat aplikasi ke 127.0.0.1 di host, dengan komentar bahwa nginx menangani TLS lalu meneruskan request ke port itu, jadi tidak ada yang dari luar server bisa menjangkau container secara langsung.

Baca selengkapnya (2)

Ada dua header yang perlu diperhatikan. Secara default nginx mengirim Host berisi $proxy_host, yaitu alamat upstream itu sendiri, jadi aplikasi di belakang proxy_pass http://web:3000 melihat web:3000, bukan domain Anda. proxy_set_header Host $host meneruskan nama yang diminta pengunjung. X-Forwarded-For yang diisi $proxy_add_x_forwarded_for menambahkan alamat yang dilihat nginx ke nilai yang sudah dikirim klien.

Konfigurasi nginx milik situs ini tidak ada di repository, jadi blok di pelajaran ini adalah versi umum dari nginx.org yang sudah diuji di lokal. Periksa setiap perubahan dengan nginx -t sebelum nginx -s reload. Saat reload, nginx memeriksa konfigurasi baru, menyalakan worker baru, dan membiarkan worker lama menyelesaikan klien yang sedang dilayaninya.

Bagaimana mendapatkan IP asli pengunjung di balik Cloudflare?

Di balik Cloudflare, setiap koneksi ke nginx datang dari alamat Cloudflare, jadi log dan rate limit melihat proxy, bukan pengunjung. Cloudflare menambahkan header CF-Connecting-IP berisi alamat pengunjung, dan modul realip di nginx menukarnya: real_ip_header menyebut nama header-nya, dan set_real_ip_from mendaftar alamat yang dipercaya untuk mengirimnya.

Baca selengkapnya (1)

Daftar alamat tepercaya itulah bagian keamanannya. Daftarkan setiap rentang yang dipublikasikan Cloudflare di cloudflare.com/ips dan jangan yang lain, karena header yang dipercaya dari alamat mana pun membuat siapa saja bisa memilih IP-nya sendiri. Cloudflare menyebut daftar itu perlu diperbarui secara rutin. Modul ini tidak ikut di-build secara default, jadi periksa nginx -V untuk --with-http_realip_module sebelum mengandalkannya.

Apa yang boleh di-cache Cloudflare, dan apa yang tidak boleh?

Cloudflare melakukan cache berdasarkan ekstensi file, bukan MIME type, dan secara default tidak meng-cache HTML maupun JSON. Di dalam batas itu, Cache-Control dari origin yang menentukan. next.config.ts situs ini mengirim public, max-age=31536000, immutable untuk /images/* dan file gambar di root, serta untuk /_next/static/* khusus di production.

Baca selengkapnya (2)

Setahun hanya aman karena URL-URL itu memakai cache busting: nama ber-hash berubah setiap kali isinya berubah, jadi URL lama tidak pernah butuh isi baru. Kalau sebuah nama tidak bisa berubah, konfigurasinya menolak berjanji selamanya. Film hanya immutable kalau URL-nya membawa stempel render ?v=, dan manifest.json milik game, satu-satunya file yang namanya tidak pernah berubah, dikecualikan.

HTML adalah kebalikannya. HTML berada di URL tetap yang isinya berubah di setiap deploy, jadi situs ini tidak menyimpannya di edge: satu request tambahan ke origin per tampilan halaman, sebagai ganti tidak pernah mengirim hasil deploy yang basi. Tabel di bawah menunjukkan header live pada 2026-10-13. Ulangi baris mana pun dengan curl -sI ke situs ini.

Kenapa Vary: Accept membuat HTML tidak boleh di-cache di edge?

Setiap halaman di situs ini menjawab dalam dua representasi di satu URL. middleware.ts menulis ulang request yang mengirim Accept: text/markdown ke route Markdown, jadi agent mendapat Markdown dan browser mendapat HTML. Vary adalah cara response memberi tahu cache header request mana yang membentuknya, sehingga cache menyimpan salinan terpisah untuk setiap varian.

Baca selengkapnya (1)

Response Markdown mengirim Vary: Accept. Response HTML tidak bisa, karena page handler App Router milik Next mengganti header Vary saat render, seperti dicatat di komentar middleware.ts. Header Vary pada response HTML yang live berisi header router dan Accept-Encoding, tetapi tidak berisi Accept. Edge yang meng-cache HTML itu bisa mengirimkannya ke request Markdown berikutnya, atau sebaliknya.

Cara situs ini melakukannya

Konfigurasi nyata di balik pelajaran ini

Kutipan dari repositori yang membangun dan men-deploy situs ini, apa adanya.

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"

Inilah seluruh kontrak antara proxy dan aplikasi. Port container 3000 hanya dipublikasikan di loopback host, pada APP_PORT, jadi satu-satunya jalan masuk dari luar adalah lewat nginx. Nomor port-nya berasal dari .env yang ditulis proses deploy.

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

Aplikasi, bukan nginx, yang menentukan kebijakan cache, lalu proxy dan CDN meneruskannya. Perhatikan apa yang dicocokkan aturan ini: setiap path di bawah /images/, termasuk yang ternyata 404. Bagian What breaks di bawah membahas kenapa itu penting.

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 adalah janji bahwa isi di balik sebuah URL tidak akan pernah berubah. Di production itu benar, karena nama chunk membawa hash isinya. Di development itu salah, dan browser terus menjalankan kode lama; komentarnya mencatat bug yang mengajarkan hal ini.

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 saat nama file tidak bisa berubah. Kondisi has membuat header setahun hanya berlaku untuk URL yang membawa stempel ?v=, jadi render ulang mendapat URL baru, sementara tautan lama tanpa stempel tetap melakukan revalidasi.

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 di satu URL. Request Markdown ditulis ulang ke route-nya sendiri dan membawa Vary: Accept; halaman HTML tidak bisa membawanya. Satu baris di sini sudah usang: Cloudflare mem-proxy www sejak 2026-10-12, jadi sekarang memang ada yang meng-cache di depan situs. Kesimpulannya tetap berlaku, karena edge tidak pernah menyimpan HTML.

Yang sering rusak

Kesalahan yang benar-benar membuang waktu

  • Cache everything di situs yang berbeda menurut Accept

    Aturan yang membuat HTML bisa di-cache akan mengikuti s-maxage dari Next dan membekukan halaman lintas deploy. Karena HTML tidak membawa Vary: Accept, aturan itu juga bisa mengirim satu representasi ke klien yang meminta representasi lain. Jangan simpan HTML di edge kecuali setiap variannya dideklarasikan.

  • Header setahun di URL yang isinya bisa berubah

    immutable memberi tahu setiap cache bahwa isi di URL itu tidak pernah berubah. Di situs ini, header itu pernah berlaku untuk /_next/static saat development, ketika nama chunk tetap sama walaupun kodenya diubah, dan browser menjalankan kode game yang basi. Berikan umur panjang hanya untuk URL yang ber-hash atau berstempel.

  • Memercayai CF-Connecting-IP dari siapa saja

    real_ip_header tanpa set_real_ip_from yang ketat membuat klien mana pun bisa menentukan alamatnya sendiri hanya dengan mengirim header itu, sehingga log, rate limit, dan pemblokiran tidak ada artinya. Daftarkan hanya rentang yang dipublikasikan Cloudflare, dan perbarui secara rutin.

  • Header cache panjang pada response error

    Aturan /images mengirim public, max-age=31536000, immutable bahkan saat file-nya tidak ada dan status-nya 404. Pada 2026-10-13, edge menjawab 404 itu dengan BYPASS, jadi tidak disimpan. Edge yang menyimpan error akan membuat file tetap terlihat hilang lama setelah Anda mengunggahnya, jadi jangan pernah meng-cache response 4xx atau 5xx.

  • Membuka port aplikasi dan hanya mengandalkan CDN

    Kalau container memublikasikan port-nya di semua interface, siapa pun yang menemukan alamat server bisa melewati Cloudflare dan nginx sepenuhnya. Ikat ke 127.0.0.1 seperti file compose situs ini, atau jangan publikasikan port sama sekali dan biarkan nginx menjangkau aplikasi lewat network Compose.

  • Yang akan saya ubah: dua komentar yang sudah usang

    middleware.ts masih menyebut tidak ada yang meng-cache di depan situs, dan next.config.ts menyebut Cloudflare tidak meng-cache /_next/image. Keduanya ditulis sebelum www berada di balik Cloudflare; hari ini request ke /_next/image mengembalikan HIT. Komentar yang menjelaskan caching butuh review yang sama dengan konfigurasi tempat ia berada.

Apa yang dilakukan edge pada tiap jenis response, dicek live pada 2026-10-13
/en/ (HTML)DYNAMICs-maxage=21600, stale-while-revalidate=31514400Next mengizinkan shared cache, tetapi edge tidak pernah menyimpan HTML
/en/ dengan Accept: text/markdownDYNAMICpublic, max-age=300, ditambah Vary: AcceptURL sama, isi berbeda
Chunk /_next/staticHITpublic, max-age=31536000, immutableFile ber-hash dilayani dari edge
Gambar teroptimasi /_next/imageHITpublic, max-age=31536000, must-revalidateVarian teroptimasi juga di-cache di edge
File /images yang tidak ada (404)BYPASSpublic, max-age=31536000, immutableError tidak disimpan, meski header-nya setahun
/api/v1/DYNAMICpublic, max-age=3600API tidak di-cache di edge
Coba sendiri

Lakukan di komputer Anda sendiri

Pasang nginx di depan aplikasi devops-lab dari pelajaran Docker, dengan Docker Compose, di laptop Anda. Aplikasinya tidak memublikasikan port sama sekali; hanya nginx yang bisa menjangkaunya. Anda hanya butuh Docker.

  1. Buat aplikasinya melaporkan apa yang ia terima, dan kirim Cache-Control setahun untuk semua yang ada di /assets/, seperti next.config.ts untuk file ber-hash. Build ulang image-nya.

    // 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. Tulis konfigurasi nginx. Baris realip hanya memercayai CF-Connecting-IP dari 127.0.0.1, yaitu container proxy itu sendiri; di production, daftar itu berisi rentang IP Cloudflare.

    # 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. Jalankan keduanya dengan Compose. web tidak punya bagian ports, dan nginx hanya dipublikasikan di 127.0.0.1:8081, aturan loopback-only yang sama dengan situs ini.

    # 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. Periksa apa yang lewat. Halaman membalas no-cache dan host=127.0.0.1, aset membalas immutable, web tidak punya port yang dipublikasikan, dan nginx -t menyetujui konfigurasinya.

    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. Uji aturan kepercayaannya. Dari laptop Anda, header itu diabaikan karena alamat Anda tidak dipercaya; dari dalam proxy, header itu menggantikan alamat klien. Lalu hapus baris proxy_set_header Host, restart proxy, dan aplikasi akan melihat 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
Kuis singkat

Tiga pertanyaan sebelum lanjut

Pilih jawaban untuk melihat kenapa benar atau salah. Tidak ada yang disimpan atau dinilai, jadi tebak saja dengan bebas.

  1. 1.Untuk mengurangi beban origin, seseorang menambahkan aturan Cloudflare yang meng-cache semuanya, termasuk HTML. Apa yang salah di situs ini?
  2. 2.Anda menyajikan /logo.png dengan public, max-age=31536000, immutable, lalu mengganti file-nya dengan nama yang sama. Apa yang dilihat pengunjung yang kembali?
  3. 3.Untuk mencatat IP asli pengunjung, seorang rekan memasang real_ip_header CF-Connecting-IP dan memercayainya dari alamat mana pun. Apa yang kini bisa dilakukan klien?
Pikirkan dulu

Jawab dalam hati, lalu cek

  1. 1.Kenapa chunk /_next/static boleh di-cache setahun, tetapi halaman HTML yang memuatnya tidak?Lihat jawabannya

    Jawaban:Nama chunk membawa hash isinya, jadi isi baru mendapat URL baru dan URL lama tidak pernah perlu diperbarui. HTML berada di URL tetap yang isinya berubah di setiap deploy, dan di situs ini juga berubah menurut header Accept.

  2. 2.Log nginx Anda hanya berisi alamat Cloudflare. Apa perbaikan yang aman?Lihat jawabannya

    Jawaban:Modul realip dengan real_ip_header CF-Connecting-IP dan satu baris set_real_ip_from untuk setiap rentang Cloudflare. Memercayai header itu dari alamat mana pun membuat siapa saja bisa memilih IP-nya sendiri.

  3. 3.Aplikasi di belakang proxy_pass http://web:3000 membuat tautan yang mengarah ke web:3000. Baris apa yang kurang?Lihat jawabannya

    Jawaban:proxy_set_header Host $host. Tanpa baris itu, nginx mengirim Host berisi $proxy_host, yaitu nama upstream itu sendiri, jadi aplikasi tidak pernah tahu domain mana yang diminta pengunjung.

  4. 4.Sebuah halaman menampilkan cf-cache-status: DYNAMIC. Apakah itu cache miss?Lihat jawabannya

    Jawaban:Bukan. MISS berarti response itu boleh di-cache tetapi belum tersimpan. DYNAMIC berarti Cloudflare memutuskan response itu sama sekali tidak boleh di-cache, jadi request langsung ke origin tanpa pengecekan cache.

Pelajari lebih dalam

Bahasan mendalam terkait di situs ini