Satu baris pada verifikasi chapter sebelumnya adalah seluruh alasan chapter ini ada: DB_PASSWORD=rahasia123 terpampang polos di environment container produksi. Utang yang kita catat besar-besaran kini jatuh tempo, dan cara menunaikannya tidak bisa berupa menyembunyikan nilai di tempat yang lebih gelap — karena masalahnya bukan tempat, melainkan medium. Environment variable adalah medium yang bocor: ia menempel pada konfigurasi container yang terbaca docker inspect, diwariskan ke setiap proses turunan, dan ikut terbawa oleh crash dump serta skrip diagnostik. Chapter ini mengganti mediumnya: kredensial berpindah bentuk dari string yang terpampang menjadi berkas read-only yang terpasang tepat di satu service, dirakit menjadi connection string hanya di dalam memori aplikasi — lalu ditutup dengan keterampilan yang membedakan produksi dari latihan: rotasi terjadwal.
Environment yang Terbuka #
Mari kita lihat dulu sekali lagi wajah kebocoran itu, sebelum menutupnya untuk selamanya. Perintah yang sama dengan demo Chapter Secret Management dijalankan pada backend produksi:
$ docker inspect toko-produksi-backend-1 --format '{{range .Config.Env}}{{println .}}{{end}}'Output yang Dihasilkan (dipotong):
APP_ENV=produksi
DB_HOST=db
DB_NAME=toko
DB_PASSWORD=rahasia123
DB_USER=toko
...
REDIS_HOST=cache
WORKERS=4Delapan baris di atas menunjukkan pembagian yang sehat dan satu penyusup: APP_ENV sampai WORKERS memang konfigurasi yang boleh dilihat siapa pun, tetapi DB_PASSWORD adalah kredensial yang menempel pada papan itu hanya karena belum ada kendaraan yang lebih layak.
Empat Opsi Rahasia Produksi #
Kendaraan itu bukan cuma satu. Empat opsi berikut adalah peta lengkapnya, dan pilihan hari ini bukan pilihan selamanya:
| Opsi | Bentuk | Cocok untuk |
|---|---|---|
Compose secrets dengan mode: 0400 | berkas read-only di /run/secrets, dideklarasikan per-service | satu-dua host tanpa orchestrator; pilihan toko hari ini |
| Secret manager + init container | Vault menyimpan terenkripsi, init container menyuntikkan ke volume | banyak host, kebutuhan audit log akses, rotasi otomatis |
| SOPS di repositori | berkas rahasia terenkripsi di Git, didekripsi saat deploy | tim yang menyimpan segalanya di repo dan sanggup menjaga kunci |
docker secret di Swarm | objek terenkripsi di log Raft para manajer | cluster Swarm — bahasan Section Orchestration dan Ekosistem |
Toko hari ini berdiri di satu host dengan satu compose.yaml, sehingga opsi pertama adalah kecocokan yang jujur; tiga lainnya adalah tangga yang kita pahami sekarang supaya saat skala menuntut, pindahnya menjadi keputusan dan bukan kepanikan.
Compose Secrets dengan Mode 0400 #
Ritualnya dimulai dari berkas di host. Folder secrets/ lahir di dalam project deployment, berisi berkas rahasia yang izinnya ditarik serapat mungkin sejak dari luar container:
$ mkdir -p secrets
$ printf 'rahasia123' > secrets/db_password.txt
$ chmod 600 secrets/db_password.txt
$ ls -l secrets/Output yang Dihasilkan:
total 4
-rw------- 1 budi budi 10 Oct 16 09:12 secrets/db_password.txtNilai rahasia123 sengaja dipertahankan sama dengan password yang sudah tertanam di database dari inisialisasi pertama — bab ini mengganti cara penyerahan, bukan isi: container yang sama, database yang sama, kredensial yang sama, dikirim lewat jalan yang tidak terbaca papan pengumuman. Berkas itu kini dideklarasikan di compose.yaml sebagai objek teratas:
secrets:
db_password:
file: ./secrets/db_password.txtDua service memakainya dengan aturan pemasangan yang berbeda — backend meminta bentuk panjang yang paling ketat, db memakai bentuk yang cukup untuk entry point postgres:
backend:
image: registry.example.com/toko/backend:1.0.0
user: "1000:1000"
env_file:
- env/${APP_ENV:-dev}.env
secrets:
- source: db_password
target: db_password
uid: "1000"
gid: "1000"
mode: 0400
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: toko
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: toko
secrets:
- source: db_password
target: db_password
mode: 0444Dua baris DB_PASSWORD dan POSTGRES_PASSWORD dari chapter sebelumnya dihapus; .env menyusut menjadi satu baris APP_ENV=produksi. Mari kita telaah empat kunci pada deklarasi panjang milik backend:
uid: "1000"dangid: "1000"— berkas yang terpasang di/run/secrets/db_passworddimiliki oleh usernode(UID 1000), satu-satunya pihak yang berhak membacanya; proses lain di dalam container yang sama pun tidak.mode: 0400— bit izin pemasangan: hanya pemilik yang boleh membaca, tidak ada yang boleh menulis; tanpa kunci ini mode bawaan lebih longgar dan rahasia menjadi bacaan umum seluruh proses.target: db_password— nama berkas di dalam container; path lengkapnya/run/secrets/db_password, konvensi yang sama dengan image resmi postgres sehinggaPOSTGRES_PASSWORD_FILElangsung kompatibel.source: db_passwordmenunjuk deklarasi teratas — satu berkas di host dipasang dua kali dengan aturan berbeda: ketat untuk backend, longgar secukupnya untuk db; tiap service menerima rahasia dalam bentuk yang paling sempit yang masih berfungsi.
Connection String Dirakit saat Runtime #
Di sisi aplikasi, server.js 1.0.0 memuat blok yang Budi tanam bersama validasi konfigurasi — kredensial dibaca dari berkas, potongan non-rahasia dari environment, dan keduanya baru dipersatukan di dalam memori:
const fs = require("fs");
function bacaSecret(nama) {
return fs.readFileSync(`/run/secrets/${nama}`, "utf8").trim();
}
function bacaKredensialDb() {
try {
return bacaSecret("db_password");
} catch {
if (process.env.DB_PASSWORD) {
return process.env.DB_PASSWORD;
}
throw new Error("kredensial db tidak ditemukan di /run/secrets/db_password maupun DB_PASSWORD");
}
}
const kredensial = encodeURIComponent(bacaKredensialDb());
const sumberData =
`postgres://${process.env.DB_USER}:${kredensial}@${process.env.DB_HOST}:5432/${process.env.DB_NAME}`;Mari kita telaah empat keputusan pada blok tersebut:
readFileSyncdengantrim()— berkas secret dibaca sekali saat startup;trim()membuang baris baru yang tidak sengaja ditulis editor, penyebab klasik galat autentikasi yang “tidak masuk akal”.- Fallback ke
DB_PASSWORD— pola*_FILEala image resmi postgres: berkas dulu, environment sebagai cadangan; jalur cadangan tetap hidup untuk menjalankan image yang sama di laptop tanpa berkas secret, dan di produksi cukup tidak diisi. encodeURIComponent— kredensial di-enkode sebelum disisipkan ke connection string, sehingga password yang mengandung@,:, atau/tidak merusak alamat; jebakan ini biasanya baru menyala justru setelah rotasi memperkenalkan karakter baru.- Perakitan terjadi di runtime — tidak ada satu pun tempat yang memuat kredensial lengkap sebelum proses berjalan: berkas hanya berisi kredensial, environment hanya berisi potongan non-rahasia, dan gabungan keduanya hidup sesaat di memori aplikasi.
Perubahan diterapkan dan hasilnya diperiksa dengan pemeriksaan yang sama yang dulu memperlihatkan kebocoran:
$ docker compose up -dOutput yang Dihasilkan (dipotong):
[+] Running 6/6
✔ Container toko-produksi-db-1 Recreated 0.4s
✔ Container toko-produksi-cache-1 Running 0.0s
✔ Container toko-produksi-db-1 Healthy 3.1s
✔ Container toko-produksi-backend-1 Recreated 0.3s
✔ Container toko-produksi-backend-1 Started 0.6s
✔ Container toko-produksi-proxy-1 Running 0.0s$ docker inspect toko-produksi-backend-1 --format '{{range .Config.Env}}{{println .}}{{end}}'Output yang Dihasilkan (dipotong):
APP_ENV=produksi
DB_HOST=db
DB_NAME=toko
DB_USER=toko
...
NODE_ENV=production
...
REDIS_HOST=cache
WORKERS=4Papan pengumuman kini hanya berisi pengumuman: DB_PASSWORD lenyap dari environment, dan aplikasi tetap berhasil masuk ke database — buktinya /healthz yang mengetuk SELECT 1 sungguhan masih menjawab:
$ docker compose exec backend wget -qO- http://localhost:3000/healthzOutput yang Dihasilkan:
okKetatnya pemasangan bisa dilihat dari dalam container:
$ docker compose exec backend ls -l /run/secrets/Output yang Dihasilkan:
-r-------- 1 node node 10 Oct 16 09:14 db_passwordPemilik node, izin 0400, sepuluh byte — persis deklarasi panjang yang kita tulis. Inilah tampilan SEHAT yang kita bandingkan dengan tampilan BOCOR di pembuka chapter: perbedaan keduanya bukan isi rahasia, melainkan siapa yang sanggup membacanya.
Secret Manager dan Init Container #
Compose secrets memindahkan rahasia keluar dari environment, tetapi rahasianya tetap berupa berkas teks polos di disk host — cukup untuk satu-dua mesin, kurang begitu toko punya banyak server dan kebijakan audit. Pola berikutnya menyimpan rahasia di secret manager terenkripsi dan menyuntikkannya tepat sebelum aplikasi menyala, lewat init container yang menulis ke volume bersama:
secret-init:
image: alpine:3.20
volumes:
- secrets-run:/run/secrets
environment:
VAULT_ADDR: ${VAULT_ADDR:?setel di .env}
VAULT_TOKEN: ${VAULT_TOKEN:?setel di .env}
entrypoint: ["/bin/sh", "-c"]
command:
- |
apk add --no-cache curl jq >/dev/null
curl -s -H "X-Vault-Token: $${VAULT_TOKEN}" \
"$${VAULT_ADDR}/v1/toko/data/db" \
| jq -r .data.data.password > /run/secrets/db_password
chown 1000:1000 /run/secrets/db_password
chmod 0400 /run/secrets/db_password
backend:
image: registry.example.com/toko/backend:1.0.0
user: "1000:1000"
volumes:
- secrets-run:/run/secrets
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
secret-init:
condition: service_completed_successfullyAlurnya tergambar sebagai rantai satu arah:
Mari kita telaah empat bagian yang bekerja sama pada snippet tersebut:
secret-initmemakai image kecil — alpine dengancurldanjqdipasang sekali jalan; init container adalah proses sekali pakai yang layak seramping mungkin.- Volume
secrets-runsebagai jembatan tangan pertama — berkas ditulis oleh init container dan dibaca oleh backend; tidak ada rahasia yang menempel pada environment, image, atau compose.yaml. condition: service_completed_successfully— gerbangdepends_onbentuk ketiga: backend menunggu bukan “menyala” dan bukan “sehat”, melainkan “selesai dengan exit 0” — pola job one-shot yang kita kenal dari Chapter Startup dan Dependency.chown 1000:1000sebelumchmod 0400— berkas ditulis oleh root milik init container; kepemilikan dipindahkan ke UID backend supaya ketatanan izin yang sama dengan compose secrets tetap terjaga.
Untuk kredensial yang berumur pendek — token yang kadaluarsa dalam hitungan jam — pola init container kurang gesit karena hanya berjalan sekali saat up. Kelanjutannya adalah sidecar: service kecil yang berdampingan dengan aplikasi dan menyegarkan berkas secara berkala — tidur, mengambil token baru, menimpa berkas di volume, lalu berulang — sementara aplikasi membaca ulang berkas itu setiap kali membangun koneksi baru. Kombinasi init container dan sidecar inilah yang membuat rotasi menjadi otomatis penuh di lingkungan besar.
Satu opsi lagi mengambil jalur berbeda: SOPS mengenkripsi berkas rahasia langsung di dalam repositori, sehingga secrets/db_password.txt bisa dikomit dalam bentuk tersandi dan didekripsi hanya saat deploy. Kejujurannya perlu dibawa sejak awal: masalahnya tidak lenyap melainkan bergeser — kunci dekripsi kini yang harus didistribusikan ke setiap mesin deploy dan dirotasi secara disiplin. Bagi tim yang sudah menyimpan seluruh kebijakan infrastruktur di Git, kompromi itu adil; bagi toko yang belum memilikinya, compose secrets hari ini adalah langkah yang tepat pada tangganya.
Rotasi Rahasia #
Rahasia yang tidak pernah berganti adalah kredensial yang bocor diam-diam bertahun-tahun — sehingga rotasi bukan prosedur darurat, melainkan jadwal. Prosedurnya tiga langkah, dan urutannya sengaja menjaga jendela gangguan sekecil mungkin:
$ printf 'rahasia456' > secrets/db_password.txt
$ chmod 600 secrets/db_password.txt
$ docker compose up -d db backendOutput yang Dihasilkan (dipotong):
[+] Running 4/4
✔ Container toko-produksi-db-1 Recreated 0.5s
✔ Container toko-produksi-db-1 Healthy 3.4s
✔ Container toko-produksi-backend-1 Recreated 0.3s
✔ Container toko-produksi-backend-1 Started 0.7sSelama jendela singkat setelah up -d, dua kredensial hidup tumpang tindih: yang baru sudah terpasang di berkas dan dibaca backend, yang lama masih menjadi kebenaran di dalam database — dan tidak satu pun permintaan bisa masuk sampai jendela itu ditutup. Penutupnya adalah langkah ketiga, mengajak database berganti kata sandinya sendiri:
$ docker compose exec db psql -U toko -c "ALTER ROLE toko WITH PASSWORD 'rahasia456';"Output yang Dihasilkan:
ALTER ROLESejak baris itu, kredensial lama mati di kedua sisi sekaligus. Tiga keputusan praktis menjaga rotasi tetap sehat: jalankan pada jam sepi (probe /healthz yang gagal selama jendela akan cepat menurunkan status backend, dan itu memang alarm yang benar), rekatkan ketiga langkah sebagai satu runbook yang dieksekusi beruntun, dan rotasi berikutnya dijadwalkan — misalnya tiap 90 hari — bukan menunggu firasat. Untuk rahasia yang dipakai banyak service, ganti satu service dalam satu waktu supaya wilayah gangguan tetap sekecil desain kita.
Kredensial toko kini menempati tempat yang benar di seluruh daur hidupnya: tersimpan sebagai berkas berizin ketat, terpasang read-only hanya pada service yang berhak, dirakit di memori saat runtime, dan berganti terjadwal. Namun mengamankan barang tidak sama dengan menjaga napas — stack yang rahasianya tertata tetap bisa mati diam-diam oleh crash yang tidak dibangkitkan atau kegagalan senyap yang tidak dilaporkan. Ketahanan semacam itu menuntut tiga lapis yang bekerja pada waktu berbeda: probe yang jujur di image, restart policy di deployment, dan kalibrasi ritme di compose. Chapter Healthcheck dan Restart Policy menyatukan ketiganya.
Referensi #
- Secrets — Compose File Reference
— deklarasi teratas
secrets, kunciuid,gid,mode, dan path bawaan/run/secrets. - Compose File Reference — Services
—
depends_ondenganservice_completed_successfullyuntuk gerbang job one-shot. - Docker secrets di Swarm — distribusi secret terenkripsi Raft sebagai tangga berikutnya.
- PostgreSQL
— dukungan pola
POSTGRES_PASSWORD_FILEpada image resmi.