Konten sudah siap di site/; sekarang kita menulis kotaknya. Tiga berkas lahir di bagian ini dan ketiganya saling mengunci: Dockerfile adalah resep pengemasan, nginx.conf menentukan bagaimana isi kotak itu disajikan, dan .dockerignore menjaga agar hanya bahan yang perlu yang ikut terkirim ke builder. Urutan penulisan kita mengikuti urutan eksekusinya kelak — build context disaring, Dockerfile dibaca, lalu baris demi barisnya membentuk layer-layer image. Setiap baris yang kita tulis di sini punya jejak yang bisa dibaca kembali: di output BuildKit pada Bagian 3 dan di header HTTP pada Bagian 4.

Dockerfile untuk Situs Statis #

Buat berkas Dockerfile di akar folder proyek dengan isi berikut:

dockerfile
# Image berbasis nginx resmi versi minor yang dipin.
FROM nginx:1.29-alpine

LABEL org.opencontainers.image.title="container-web-statis" \
      org.opencontainers.image.description="Situs katalog produk statis yang disajikan nginx" \
      org.opencontainers.image.source="https://example.com/container-web-statis" \
      org.opencontainers.image.licenses="MIT"

# Konfigurasi server menimpa default.conf bawaan image.
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Konten situs disalin setelah konfigurasi agar layer mudah di-cache.
COPY site/ /usr/share/nginx/html

EXPOSE 80

# Validasi konfigurasi saat build: gagal di sini lebih baik daripada crash saat runtime.
RUN nginx -t

