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:

bash
$ docker inspect toko-produksi-backend-1 --format '{{range .Config.Env}}{{println .}}{{end}}'

Output yang Dihasilkan (dipotong):

text
APP_ENV=produksi
DB_HOST=db
DB_NAME=toko
DB_PASSWORD=rahasia123
DB_USER=toko
...
REDIS_HOST=cache
WORKERS=4

Delapan 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:

OpsiBentukCocok untuk
Compose secrets dengan mode: 0400berkas read-only di /run/secrets, dideklarasikan per-servicesatu-dua host tanpa orchestrator; pilihan toko hari ini
Secret manager + init containerVault menyimpan terenkripsi, init container menyuntikkan ke volumebanyak host, kebutuhan audit log akses, rotasi otomatis
SOPS di repositoriberkas rahasia terenkripsi di Git, didekripsi saat deploytim yang menyimpan segalanya di repo dan sanggup menjaga kunci
docker secret di Swarmobjek terenkripsi di log Raft para manajercluster 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:

bash
$ mkdir -p secrets
$ printf 'rahasia123' > secrets/db_password.txt
$ chmod 600 secrets/db_password.txt
$ ls -l secrets/

Output yang Dihasilkan:

text
total 4
-rw------- 1 budi budi 10 Oct 16 09:12 secrets/db_password.txt

Nilai 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:

yaml
secrets:
  db_password:
    file: ./secrets/db_password.txt

Dua service memakainya dengan aturan pemasangan yang berbeda — backend meminta bentuk panjang yang paling ketat, db memakai bentuk yang cukup untuk entry point postgres:

yaml
  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: 0444

Dua 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:

  1. uid: "1000" dan gid: "1000" — berkas yang terpasang di /run/secrets/db_password dimiliki oleh user node (UID 1000), satu-satunya pihak yang berhak membacanya; proses lain di dalam container yang sama pun tidak.
  2. 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.
  3. target: db_password — nama berkas di dalam container; path lengkapnya /run/secrets/db_password, konvensi yang sama dengan image resmi postgres sehingga POSTGRES_PASSWORD_FILE langsung kompatibel.
  4. source: db_password menunjuk 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:

js
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:

  1. readFileSync dengan trim() — berkas secret dibaca sekali saat startup; trim() membuang baris baru yang tidak sengaja ditulis editor, penyebab klasik galat autentikasi yang “tidak masuk akal”.
  2. Fallback ke DB_PASSWORD — pola *_FILE ala 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.
  3. 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.
  4. 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:

bash
$ docker compose up -d

Output yang Dihasilkan (dipotong):

text
[+] 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
bash
$ docker inspect toko-produksi-backend-1 --format '{{range .Config.Env}}{{println .}}{{end}}'

Output yang Dihasilkan (dipotong):

text
APP_ENV=produksi
DB_HOST=db
DB_NAME=toko
DB_USER=toko
...
NODE_ENV=production
...
REDIS_HOST=cache
WORKERS=4

Papan 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:

bash
$ docker compose exec backend wget -qO- http://localhost:3000/healthz

Output yang Dihasilkan:

text
ok

Ketatnya pemasangan bisa dilihat dari dalam container:

bash
$ docker compose exec backend ls -l /run/secrets/

Output yang Dihasilkan:

text
-r-------- 1 node node 10 Oct 16 09:14 db_password

Pemilik 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:

yaml
  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_successfully

Alurnya tergambar sebagai rantai satu arah:

Mari kita telaah empat bagian yang bekerja sama pada snippet tersebut:

  1. secret-init memakai image kecil — alpine dengan curl dan jq dipasang sekali jalan; init container adalah proses sekali pakai yang layak seramping mungkin.
  2. Volume secrets-run sebagai jembatan tangan pertama — berkas ditulis oleh init container dan dibaca oleh backend; tidak ada rahasia yang menempel pada environment, image, atau compose.yaml.
  3. condition: service_completed_successfully — gerbang depends_on bentuk 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.
  4. chown 1000:1000 sebelum chmod 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:

bash
$ printf 'rahasia456' > secrets/db_password.txt
$ chmod 600 secrets/db_password.txt
$ docker compose up -d db backend

Output yang Dihasilkan (dipotong):

text
[+] 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.7s

Selama 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:

bash
$ docker compose exec db psql -U toko -c "ALTER ROLE toko WITH PASSWORD 'rahasia456';"

Output yang Dihasilkan:

text
ALTER ROLE

Sejak 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 #