Container menyala bukan akhir cerita — sidangnya baru dimulai. Selama tiga bagian kita menulis janji-janji lewat konfigurasi: nginx akan menjawab 200, gzip akan memangkas ukuran respons, no-cache akan menempel pada halaman depan, dan try_files akan menolak jalur yang tak ada. Bagian ini memanggil seluruh saksi-saksinya satu per satu dengan curl, membaca jejak lima permintaan di docker logs, lalu menguji ketahanan lewat siklus stop-start sebelum membongkar stack dengan bersih. Setiap angka yang muncul di bawah punya asal-usul yang bisa kita jelaskan — itu standar pembuktian sebuah proyek container.

Membaca Respons Halaman Depan #

Saksi pertama adalah kepala respons itu sendiri. Flag -I mengirim permintaan HEAD: nginx memprosesnya seperti GET tapi hanya mengirim header, tanpa isi:

bash
$ curl -I http://localhost:8080

Output yang Dihasilkan:

text
HTTP/1.1 200 OK
Server: nginx/1.29.4
Date: Tue, 13 Oct 2026 15:07:31 GMT
Content-Type: text/html
Content-Length: 2154
Last-Modified: Tue, 13 Oct 2026 14:56:26 GMT
Connection: keep-alive
ETag: "6ace469a-86a"
Cache-Control: no-cache
Accept-Ranges: bytes

Mari kita telaah header demi header:

  1. HTTP/1.1 200 OK dan Server: nginx/1.29.4 — status sukses dari nginx versi persis 1.29.4: bukti bahwa tag 1.29-alpine yang kita pin di Dockerfile menunaikan janjinya, versi minor terkunci rilis 1.29. Nomor lengkap ini juga yang membuat container kita dapat dibedakan dari instalasi nginx host mana pun.
  2. Content-Type: text/html — nginx mengenali sufiks .html lewat tabel mime.types bawaan imagenya; browser tahu harus merender HTML, bukan menumpahkan teks mentah.
  3. Content-Length: 2154 — ukuran index.html kita byte-per-byte, 2.154 byte. Angka ini murni buah berkas Bagian 1: tambah satu paragraf, dan nilai ini ikut naik. HEAD memperbolehkan nginx mengumumkan panjang ini karena berkas statis sudah diketahui utuh di disk.
  4. Last-Modified: Tue, 13 Oct 2026 14:56:26 GMT — waktu berkas dibuat, dibawa masuk ke dalam image karena COPY mempertahankan mtime sumbernya. Inilah dasar validasi If-Modified-Since milik klien cache.
  5. ETag: "6ace469a-86a" — validator kuat buatan nginx untuk berkas statis: bagian pertama adalah mtime dalam heksadesimal, bagian kedua ukuran berkas — 0x86a memang tepat 2.154. Klien yang mengirim If-None-Match dengan nilai ini akan dijawab 304 Not Modified tanpa mengirim ulang isi.
  6. Cache-Control: no-cache — bintang tamu yang paling menarik. Konfigurasi kita menempelkannya pada location = /index.html, padahal yang kita minta adalah /. Jawabannya adalah internal redirect yang kita tandai di Bagian 2: directive index mengalihkan / ke /index.html, pencocokan location diulang, dan blok location = /index.html-lah yang akhirnya melayani — membawa serta header no-cache-nya. Halaman depan boleh di-cache, tapi wajib divalidasi ulang: pengunjung selalu melihat katalog termutakhir.

Dua baris terakhir menyertai tanpa drama: Accept-Ranges: bytes membuka dukungan unduhan parsial untuk berkas besar kelak, dan Connection: keep-alive menjaga koneksi terbuka untuk permintaan berikutnya.

Memburu 404 untuk try_files #

Saksi kedua menguji penjaga gerbang. Kita minta jalur yang kita tahu tidak ada:

bash
$ curl -I http://localhost:8080/tidakada

Output yang Dihasilkan:

text
HTTP/1.1 404 Not Found
Server: nginx/1.29.4
Date: Tue, 13 Oct 2026 15:08:14 GMT
Content-Type: text/html
Content-Length: 153
Connection: keep-alive

Inilah kerja try_files $uri $uri/ =404 dari Bagian 2: tidak ada berkas tidakada, tidak ada folder demikian, maka jawaban terakhir yang tegas adalah 404 Not Found — halaman kesalahan bawaan nginx sepanjang 153 byte (Content-Length-nya persis begitu). Perhatikan yang tidak muncul: tidak ada Cache-Control, tidak ada ETag — header-header itu hanya ditambahkan pada blok location yang cocok, dan halaman kesalahan tidak mewarisi janji-janji itu. Penolakan yang bersih, bukan index direktori yang bocor.