Mari kita telaah keenam instruksi tersebut satu per satu:

  1. FROM nginx:1.29-alpine — image dasar resmi nginx, dipin pada minor 1.29 dengan varian Alpine 47.9MB. Kebiasaan memilih tag eksplisit ini warisan langsung Memahami Image Tag: tag latest bisa bergeser diam-diam, sedangkan 1.29-alpine terkunci pada rilis 1.29 — dan kelak terbukti lewat header Server: nginx/1.29.4 di Bagian 4. Mengunci lebih dalam lagi dengan digest tetap tersedia lewat pola FROM image@sha256:… dari Image Digest.
  2. LABEL dengan kunci OCI — empat pasang kunci-nilai org.opencontainers.image.* menempelkan metadata standar Open Containers pada image: judul, deskripsi, sumber, dan lisensi. Label tidak menambah layer filesystem — ia hidup di konfigurasi image, persis perilaku yang kita bedah pada VOLUME dan LABEL — tapi membuat image ini dikenali tool ekosistem: registry, scanner, dan catalog.
  3. COPY nginx.conf /etc/nginx/conf.d/default.conf — konfigurasi kita menimpa default.conf bawaan image di lokasi yang tepat: nginx memuat seluruh conf.d/*.conf dari dalam blok http pada konfigurasi utamanya. Tanpa penimpaan ini, container akan menyajikan halaman selamat datang bawaan nginx — bukan katalog Kopi Nusantara.
  4. COPY site/ /usr/share/nginx/html — seluruh isi folder konten Bagian 1 disalin ke direktori penyajian bawaan image. Perhatikan urutannya: konfigurasi disalin lebih dulu, konten belakangan — persis strategi cache dari Build Cache. Suntingan site/index.html kelak hanya meruntuhkan layer keempat ke bawah, sementara layer COPY nginx.conf beserta semuanya di atasnya tetap CACHED.
  5. EXPOSE 80 — catatan metadata bahwa container ini mendengarkan port 80. Ia tidak menerbitkan port apa pun ke host — tugas itu dipegang flag -p dan kunci ports — sebagaimana kita tegaskan tuntas di EXPOSE dan Port.
  6. RUN nginx -t — jangkar keselamatan proyek ini: image nginx menyertakan binary nginx, dan nginx -t memvalidasi seluruh konfigurasi yang barusan kita timpa. Bila nginx.conf keliru, build gagal di sini — bukan saat container dinyalakan di depan pengunjung. Komentar pada barisnya mengatakan tegas: gagal saat build lebih baik daripada crash saat runtime.

Satu instruksi yang sengaja tidak kita tulis justru paling sering ditanyakan pemula: di mana CMD? Jawabannya: kita mewarisinya. Image nginx resmi membawa ENTRYPOINT bernama /docker-entrypoint.sh dan CMD berupa nginx -g daemon off; — skrip entrypoint itu pula yang menyiapkan konfigurasi bawaan setiap kali container menyala. Karena Dockerfile kita tidak menimpa keduanya, container hasil build tetap menjalankan nginx di latar depan sebagai PID 1, persis perilaku yang kita bedah pada CMD dan ENTRYPOINT. Menulis ulang CMD yang sama hanya menambah satu baris yang bisa salah; mewarisi yang benar adalah pilihan paling bersih.

Konfigurasi nginx Gaya conf.d #

Berkas kedua adalah nginx.conf — meski namanya begitu, isinya bukan konfigurasi utama melainkan satu blok server gaya conf.d yang ditempelkan menimpa default.conf bawaan image. Simpan di akar proyek:

conf
# Konfigurasi server untuk situs statis (gaya conf.d).
# Berkas ini menimpa default.conf bawaan image nginx dan otomatis
# dimuat oleh blok http lewat directive include conf.d/*.conf.

server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    # Kompresi respons untuk tipe selain text/html (selalu dikompresi).
    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
    gzip_min_length 256;

    # Aset statis boleh di-cache panjang oleh browser.
    location ~* \.(css|js|png|jpg|jpeg|svg|ico)$ {
        expires 7d;
        add_header Cache-Control public;
    }

    # index.html selalu divalidasi ulang agar pembaruan konten langsung terlihat.
    location = /index.html {
        add_header Cache-Control no-cache;
    }

    location / {
        try_files $uri $uri/ =404;
    }
}

Mari kita telaah enam kelompok directive di dalamnya:

  1. listen 80 dan server_name _ — blok server ini menerima koneksi pada port 80 untuk nama host apa pun; garis bawah adalah konvensi catch-all. Port yang didengarkan ini harus sejajar dengan EXPOSE 80 di Dockerfile dan sisi :80 pada pemetaan port di Bagian 3 — tiga tempat, satu angka.
  2. root dan index — direktori penyajian /usr/share/nginx/html dan berkas pembuka index.html. Dua baris ini saling mengunci dengan Dockerfile: root wajib sama dengan tujuan COPY site/, dan index wajib sama dengan nama berkas Bagian 1. Satu huruf saja berbeda, dan yang muncul di layar pengunjung adalah 404 — bukan katalog.
  3. gzip on, gzip_types, gzip_min_length 256 — kompresi menyala untuk text/css, application/javascript, application/json, dan image/svg+xml. Yang tidak tercantum bukan berarti lolos: text/html selalu ikut dikompresi ketika gzip on — ia tidak perlu — dan tidak boleh — ditulis di gzip_types. Ambang 256 byte mencegah respons mungil justru membesar karena pembungkus kompresi.
  4. location ~* \.(css|js|png|jpg|jpeg|svg|ico)$ — pola regex untuk berkas aset: expires 7d menetapkan header Expires tujuh hari ke depan, dan add_header Cache-Control public mengizinkan cache bersama menyimpannya. Situs kita hari ini belum punya aset seperti itu — blok ini investasi untuk saat site/ kemudian hari berisi CSS dan gambar.
  5. location = /index.html — kebalikan dari aset: halaman utama justru tidak boleh dijamah cache lama. Cache-Control no-cache berarti “boleh disimpan, tapi wajib divalidasi ulang ke server sebelum dipakai” — berbeda dari no-store yang melarang penyimpanan sama sekali. Dengan begitu pembaruan konten terlihat seketika, tanpa membayar biaya unduhan penuh setiap kunjungan.
  6. location / dengan try_files $uri $uri/ =404 — penjaga gerbang terakhir: coba berkas persis namanya, coba sebagai folder, lalu menyerah dengan 404 yang tegas. Tanpa fallback =404, permintaan ke jalur yang tak ada bisa berakhir pada daftar isi direktori otomatis — perilaku yang tidak kita inginkan dari situs publik.

.dockerignore untuk Build Context #

Berkas ketiga menyeleksi apa yang boleh meninggalkan folder proyek menuju builder. Simpan sebagai .dockerignore:

text
# Berkas yang tidak perlu ikut dalam build context.
.git
.gitignore
README.md
bagian/
*.md

Lima baris penyaring ini bekerja sebelum satu byte context pun terkirim, dan alasan pemakaiannya ada tiga:

  1. Context tetap ramping. Tanpa penyaring, riwayat Git, dokumentasi, dan materi penulisan chapter ikut diarsipkan dan dikirim ke builder pada setiap build — di mesin sungguhan, .git saja bisa berukuran puluhan megabyte. Dengan penyaring, context proyek ini tinggal hitungan kilobyte, dan angka persisnya kita saksikan pada baris transferring context di Bagian 3.
  2. Cache COPY tetap awet. Checksum layer dihitung dari isi context yang terkirim; menyunting README.md atau menambah catatan pada *.md lain tidak lagi meruntuhkan rantai cache — mekanisme invalidasi yang kita bedah pada .dockerignore dan Build Cache.
  3. Image hanya berisi yang dibutuhkan. Folder bagian/ berisi materi penulisan course ini — tidak ada alasannya ikut menumpang di dalam image produksi. Penyaringan menjaga batas yang sama dengan pemisahan site/ di Bagian 1: konten, resep, dan dokumentasi tidak saling mencemari.

Satu kejutan lama juga berlaku di sini: menyaring Dockerfile sendiri tidak membuat build gagal. Builder membaca definisi build lewat jalurnya tersendiri — langkah load build definition pada output Bagian 3 — terpisah dari pengarsipan context, sebagaimana kita saksikan pada .dockerignore. .dockerignore kita tidak menyaring Dockerfile; ia hanya menahan *.md, README.md, bagian/, dan riwayat Git — sementara nginx.conf, compose.yaml, dan site/ tetap berangkat sebagai bahan build.

Tiga berkas kini berdiri di akar proyek, dan resepnya tinggal dibakar. Bagian berikutnya menghidupkan seluruh tulisan kita: docker build merakit layer demi layer dengan output BuildKit yang kita baca baris demi baris, docker run menghidupkan container dengan flag-flag yang sudah akrab, lalu docker compose up memindahkan semuanya ke dalam satu berkas deklarasi. Ikuti terus — angka-angka pada Bagian 4 semuanya lahir dari keputusan yang kita ambil di bagian ini.

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>