Janji yang digantung sejak Chapter Reverse Proxy di Section Multi-Container Application kini jatuh tempo: “sertifikat cukup dipasang di proxy, sebelum lalu lintas terbelah”. Menyala ber-TLS di satu tempat — bukan di setiap service — adalah keputusan arsitektur dengan tiga dividen. Sertifikat hidup terpusat: dipasang, diganti, dan diperbarui di satu service, bukan dikurasi ke empat aplikasi yang masing-masing punya cara salah sendiri. Pengalihan HTTP ke HTTPS menjadi kebijakan satu baris di pintu, bukan konvensi yang harus dipatuhi semua penghuni. Dan seluruh service di belakang proxy tetap polos HTTP internal — backend Node, frontend nginx, sampai db tidak pernah tahu enkripsi ada, tidak memuat satu baris kode TLS, dan tetap tak terlihat dari internet. Chapter ini melunasi janji itu untuk toko: sertifikat pertama lewat certbot dengan DNS challenge, konfigurasi nginx produksi yang tetap waras ketika IP backend berganti, lini depan lengkap di compose.yaml, dan verifikasi dari dua arah — pintu HTTP yang mengarahkan, dan pintu HTTPS yang menjawab.
Sertifikat Pertama lewat DNS Challenge #
Budi sudah menyiapkan domain untuk toko — sebut saja toko.example.com — beserta akses ke panel DNS penyedianya. Sertifikat produksi diterbitkan Let’s Encrypt lewat certbot, dan certbot sendiri berjalan sebagai container: compose.yaml menerima dua penghuni baru sebelum stack dinyalakan ulang, yaitu service certbot dan volume etc-letsencrypt yang akan menjadi rumah sertifikat:
certbot:
image: certbot/certbot
volumes:
- etc-letsencrypt:/etc/letsencrypt
- certbot-webroot:/var/www/certbot
entrypoint: >
/bin/sh -c "trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot --quiet; sleep 12h & wait $${!}; done"
restart: unless-stopped
volumes:
etc-letsencrypt:
certbot-webroot:Penerbitan pertama tidak memakai loop itu, melainkan satu kali jalan interaktif — docker compose run menimpa entrypoint agar certbot bicara langsung dengan kita:
$ docker compose run --rm --entrypoint certbot certbot certonly --preferred-challenges dnsOutput yang Dihasilkan (dipotong):
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Enter email address (used for urgent renewal and security notices)
(Enter 'c' to cancel): [email protected]
...
Please enter the domain name(s) you would like in your certificate
( comma and/or space separated) (Enter 'c' to cancel): toko.example.com
...
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Please deploy a DNS TXT record for the domain:
_acme-challenge.toko.example.com.
and put this value in it:
9fJ4kQ7xVn2mR8sT6uW3yZ1aB5cD0eF2gH4iJ6kL7mN8o9pQ0rS1tU3vW5xY7zA
...
Before continuing, verify that the TXT record has been deployed. You can
check this by running: dig TXT _acme-challenge.toko.example.com
...
Press Enter to Continue
...
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/toko.example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/toko.example.com/privkey.pem
This certificate expires on 2027-01-18.Mari kita telaah tiga bagian terpenting dari sesi tersebut:
docker compose run --rm --entrypoint certbot— container sekali pakai dari servicecertbot; entrypoint loop ditimpa sementara agar certbot berjalan interaktif,--rmmembersihkan container begitu sesi usai, dan volumeetc-letsencryptmenjadikan hasilnya tetap tinggal setelah container-nya pergi.- Rekaman TXT
_acme-challenge.toko.example.com— bukti kepemilikan domain diajukan lewat DNS, bukan lewat HTTP: Budi menempelkan nilai acak itu di panel DNS penyedia domain, dan Let’s Encrypt memverifikasinya dengan menanyakannya ke DNS publik — tidak satu port pun di server harus terbuka untuk penerbitan ini. fullchain.pemdanprivkey.pemdi dalam volume — pasangan sertifikat jatuh keetc-letsencrypt, volume yang sama kelak dipasang read-only pada proxy; tanggal2027-01-18adalah sembilan puluh hari ke depan — umur sertifikat Let’s Encrypt, sekaligus alasan loop perpanjangan wajib hidup.
Mengapa DNS challenge lebih dulu, bukan HTTP challenge yang tampak lebih lazim? Karena HTTP challenge menuntut kebuntuan yang belum bisa kita pecahkan: tantangan HTTP harus dijawab dari port 80 oleh proxy — padahal proxy kita belum menyala dengan konfigurasi TLS, sebab nginx -t menolak konfigurasi yang mereferensikan berkas sertifikat yang belum ada. Proxy menunggu sertifikat; sertifikat menunggu proxy. DNS challenge memotong lingkaran itu dari luar: sertifikat lahir lebih dulu lewat panel DNS, proxy menyala kedua dengan sertifikat yang sudah menunggu. Tantangan HTTP tetap berharga sesudahnya — begitu proxy berjalan, perpanjangan berpindah ke mode --webroot yang memakai port 80, persis loop di service certbot di atas.
Satu variasi layak dicatat untuk masa depan: certbot memiliki plugin DNS per penyedia — cloudflare, route53, dan kawan-kawannya — yang menuliskan rekaman TXT secara otomatis lewat API, menghapus Press Enter manual dari ritus, dan menjadi satu-satunya jalan bagi sertifikat wildcard seperti *.toko.example.com. Plugin-plugin itu membawa kredensial API sendiri, yang artinya satu rahasia baru masuk daftar — dan pola penanganannya sudah kita miliki dari Chapter Secrets dan Environment.
nginx.conf Produksi #
Sertifikat kini menunggu di volume; giliran konfigurasi yang memakainya. Berkas nginx.conf lama diganti seluruhnya menjadi konfigurasi produksi dua server — pintu HTTP yang mengarahkan dan pintu HTTPS yang melayani:
resolver 127.0.0.11 valid=10s;
server {
listen 80;
server_name toko.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name toko.example.com;
ssl_certificate /etc/letsencrypt/live/toko.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/toko.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location /api/ {
set $backend_host backend:3000;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://$backend_host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
location / {
set $frontend_host frontend:80;
proxy_pass http://$frontend_host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}Mari kita telaah sepuluh keputusan pada konfigurasi tersebut:
resolver 127.0.0.11 valid=10s— proxy berjanji menyelesaikan nama lewat embedded DNS network (alamat yang kita bedah di Chapter Container DNS) dan hasilnya di-cache hanya sepuluh detik; inilah fondasi seluruh ketahanan-redeploy di bawah.location /.well-known/acme-challenge/denganroot /var/www/certbot— pintu kecil yang sengaja dibiarkan terbuka di server 80: certbot menulis berkas tantangan ke volume webroot, proxy menyajikannya, dan perpanjangan HTTP challenge berjalan tanpa menyentuh konfigurasi.return 301dengan URI tujuan terenkripsi — pengalihan permanen yang memelihara nama host asli ($host) beserta seluruh jalurnya ($request_uri); pengunjung yang mengetik alamat lama mendarat di tempat yang sama, hanya terenkripsi.listen 443 ssldanhttp2 on— TLS di port 443 dengan HTTP/2 yang dinyalakan sebagai directive terpisah, bentuk modern sejak nginx 1.25 menggantikan suffixhttp2padalisten.ssl_certificatefullchain danssl_certificate_keyprivkey — pasangan dari volumeetc-letsencryptdipasang read-only;fullchainmembawa rantai sertifikat lengkap supaya klien lama pun membangun kepercayaan tanpa perjalanan tambahan.ssl_protocols TLSv1.2 TLSv1.3— versi lebih tua dari TLS 1.2 dimatikan; negosiasi dengan klien usang gagal dengan jujur alih-alih berhasil dengan enkripsi yang memalukan.ssl_cipherskeluarga AEAD-ECDHE danssl_prefer_server_ciphers off— hanya cipher autentikasi-terenkripsi dengan key exchange ephemeral (forward secrecy), dan hak memilih diberikan kepada klien — praktik modern yang menghormati preferensi browser.ssl_session_cachedanssl_session_tickets off— sesi TLS boleh dilanjutkan lewat cache bersama sepuluh menit untuk memangkas jabat tangan, tetapi ticket dimatikan karena key ticket yang mengendara lama melemahkan forward secrecy yang baru saja kita bangun.Strict-Transport-Securitydenganalways— header HSTS menyuruh browser menolak HTTP untuktoko.example.comselama setahun;alwaysmemastikan perintah itu ikut terkirim pada jawaban galat, bukan hanya saat sukses.set $backend_hostdanproxy_passmemuat variabel — alamat upstream dipindah ke variabel sehingga nginx menunda resolusi nama sampai permintaan datang; tiga header identitas klien dari masa Reverse Proxy tetap diteruskan, kali ini ditambahX-Forwarded-Proto httpsagar aplikasi tahu permintaan telah melewati terowongan terenkripsi.
Kenapa Bukan Blok upstream Statis #
Cara menulis upstream yang paling familiar justru cara yang paling rapuh di produksi. Bentuk statisnya berbunyi demikian:
upstream backend_toko {
server backend:3000;
}Perilaku nginx terhadap dua bentuk itu berbeda pada satu momen yang menentukan: kapan nama di-resolve. Blok upstream statis diselesaikan satu kali, saat memuat konfigurasi — nginx menyimpan hasilnya sebagai alamat IP dan tidak pernah bertanya lagi selama container hidup. Pola proxy_pass dengan hostname langsung pada dasarnya sama: resolusi terjadi saat start.
Konsekuensinya menyala tepat saat produksi bergerak. Setiap rilis versi baru backend — image 1.1.0 ditarik, docker compose up -d backend menciptakan ulang container — memberi backend alamat IP yang baru di network. Proxy yang memegang alamat lama tetap mengirimkan permintaan ke IP yang tidak lagi dihuni: jawabannya 502 Bad Gateway di setiap permintaan, meski backend baru sehat dan segera menjawab healthz. Penyembuhannya manual — docker compose restart proxy supaya resolusi diulang — persis kebiasaan yang kita tandai sebagai utang pada Chapter Reverse Proxy di masa development.
Pola variabel memutus ketergantungan pada momen start. Begitu proxy_pass memuat variabel, nginx menyelesaikan nama saat permintaan berjalan, dengan hasil resolusi di-cache selama valid=10s sesuai directive resolver. Redeploy backend mengganti IP; paling lama sepuluh detik kemudian proxy bertanya ulang dan menemukan penghuni baru — tanpa restart, tanpa operator jam tiga pagi, tanpa satu pun 502 yang lebih tua dari sepuluh detik. Ada bonus ketahanan yang tak disangka: karena resolusi ditunda, proxy kini bisa menyala lebih dulu daripada backend — kegagalan DNS saat start tidak lagi menggagalkan boot, hanya menunda permintaan pertama.
Compose Lini Depan #
Seluruh lini depan kini merapat ke compose.yaml. Folder deployment tumbuh menjadi bentuk finalnya:
toko-produksi/
├── compose.yaml
├── nginx.conf
├── frontend.conf
├── frontend-html/
│ └── index.html
├── .env
├── env/
│ ├── dev.env
│ ├── staging.env
│ └── produksi.env
└── secrets/
└── db_password.txtHalaman frontend-html/index.html adalah halaman yang sama sejak Chapter Reverse Proxy — satu berkas statis dengan tautan ke /api/produk.json — dan frontend.conf-nya singkat: gzip untuk teks yang bisa dikecilkan dan akar dokumen bawaan nginx:
server {
listen 80;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
location / {
root /usr/share/nginx/html;
}
}Bagian baru compose.yaml menyatukan semuanya — proxy naik kelas, frontend hadir, certbot yang tadi mendahului tetap di tempatnya, dan backend hanya berubah tempat dipandang:
proxy:
image: nginx:1.29-alpine
ports:
- "80:80"
- "443:443"
networks:
- frontend-net
volumes:
- etc-letsencrypt:/etc/letsencrypt:ro
- certbot-webroot:/var/www/certbot:ro
configs:
- source: nginx-conf
target: /etc/nginx/conf.d/default.conf
tmpfs:
- /var/cache/nginx
- /run
entrypoint: >
/bin/sh -c "while :; do sleep 6h; nginx -s reload; done & nginx -g 'daemon off;'"
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
frontend:
image: nginx:1.29-alpine
networks:
- frontend-net
configs:
- source: frontend-conf
target: /etc/nginx/conf.d/default.conf
- source: frontend-html
target: /usr/share/nginx/html/index.html
read_only: true
tmpfs:
- /var/cache/nginx
- /run
restart: unless-stopped
backend:
image: registry.example.com/toko/backend:1.0.0
env_file:
- env/${APP_ENV:-dev}.env
...
configs:
nginx-conf:
file: ./nginx.conf
frontend-conf:
file: ./frontend.conf
frontend-html:
file: ./frontend-html/index.htmldocker compose up -d kemudian menciptakan frontend dan certbot, menciptakan ulang proxy dengan tiga perubahan sekaligus — port baru, sertifikat, dan entrypoint reload — sementara db, cache, dan backend tidak tersentuh sama sekali. Mari kita telaah enam keputusan pada lini depan tersebut:
ports80 dan 443 hanya pada proxy — port 8080 sementara dihapus; seluruh permukaan publik toko kini dua port resmi di satu service, dan service lain murni internal.- Volume sertifikat dipasang
:ropada proxy — proxy membaca sertifikat, certbot menulisnya; pemisahan baca-tulis per-mount adalah kebijakan yang sama dengan suffix:rodari Chapter Mount Read-Only. - Tiga berkas lewat
configs— nginx.conf proxy, frontend.conf, dan index.html berstatus objek project: ter-review bersama compose.yaml, dipasang read-only, tanpa path host yang tercecer. - Entrypoint proxy memuat loop
nginx -s reloadtiap enam jam — sertifikat yang diperbarui di disk tidak dimuat otomatis oleh nginx yang sedang berjalan; reload berkala memuatnya tanpa memutus satu koneksi pun, karenareloadmelahirkan worker baru dengan konfigurasi baru sambil worker lama menuntaskan permintaannya. - Service
certbotmenjalankancertbot renew --webroottiap dua belas jam —renewhanya bertindak ketika sertifikat berumur kurang dari tiga puluh hari, jadi loop ini diam selama dua bulan pertama lalu memperbarui dengan tenang;trap exit TERMmembuat container berhenti rapi saatdocker compose stop. - Frontend berjalan
read_onlydengan dua lubang tmpfs — filesystem beku plus/var/cache/nginxdan/run, resep yang kita latih pada Chapter Read-Only Filesystem; untuk frontend yang lebih besar — ratusan berkas, aset hasil build — langkah berikutnya adalah image frontend sendiri yang membakal isi, bukan configs per berkas.
Verifikasi Lini Depan #
Empat pemeriksaan menutup ritual. Yang pertama memastikan konfigurasi yang kita tulis bukan hanya meyakinkan di atas kertas — nginx sendiri yang memeriksanya:
$ docker compose exec proxy nginx -tOutput yang Dihasilkan:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successfulPemeriksaan kedua membaca daftar stack — dan kolom PORTS adalah berita utamanya:
$ docker compose psOutput yang Dihasilkan:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
toko-produksi-backend-1 registry.example.com/toko/backend:1.0.0 "node server.js" backend 16 minutes ago Up 16 minutes (healthy)
toko-produksi-cache-1 redis:7-alpine "docker-entrypoint.s…" cache 16 minutes ago Up 16 minutes (healthy)
toko-produksi-certbot-1 certbot/certbot "/bin/sh -c 'trap ex…" certbot 3 minutes ago Up 3 minutes
toko-produksi-db-1 postgres:18-alpine "docker-entrypoint.s…" db 16 minutes ago Up 16 minutes (healthy)
toko-produksi-frontend-1 nginx:1.29-alpine "nginx -g 'daemon off…" frontend 3 minutes ago Up 3 minutes
toko-produksi-proxy-1 nginx:1.29-alpine "nginx -g 'daemon off…" proxy 3 minutes ago Up 3 minutes 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:80->80/tcp, [::]:443->443/tcpEnam service berjalan, dan hanya satu baris yang mengisi kolom PORTS: proxy, dengan 80 dan 443. Pemeriksaan ketiga mengetuk pintu HTTP — di mesin latihan kita memakai localhost, dan sertifikat yang tertera toko.example.com membuat verifikasi dilewati dengan -k:
$ curl -I http://localhost/Output yang Dihasilkan:
HTTP/1.1 301 Moved Permanently
Server: nginx/1.29.4
Date: Tue, 20 Oct 2026 03:41:07 GMT
Content-Type: text/html
Content-Length: 162
Connection: keep-alive
Location: https://localhost/Pintu HTTP menolak melayani dan mengarahkan — header Location membawa versi terenkripsi dari alamat yang sama persis, janji return 301 yang ditepati. Pemeriksaan terakhir mengetuk pintu yang diarahkan itu:
$ curl -Ik https://localhost/Output yang Dihasilkan:
HTTP/2 200
server: nginx/1.29.4
date: Tue, 20 Oct 2026 03:41:52 GMT
content-type: text/html
strict-transport-security: max-age=31536000; includeSubDomainsEmpat baris terakhir adalah kwitansi lengkap lini depan. HTTP/2 200 membuktikan terminasi TLS bekerja dan protokol modern dinegosiasikan lewat ALPN; server: nginx/1.29.4 adalah proxy yang sama sejak Section Multi-Container Application; content-type: text/html adalah frontend yang menjawab lewat variabel $frontend_host; dan strict-transport-security menegaskan bahwa kunjungan berikutnya dari browser tidak akan pernah kembali ke jalur polos. Toko kini punya pintu sungguhan: terenkripsi, teralihkan, tercatat, dan diperbarui sendiri setiap dua belas jam.
Namun pintu yang aman bukan akhir kisah produksi — ia justru mengubah sorotan ke barang yang dilindunginya. Di balik proxy yang ber-TLS itu, seluruh kebenaran toko beristirahat di sebuah volume disk: data pelanggan, pesanan, stok, dan kelak gambar produk. Sertifikat bisa diterbitkan ulang dalam sembilan puluh hari dan datanya tidak; volume yang lahir diam-diam dari docker compose up bisa musnah diam-diam oleh docker compose down -v; dan disk yang berbagi partisi dengan log sistem bisa tersedak bersama. Chapter Persistent Storage Production mengurus sisi itu: named volume berlabel yang dikelola di luar siklus Compose, volume driver jaringan untuk data yang harus bertahan dari host, dan data-root yang memisahkan Docker ke disk khusus — rumah yang layak bagi data yang kini dilindungi pintu ber-TLS.
Referensi #
- nginx Documentation
— directive
ssl_protocols,resolver,proxy_pass, dan perilaku resolusi upstream. - Docker Networking Overview
— embedded DNS
127.0.0.11pada user-defined network. - Configs — Compose File Reference
— pemasangan berkas konfigurasi sebagai objek project dengan
sourcedantarget. - Compose File Reference — Services
—
entrypoint,ports,depends_on, dantmpfsuntuk lini depan.