Memastikan Isi dengan grep #

Header bisa benar sementara isinya salah — saksi ketiga memastikan halaman yang benar-benar terkirim:

bash
$ curl -s http://localhost:8080/ | grep "<title>"

Output yang Dihasilkan:

text
  <title>Katalog Produk — Kopi Nusantara</title>

Flag -s meredam kemajuan unduhan, dan grep menyaring baris judul dari isi respons. Dua spasi di depan bukan kecelakaan — itu indentasi asli dari site/index.html Bagian 1; server mengirim byte apa adanya. Judul lengkap dengan tanda hubung em tersampaikan utuh berkat UTF-8 yang kita deklarasikan sejak baris keempat berkas itu.

Menimbang gzip Byte-per-Byte #

Saksi keempat adalah janji kompresi, dan kita menimbangnya dengan wc -c di kedua ujung jalur. Timbangan pertama jatuh ke berkas aslinya di meja sendiri — barang yang ditulis pada Bagian 1:

bash
$ wc -c site/index.html

Output yang Dihasilkan:

text
2154 site/index.html

Timbangan kedua jatuh ke versi yang dikirim server: header Accept-Encoding: gzip menyatakan kesediaan menerima kompresi, dan karena header itu kita pasang sendiri, curl tidak mendekompresi jawabannya — wc -c menghitung byte gzip yang benar-benar sampai ke tangan:

bash
$ curl -s -H "Accept-Encoding: gzip" http://localhost:8080/ | wc -c

Output yang Dihasilkan:

text
968

Dari 2.154 byte menjadi 968 byte: 1.186 byte lebih ramping, pemangkasan sekitar 55 persen untuk satu berkas HTML — dan gzip menyala karena text/html selalu ikut dikompresi saat gzip on, serta 2.154 byte melewati ambang gzip_min_length 256. Bagaimana wujud header saat kompresi aktif? Kita intip dengan HEAD:

bash
$ curl -sI -H "Accept-Encoding: gzip" http://localhost:8080/

Output yang Dihasilkan:

text
HTTP/1.1 200 OK
Server: nginx/1.29.4
Date: Tue, 13 Oct 2026 15:09:41 GMT
Content-Type: text/html
Last-Modified: Tue, 13 Oct 2026 14:56:26 GMT
Connection: keep-alive
ETag: W/"6ace469a-86a"
Cache-Control: no-cache
Content-Encoding: gzip

Tiga perubahan mencolok dibanding respons polos:

  1. Content-Encoding: gzip — pengumuman resmi bahwa isi respons terkompresi; klien wajib mendekompresi sebelum merender. Header ini hanya muncul karena permintaan kita menyatakan kesediaan menerima gzip — tanpa Accept-Encoding, nginx mengirim polos.
  2. ETag: W/"6ace469a-86a" — tanda kutip kini didahului W/: ETag berubah menjadi lemah. Isi terkompresi bukan byte-per-byte identik dengan berkas aslinya, jadi validator kuat tidak lagi jujur dipakai; nginx menurunkan pangkatnya agar klien cache tidak salah mengira dua representasi ini identik.
  3. Content-Length hilang — pada respons terkompresi yang dialirkan, nginx belum tahu panjang akhir saat mulai mengirim, sehingga tidak berani mengumumkan angka apa pun; transfer berlangsung secara chunked per potongan. Cache-Control: no-cache tetap setia menempel — internal redirect bekerja sama persisnya baik respons polos maupun terkompresi.

Jejak Lima Permintaan di docker logs #

Saksi kelima adalah pembukuan. Image nginx mengalirkan akses log ke stdout dan error log ke stderr — keduanya ditampung daemon dan dibaca ulang lewat docker logs, persis mekanisme driver json-file yang kita bedah pada Docker Logs dan Melihat Log Container:

bash
$ docker logs web-statis

Output yang Dihasilkan:

text
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Configuration complete; ready for start up
2026/10/13 15:05:44 [notice] 1#1: using the "epoll" event method
2026/10/13 15:05:44 [notice] 1#1: start worker processes
2026/10/13 15:05:44 [notice] 1#1: start worker process 39
172.18.0.1 - - [13/Oct/2026:15:07:31 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/8.14.1"
172.18.0.1 - - [13/Oct/2026:15:08:14 +0000] "HEAD /tidakada HTTP/1.1" 404 0 "-" "curl/8.14.1"
172.18.0.1 - - [13/Oct/2026:15:08:52 +0000] "GET / HTTP/1.1" 200 2154 "-" "curl/8.14.1"
172.18.0.1 - - [13/Oct/2026:15:09:26 +0000] "GET / HTTP/1.1" 200 980 "-" "curl/8.14.1"
172.18.0.1 - - [13/Oct/2026:15:09:41 +0000] "HEAD / HTTP/1.1" 200 0 "-" "curl/8.14.1"

Beberapa baris banner entrypoint di antara dua baris pertama dipotong agar ringkas — semuanya pemberitahuan skrip startup image nginx. Mari kita telaah isi yang tersisa:

  1. Lima baris akses log = lima uji kita berurutan — HEAD / pukul 15:07:31, HEAD /tidakada 15:08:14, GET / untuk grep judul, GET / versi gzip pukul 15:09:26, dan HEAD / gzip terakhir. Log adalah jejak audit: tidak satu permintaan pun lewat tanpa dicatat.
  2. 172.18.0.1 pada setiap baris — wajah kita di mata container. Permintaan dari host masuk lewat gateway network container-web-statis_default; subnet kedua pool berurutan Docker (172.17 milik docker0, lalu 172.18 milik network kita), persis peta Konsep Networking Docker dan Membuat Custom Network.
  3. [13/Oct/2026:15:07:31 +0000] — stempel waktu UTC: container tidak memasang zona waktu, jadi +0000 adalah nol yang jujur. Bandingkan dengan header Date pada respons pertama kita yang juga GMT — keduanya mencatat detik yang sama dari dua alamat berbeda: log akses dan header HTTP minum dari satu sumber kebenaran, jam sistem container.
  4. Kolom byte terakhir bercerita per permintaan — HEAD mencatat 0 karena tanpa isi; GET polos mencatat 2154 — persis Content-Length; dan GET gzip mencatat 980, bukan 968 — selisih 12 byte adalah pembungkus chunked (penanda ukuran potongan dan penutupnya) yang ikut terhitung sebagai byte yang dikirim. nginx membukukan apa yang benar-benar berangkat.
  5. "curl/8.14.1" di ujung baris — user agent lengkap dengan versi, dicatat apa adanya; tanda kutip kosong "-" sebelumnya adalah kolom identitas dan referrer yang memang tidak dikirim curl.

Siklus Hidup: Stop, Gagal, Start #

Saksi terakhir menguji klaim paling berani dari compose.yaml: restart: unless-stopped. Kita hentikan stack dengan sengaja:

bash
$ docker compose stop

Output yang Dihasilkan:

text
[+] Stopping 1/1
 ✔ Container web-statis  Stopped                                       0.3s

Sekarang pintu port 8080 seharusnya benar-benar tertutup. Bukti paling jujur datang dari klien yang mencoba mengetuknya:

bash
$ curl -I http://localhost:8080; echo "exit code: $?"

Output yang Dihasilkan:

text
curl: (7) Failed to connect to localhost port 8080 after 0 ms: Could not connect to server
exit code: 7

Tidak ada yang memalsukan kegagalan ini: koneksi ditolak seketika, curl melaporkan exit code 7 — kode kegagalan koneksinya sendiri, bukan kode HTTP — dan pesannya menyebut alamat yang persis kita matikan. Perhatikan pula sebuah keheningan: container tidak bangkit sendiri meski restart: unless-stopped terpasang. Itu bukan kegagalan kebijakan, melainkan intinya: seluruh kebijakan restart menghormati penghentian manual, dan unless-stopped bahkan mencatatnya permanen — daemon yang menyala ulang pun tidak akan membangunkannya, persis tabel Restart Policy. Janji kebijakan ini berlaku untuk kematian yang tak diundang: crash, OOM, host yang reboot — bukan untuk keputusan tangan manusia.

Karena keputusan manual juga bisa ditarik, hidupkan kembali:

bash
$ docker compose start

Output yang Dihasilkan:

text
[+] Running 1/1
 ✔ Container web-statis  Started                                       0.4s

Dan pintu terbuka lagi, terbukti lewat respons yang sama persis:

bash
$ curl -I http://localhost:8080

Output yang Dihasilkan:

text
HTTP/1.1 200 OK
Server: nginx/1.29.4
Date: Tue, 13 Oct 2026 15:11:48 GMT
Content-Type: text/html
Content-Length: 2154
...

Sisa header sama persis dengan respons pertama — Last-Modified dan ETag tak bergerak sedikit pun, karena start menghidupkan container yang sama dengan berkas yang sama: tidak ada build, tidak ada layer baru, hanya proses yang melanjutkan pekerjaannya.

Proyek selesai; tamu dipulangkan dan meja dibersihkan:

bash
$ docker compose down

Output yang Dihasilkan:

text
[+] Running 2/2
 ✔ Container web-statis                  Removed                         0.3s
 ✔ Network container-web-statis_default  Removed                         0.2s

Dua baris ✔ itu adalah kebersihan yang kita maksud: container dihapus dan network bawaan project ikut dibongkar. Yang tidak ikut hilang adalah image container-web-statis:1.0 — ia tetap tersimpan lokal, siap dipakai up berikutnya tanpa membangun ulang. Menjalankan ulang seluruh proyek kini cuma satu perintah dari folder yang sama, dan menghapus jejaknya juga satu perintah.

Ringkasan Proyek #

Empat bagian berlalu dan lima janji tertunaikan:

  1. Situs statis satu berkas — katalog Kopi Nusantara yang sadar-container: satu permintaan, tanpa dependensi eksternal, encoding jelas sejak baris pertama.
  2. Dockerfile, nginx.conf, .dockerignore yang saling mengunci — image 48.2MB dengan basis terpin, konfigurasi tervalidasi saat build, dan context yang tersaring sejak berangkat.
  3. Build yang cepat dan bisa diulang — 12 langkah dalam 4 detik, cache yang bekerja penuh pada build kedua, dan output BuildKit yang kita baca seperti membaca buku.
  4. Definisi yang hidup di berkas — seluruh flag docker run bermigrasi ke compose.yaml sepuluh baris yang bisa di-commit dan dijalankan siapa pun.
  5. Pembuktian menyeluruh — header demi header dijelaskan asal-usulnya, gzip terukur 55 persen, log membukukan lima permintaan, siklus stop-start teruji, dan teardown bersih tanpa sisa.

Keberangkatan berikutnya sudah menunggu di Proyek: Aplikasi Go dan PostgreSQL. Jika proyek ini menyajikan berkas, proyek itu melayani API: backend Go yang dikompilasi multi-stage, database PostgreSQL dengan healthcheck dan migrasi otomatis, container yang saling bicara lewat DNS Compose, dan integration test yang berjalan dari dalam stack. Semua kebiasaan baik yang kita tanam di sini — image ramping, konfigurasi tervalidasi, definisi di berkas, pembuktian dengan uji — akan naik kelas mengikuti tuntutan aplikasi dinamis.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

compose.yaml

Unduh berkas
yaml
name: container-web-statis

services:
  web:
    build: .
    image: container-web-statis:1.0
    container_name: web-statis
    ports:
      - "8080:80"
    restart: unless-stopped

site/index.html

Unduh berkas
html
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="description" content="Katalog produk Kopi Nusantara: Torabika, Gayo, dan Kintamani.">
  <title>Katalog Produk — Kopi Nusantara</title>
  <style>
    /* Palet warna terpusat sebagai custom properties. */
    :root {
      --latar: #f7f4ef;
      --teks: #2b2723;
      --aksen: #6f4e37;
      --kartu: #ffffff;
    }
    body {
      font-family: Georgia, serif;
      background: var(--latar);
      color: var(--teks);
      margin: 0;
      line-height: 1.6;
    }
    header, footer { background: var(--aksen); color: #fff; text-align: center; padding: 1rem; }
    h1 { margin: 0; font-size: 1.6rem; }
    main { max-width: 680px; margin: 2rem auto; padding: 0 1rem; }
    h2 { color: var(--aksen); margin: 0 0 1rem; }
    .pengantar { margin: 0 0 1.5rem; }
    section.produk { display: grid; gap: 1rem; }
    .kartu {
      background: var(--kartu);
      border: 1px solid #e6dfd3;
      border-radius: 8px;
      padding: 1rem;
    }
    .kartu h3 { margin: 0 0 0.25rem; }
    .harga { color: var(--aksen); font-weight: bold; margin: 0 0 0.25rem; }
    .kartu p { margin: 0; }
  </style>
</head>
<body>
  <header>
    <h1>Kopi Nusantara</h1>
    <p>Kopi pilihan Indonesia</p>
  </header>
  <main>
    <h2>Daftar Produk</h2>
    <p class="pengantar">Tiga kopi favorit kami, disangrai segar setiap pekan.</p>
    <section class="produk">
      <article class="kartu">
        <h3>Kopi Torabika</h3>
        <p class="harga">Rp 28.000</p>
        <p>Robusta dengan aroma kuat, favorit pelanggan.</p>
      </article>
      <article class="kartu">
        <h3>Kopi Gayo</h3>
        <p class="harga">Rp 45.000</p>
        <p>Arabika dataran tinggi Aceh dengan aroma rempah.</p>
      </article>
      <article class="kartu">
        <h3>Kopi Kintamani</h3>
        <p class="harga">Rp 42.000</p>
        <p>Arabika Bali dengan sentuhan rasa sitrus.</p>
      </article>
    </section>
  </main>
  <footer>
    <p>&copy; 2026 Kopi Nusantara — disajikan oleh nginx</p>
  </footer>
</body>
</html>