//BAGIAN 4 DARI 10
Bagian 4: Vault Agent Sidecar dan Docker Compose, Secret Masuk Tanpa App Tahu Vault Ada
· 19 menit baca
English//TUTORIAL · 4 DARI 10 — Secure CI/CD Pipeline dengan Go, GitHub Actions, Vault, dan Dokploy
- 01Bagian 1: Kenapa Saya Bikin Secure CI/CD Pipeline, dan Persiapan Tools-nya
- 02Bagian 2: Vault, Secret, Policy, dan AppRole per Environment
- 03Bagian 3: Aplikasi Go Kecil yang Membaca Secret dari File dan Menolak Jalan Kalau Config Salah
- 04Bagian 4: Vault Agent Sidecar dan Docker Compose, Secret Masuk Tanpa App Tahu Vault Ada
- 05Bagian 5: Repo GitHub dan Workflow PR sebagai Security Gate
- 06Bagian 6: Ruleset yang Memblokir Merge, dan Lima Demo PR yang Harus Gagal
- 07Bagian 7: Menyiapkan Environment Dev dan Production di Dokploy
- 08Bagian 8: Workflow Deploy, Build Sekali, Scan, SBOM, GHCR, lalu Promosi ke Production
- 09Bagian 9: Proses Exception untuk Temuan Security yang Belum Bisa Diperbaiki
- 10Bagian 10: CI Mengambil Token Dokploy dari Vault lewat GitHub OIDC, dan Penutup Seri
Sampai Bagian 3 kita punya dua hal yang belum saling kenal. Di Vault ada secret, policy, dan AppRole per environment (Bagian 2). Di repo ada aplikasi Go yang membaca secret dari satu file JSON dan langsung berhenti kalau file itu nggak beres (Bagian 3).
Di bagian ini keduanya disambungkan. Caranya bukan dengan menambahkan Vault SDK ke aplikasi, tapi dengan sidecar: satu container kecil bernama vault-agent yang login ke Vault, mengambil secret, lalu menulis config.json ke volume yang dibagi dengan app. Aplikasinya sendiri nggak tahu Vault ada. Ia cuma tahu ada file di /run/config/config.json.
Hasil akhirnya: dua file compose (dev dan production) yang nanti dipakai Dokploy di Bagian 7, satu file override untuk laptop, dan satu script yang menyalakan Vault dev server lokal. Yang terakhir ini penting: semua uji di bagian ini jalan di laptop, jadi Vault asli baru disentuh di langkah paling akhir.
Kenapa sidecar, bukan Vault SDK di aplikasi
Saya sempat menimbang dua pilihan:
Pilihan | Untung | Rugi |
|---|---|---|
App memanggil Vault sendiri (SDK) | Bisa reload secret tanpa restart | App harus urus login, renew token, retry. Kode security bercampur dengan kode bisnis. Setiap app lain harus mengulang hal yang sama |
Vault Agent sidecar | App cukup baca file. Login, renew, dan render diurus agent yang sudah teruji | Rotasi secret baru terpakai setelah app restart |
Untuk proyek ini saya pilih sidecar. Aplikasinya tetap kecil dan gampang dites (cukup kasih file JSON dummy), dan semua urusan Vault terkumpul di satu tempat yang bisa di-review. Kekurangannya saya catat jujur sebagai accepted risk di akhir artikel.
Alurnya seperti ini:
Dokploy .env: VAULT_ADDR, VAULT_ROLE_ID, VAULT_SECRET_ID │ ▼┌──────────────────── vault-agent (UID 65532) ────────────────────┐│ entrypoint.sh: tulis role-id & secret-id → /vault/auth (tmpfs) ││ vault agent: login AppRole → token → baca 2 secret ││ template: render → /vault/out/config.json (tmpfs, 0400) │└─────────────────────────────────────────────────────────────────┘ │ volume config_vol (dibagi) volume vault_auth (TIDAK dibagi) ▼┌──────────────────── app (UID 65532, distroless) ────────────────┐│ /run/config/config.json (mount read-only) ││ root filesystem read-only, cap_drop ALL │└─────────────────────────────────────────────────────────────────┘Isi folder vault-agent/ di repo ada empat file: agent.hcl, config.json.ctmpl, entrypoint.sh, dan Dockerfile. Kita bahas satu per satu.
agent.hcl: AppRole auto_auth dan template
Ini konfigurasi inti Vault Agent:
# No vault { address } stanza: HCL does not expand env vars, so the agent reads VAULT_ADDR itself. auto_auth { method "approle" { mount_path = "auth/approle" config = { role_id_file_path = "/vault/auth/.role-id" secret_id_file_path = "/vault/auth/.secret-id" remove_secret_id_file_after_reading = false } }} # Give up instead of serving a stale or partial config when Vault keeps refusing.template_config { exit_on_retry_failure = true} template { source = "/vault/config/config.json.ctmpl" destination = "/vault/out/config.json" perms = "0400" error_on_missing_key = true}Penjelasan per blok:
auto_authdenganmethod "approle": agent login sendiri memakairole_iddansecret_idyang dibaca dari file. Token hasil login (TTL 1 jam, maksimal 4 jam, sesuai role di Bagian 2) diperpanjang otomatis. Kalau sudah mentok ditoken_max_ttl, agent login ulang.remove_secret_id_file_after_reading = false: defaultnya agent menghapus filesecret_idsetelah dibaca. Di sini saya matikan, karena agent butuh file itu lagi waktu login ulang setelah 4 jam. Kalau file-nya sudah hilang, agent nggak bisa login dan render berhenti.template_config { exit_on_retry_failure = true }: kalau Vault terus menolak (misalnya 403), agent menyerah dan keluar, bukan diam di tempat sambil berpura-pura sehat.template: renderconfig.json.ctmplke/vault/out/config.jsondengan izin0400(hanya pemilik yang boleh baca).error_on_missing_key = truemembuat key yang hilang jadi error, bukan string kosong yang diam-diam lolos.
Perhatian
Jangan tulis `vault { address = "${VAULT_ADDR}" }`
HCL nggak meng-expand variabel environment. Kalau ditulis begitu, agent mencoba menghubungi alamat yang secara harfiah berbunyi ${VAULT_ADDR}. Cukup hapus stanza vault, karena Vault Agent membaca env VAULT_ADDR sendiri. Ini pelajaran dari proyek saya sebelumnya, makanya ada komentar di baris pertama.
Satu hal yang awalnya ada di rencana tapi akhirnya nggak dipakai: disable_mlock = true. Saya kira agent non-root butuh itu supaya nggak perlu IPC_LOCK. Waktu dites, agent jalan normal tanpanya, jadi saya buang. Konfigurasi yang nggak terbukti perlu sebaiknya nggak ikut masuk.
config.json.ctmpl: hanya path, nggak pernah nilai
Template ini file yang di-commit ke repo publik, jadi isinya hanya path secret. Nilainya ada di Vault.
{{- /* Paths only, never values. APP_ENV is pinned in the compose file of each environment. */ -}}{{- $env := env "APP_ENV" -}}{{- $jwt := "" -}}{{- $aes := "" -}}{{- with secret (printf "static-secret/data/secure-cicd-go/%s/secrets/jwt" $env) }}{{ $jwt = .Data.data.secret_key }}{{ end -}}{{- with secret (printf "static-secret/data/secure-cicd-go/%s/secrets/aes" $env) }}{{ $aes = .Data.data.secret_key }}{{ end -}}{"env": {{ $env | toJSON }}, "jwtSecret": {{ $jwt | toJSON }}, "aesKey": {{ $aes | toJSON }}}Beberapa keputusan di sini:
Path dibangun dari
APP_ENV. Variabel ini dipatok di file compose, bukan dibaca dari.env. KalauAPP_ENVbisa diubah lewat.env, satu salah ketik saja sudah cukup untuk membuat stack dev mencoba membaca path production. Untungnya Vault akan menolak (policy dev hanyareaddi folder dev), tapi lebih baik celahnya nggak dibuka sama sekali.data/di tengah path karena mountstatic-secretadalah KV versi 2. Isi secret di KV v2 ada di bawah<mount>/data/..., dan field-nya ada di.Data.data.Semua nilai lewat
toJSON. Ini yang membuat output tetap JSON valid walaupun secret berisi"atau\. Nanti di uji lokal saya sengaja memasang secret dengan karakter itu.Satu baris JSON di akhir, bentuknya selalu lengkap. Draf awal saya menaruh potongan JSON (lengkap dengan koma) di dalam blok
with. Masalahnya, kalau satu blok nggak jalan, komanya ikut hilang dan JSON-nya rusak dengan cara yang membingungkan. Versi akhir mengisi variabel dulu, lalu mencetak JSON utuh sekali.
Bentuk JSON-nya sama persis dengan yang diharapkan config.Load di Bagian 3: env, jwtSecret, aesKey. App memakai DisallowUnknownFields, menolak field kosong, dan mengecek AES key harus 64 karakter hex. Jadi validasi isi tetap di app (fail closed), agent cukup memastikan file-nya ada.
entrypoint.sh: dari env ke file, gagal cepat
Dokploy memberikan kredensial lewat env, sedangkan AppRole auto_auth membaca dari file. Jembatannya script kecil ini:
#!/bin/sh# Writes the AppRole credentials from the environment to the tmpfs auth volume, then runs the agent.set -eu : "${VAULT_ADDR:?VAULT_ADDR is not set}": "${APP_ENV:?APP_ENV is not set}": "${VAULT_ROLE_ID:?VAULT_ROLE_ID is not set}": "${VAULT_SECRET_ID:?VAULT_SECRET_ID is not set}" umask 077printf '%s' "$VAULT_ROLE_ID" > /vault/auth/.role-idprintf '%s' "$VAULT_SECRET_ID" > /vault/auth/.secret-id exec vault agent -config=/vault/config/agent.hclBaris
: "${X:?pesan}"membuat script langsung keluar dengan pesan jelas kalau salah satu env kosong. Lebih enak dibaca daripada error login yang samar.umask 077membuat kedua file hanya bisa dibaca pemiliknya.printf '%s'(bukanecho) supaya nggak ada newline tambahan di belakang nilai.execmengganti proses shell denganvault agent, jadi sinyal stop dari Docker sampai langsung ke agent.
File ini nggak ada di rencana awal. Saya tambahkan waktu implementasi karena menulis kredensial di command: compose jadi panjang dan susah di-review.
Dockerfile vault-agent: dipatok, dibake, non-root
# Config and template are baked in: a template change changes the image, so compose recreates# the container instead of serving a stale render.FROM hashicorp/vault:2.2.1@sha256:962a92876e168d68eaaa24b0b8b49bbce1cb63024060a67bba95cd158a366fc2COPY --chmod=0444 agent.hcl config.json.ctmpl /vault/config/COPY --chmod=0555 entrypoint.sh /usr/local/bin/agent-entrypoint.sh# Same UID as the app, so the 0400 config.json is readable by the app and nobody else.USER 65532:65532# Healthy means "a config has been rendered", not "Vault is reachable now": after an agent restart the# previous render stays on the tmpfs volume while the app holds it, so a revoked secret_id still reads healthy.HEALTHCHECK --interval=3s --timeout=3s --start-period=5s --retries=30 CMD ["test", "-s", "/vault/out/config.json"]ENTRYPOINT ["/usr/local/bin/agent-entrypoint.sh"]Kenapa begini:
Base dipatok dengan digest. Tag
2.2.1bisa saja ditimpa, digest nggak. Rebuild di server selalu memakai image yang sama.Config dan template di-bake ke image, bukan di-mount dari host. Kalau template berubah, image berubah, dan Compose membuat ulang container-nya. Kalau di-mount, container lama bisa terus jalan dengan render basi dan nggak ada yang sadar.
--chmod=0444/0555: file config hanya bisa dibaca, entrypoint hanya bisa dibaca dan dijalankan.USER 65532:65532: agent nggak jalan sebagai root. Tanpa baris ini Trivy config memberi temuan DS002 (HIGH), dan itu akan memblokir PR pertama di Bagian 5.UID sama dengan app (65532). Inilah yang membuat izin
0400bisa dipakai: agent (pemilik) menulis, app (UID yang sama) membaca, user lain nggak bisa apa-apa.Healthcheck
test -s: sehat artinya fileconfig.jsonada dan nggak kosong. Isinya divalidasi app.
Cek hasil:
trivy config --severity HIGH,CRITICAL --exit-code 1 vault-agent/Harus selesai tanpa temuan.
File compose: dua container, dua volume tmpfs
Ini docker-compose.development.yml lengkap. File production identik, bedanya hanya APP_ENV: production dan tag image :production.
services: vault-agent: build: ./vault-agent restart: unless-stopped environment: APP_ENV: development VAULT_ADDR: ${VAULT_ADDR:?set VAULT_ADDR} VAULT_ROLE_ID: ${VAULT_ROLE_ID:?set VAULT_ROLE_ID} VAULT_SECRET_ID: ${VAULT_SECRET_ID:?set VAULT_SECRET_ID} volumes: - vault_auth:/vault/auth - config_vol:/vault/out read_only: true cap_drop: [ALL] security_opt: [no-new-privileges:true] app: image: ghcr.io/dimss113/secure-cicd-go:development pull_policy: always restart: unless-stopped depends_on: vault-agent: condition: service_healthy # Only the rendered config; the AppRole volume is never mounted here. volumes: - config_vol:/run/config:ro expose: - "8080" read_only: true cap_drop: [ALL] security_opt: [no-new-privileges:true] networks: - dokploy-network # tmpfs: secrets live in RAM only and vanish when the stack stops.volumes: vault_auth: driver_opts: {type: tmpfs, device: tmpfs, o: "uid=65532,gid=65532,mode=0700"} config_vol: driver_opts: {type: tmpfs, device: tmpfs, o: "uid=65532,gid=65532,mode=0700"} networks: # Created by Dokploy; Traefik routes the domain to the app over it. dokploy-network: external: true(Komentar pembuka file saya potong. Isinya: Dokploy menulis tiga variabel VAULT_* ke .env, dan APP_ENV sengaja dipatok di sini.)
Dua volume, dua tingkat rahasia
Volume | Isi | Di-mount ke | Kenapa |
|---|---|---|---|
|
| hanya | Ini kredensial untuk login ke Vault. App nggak butuh, jadi app nggak dapat |
|
|
| App hanya butuh hasil akhirnya, dan cuma boleh membaca |
Keduanya tmpfs: isinya di RAM, nggak pernah ditulis ke disk VPS, dan hilang saat stack berhenti. Opsi uid=65532,gid=65532,mode=0700 membuat root folder volume dimiliki UID 65532. Tanpa itu, volume dibuat milik root dan agent non-root nggak bisa menulis apa pun ke sana.
Kenapa pemisahan ini penting? Kalau suatu saat app punya celah (misalnya path traversal), yang bisa bocor paling banter config.json environment itu sendiri. secret_id, yang bisa dipakai untuk login ulang ke Vault dari luar, nggak pernah ada di filesystem app.
Hardening yang sama di kedua container
read_only: true: root filesystem container nggak bisa ditulis. Satu-satunya tempat yang bisa ditulis agent adalah dua volume tmpfs tadi. App bahkan nggak punya tempat menulis sama sekali.cap_drop: [ALL]: semua Linux capability dibuang. Nggak ada yang butuh.no-new-privileges:true: proses nggak bisa naik hak akses lewat binary setuid.
Catatan
Hardening compose belum dicek otomatis
Trivy config memindai Dockerfile, tapi nggak memindai file Compose. Jadi baris-baris hardening di atas sekarang dijaga lewat review PR. Otomatisasinya (policy-as-code dengan Conftest/OPA) saya tunda ke proyek berikutnya, dan tercatat sebagai known limitation.
depends_on dan healthcheck: urutan start
vault-agent start → entrypoint tulis kredensial → agent login, render config.json → healthcheck test -s lolos (cek tiap 3 detik, sampai 30x) → status: healthyapp baru dibuat dan start → config.Load membaca /run/config/config.json → valid → listen :8080condition: service_healthy artinya app nggak akan start sebelum agent sehat. Kalau render gagal (403, key hilang), agent nggak pernah sehat, dan app nggak pernah jalan. Ini yang saya mau: lebih baik service mati daripada jalan dengan config kosong.
App sendiri punya healthcheck di Dockerfile-nya (Bagian 3): /app healthcheck, karena image distroless nggak punya shell atau curl.
Kenapa ada dokploy-network
App bergabung ke network dokploy-network yang dibuat Dokploy, karena lewat network itulah Traefik meneruskan request dari domain ke port 8080 app. Vault-agent nggak ikut, ia cukup di network default stack untuk keluar ke Vault. Port nggak di-publish ke host, cukup expose.
Cek hasil: sintaks kedua file valid.
docker compose -f docker-compose.development.yml config -qdocker compose -f docker-compose.production.yml config -qKeduanya harus exit 0. Kalau mengeluh set VAULT_ADDR, itu wajar: export dummy dulu, atau jalankan setelah langkah berikutnya.
Uji lokal dengan Vault dev server
Sebelum menyentuh Vault asli, saya mau membuktikan semuanya di laptop: jalur bahagia, isolasi, dan kegagalan. Untuk itu ada scripts/local-vault.sh yang menyalakan Vault dev server di container dan mengisinya dengan struktur yang sama persis seperti Vault asli. Ini versi yang saya potong sedikit:
NAME=scg-vault-devNET=scg-localIMAGE=hashicorp/vault:2.2.1@sha256:962a92876e168d68eaaa24b0b8b49bbce1cb63024060a67bba95cd158a366fc2 up() { mkdir -p .local && chmod 700 .local openssl rand -hex 16 > .local/root-token docker network inspect "$NET" >/dev/null 2>&1 || docker network create "$NET" >/dev/null docker run -d --name "$NAME" --network "$NET" -p 127.0.0.1:8200:8200 --cap-add IPC_LOCK \ -e VAULT_DEV_ROOT_TOKEN_ID="$(cat .local/root-token)" "$IMAGE" \ server -dev -dev-listen-address=0.0.0.0:8200 >/dev/null until vexec status >/dev/null 2>&1; do sleep 1; done vexec secrets enable -path=static-secret kv-v2 >/dev/null vexec auth enable approle >/dev/null for env in development production; do # The development JWT secret holds a quote and a backslash on purpose: the template must still emit valid JSON. jwt="$(openssl rand -hex 24)" [ "$env" = development ] && jwt="q\"b\\s-$jwt" vexec kv put -mount=static-secret "secure-cicd-go/$env/secrets/jwt" secret_key="$jwt" >/dev/null vexec kv put -mount=static-secret "secure-cicd-go/$env/secrets/aes" secret_key="$(openssl rand -hex 32)" >/dev/null vexec policy write "secure-cicd-go-$env" - < "vault/policies/secure-cicd-go-$env.hcl" >/dev/null vexec write "auth/approle/role/secure-cicd-go-$env" token_policies="secure-cicd-go-$env" \ token_ttl=1h token_max_ttl=4h secret_id_ttl=0 secret_id_num_uses=0 >/dev/null # ... tulis VAULT_ADDR, VAULT_ROLE_ID, VAULT_SECRET_ID ke .local/$env.env (chmod 600) done}Yang penting dari script ini:
Image Vault-nya sama dengan base vault-agent, dipatok digest yang sama.
Policy diambil dari
vault/policies/di repo, jadi yang dites adalah policy yang sama dengan yang dipasang di Vault asli. Kalau policy-nya salah, uji lokal ikut gagal.**Secret JWT dev sengaja berisi
"dan\**. Kalau template lupatoJSON, app akan menolak config-nya dan ketahuan di sini, bukan di production.Semua nilai acak dan dibuang. Root token dibuat acak per sesi, dev server menyimpan semuanya di memori, dan kredensial AppRole ditulis ke
.local/yang ada di.gitignore.Vault dev bergabung ke network
scg-local, dan alamat yang ditulis ke.local/*.envadalahhttp://scg-vault-dev:8200, nama container yang bisa di-resolve dari network itu.
File override lokal
Compose untuk server menarik image dari GHCR dan nggak membuka port. Di laptop saya butuh kebalikannya, jadi ada docker-compose.local.yml:
services: vault-agent: networks: [default, scg-local] app: build: context: . args: COMMIT: ${COMMIT:-dev} BUILD_TIME: ${BUILD_TIME:-unknown} image: secure-cicd-go:local pull_policy: build ports: - "127.0.0.1:8080:8080" networks: scg-local: external: trueCompose menggabungkan file -f dari kiri ke kanan. File di kanan menimpa atau menambah, sisanya tetap dari file kiri:
Bagian | development.yml (server) | local.yml (laptop) | Hasil gabungan |
|---|---|---|---|
|
|
| ditimpa |
| nggak ada |
| ditambah |
|
|
| ditimpa, nggak tarik GHCR |
| hanya |
| ditambah |
| default |
| ditambah, bisa ke Vault dev |
| ada | nggak disebut | tetap dari file server |
Kenapa nggak edit file development langsung? Karena file itu yang dipakai Dokploy. Override memisahkan setelan server dan setelan laptop, tanpa risiko port terbuka atau build lokal ikut terbawa ke production. Port juga sengaja di-bind ke 127.0.0.1, bukan ke semua interface.
Jalur bahagia
scripts/local-vault.sh updocker network create dokploy-network 2>/dev/null # sekali saja; di server dibuat Dokploy set -a; . .local/development.env; set +a # export VAULT_ADDR, VAULT_ROLE_ID, VAULT_SECRET_IDexport COMPOSE_FILE=docker-compose.development.yml:docker-compose.local.ymlexport COMMIT=$(git rev-parse HEAD)export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)docker compose up -d --build --waitBeberapa hal kecil yang membuat sesi uji ini enak:
set -amembuat setiap variabel yang dibaca dari file langsung di-export, jadi kredensial lokal masuk ke environment tanpa nilainya pernah diketik atau tampil di layar.COMPOSE_FILEberisi daftar file yang dipisah titik dua. Ini sama dengan menulis-f docker-compose.development.yml -f docker-compose.local.ymldi setiap perintah, dengan urutan yang sama.--waitmenunggu sampai semua container sehat, dan gagal kalau nggak.
Kenapa kredensial perlu di-export untuk perintah seperti ps atau logs juga? Karena file compose memakai ${VAULT_ADDR:?set VAULT_ADDR}. Compose meng-interpolasi file itu di setiap perintah, dan langsung menolak kalau variabelnya kosong.
Cek hasil:
docker compose ps # dua-duanya healthycurl -s localhost:8080/health # {"status":"ok"}curl -s localhost:8080/version # commit hash dan waktu buildcurl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/v1/tokens \ -H 'Content-Type: application/json' -d '{"subject":"demo"}' # 201POST /v1/tokens yang menjawab 201 membuktikan JWT secret (yang berisi " dan \) sampai ke app sebagai JSON valid dan lolos validasi panjang.
Isolasi: izin file, mount, dan filesystem
docker compose exec vault-agent ls -l /vault/out/# -r-------- 1 65532 65532 ... config.json docker compose exec app /app healthcheck && echo ok docker inspect "$(docker compose ps -q app)" \ --format '{{range .Mounts}}{{.Destination}} rw={{.RW}}{{"\n"}}{{end}}'# /run/config rw=false <- hanya ini; /vault/auth tidak adaImage app distroless, jadi nggak ada ls atau cat di dalamnya. Makanya izin file dicek dari container agent, dan mount app dicek lewat docker inspect dari luar. Hasilnya harus: file 0400 milik 65532, app punya satu mount saja dan itu read-only. Root filesystem-nya read-only karena read_only: true, dan app memang nggak punya alasan menulis apa pun.
Lalu restart agent:
docker compose restart vault-agentdocker compose ps # vault-agent kembali healthy, app tetap jalanUji gagal: ini yang paling penting
Jalur bahagia saja belum membuktikan apa-apa. Yang perlu dibuktikan adalah sistemnya menolak dengan benar.
1. Kredensial dev dipakai di stack production.
docker compose down -vexport COMPOSE_FILE=docker-compose.production.yml:docker-compose.local.ymldocker compose up -d --build --wait# gagal: vault-agent tidak pernah healthy docker compose logs vault-agent | grep -i "permission denied"docker compose down -vVariabel yang di-export masih kredensial dev, tapi APP_ENV di file production adalah production, jadi template meminta path production. Token dari role dev hanya membawa policy dev, Vault menjawab 403 permission denied, render gagal, agent keluar, dan app nggak pernah running. Persis yang kita mau.
2. Field secret salah nama.
export COMPOSE_FILE=docker-compose.development.yml:docker-compose.local.ymlscripts/local-vault.sh kv put -mount=static-secret \ secure-cicd-go/development/secrets/aes secret_keyx="$(openssl rand -hex 32)"docker compose up -d --build --wait# gagal: render error karena secret_key tidak adadocker compose logs vault-agent | tail -n 20Berkat error_on_missing_key = true, key yang hilang membuat render error dan app nggak start. Tanpa opsi itu template akan mencetak string kosong, dan kita bergantung sepenuhnya pada validasi app. Dua lapis lebih baik daripada satu.
Bersih-bersih:
docker compose down -vscripts/local-vault.sh down # hapus container, network scg-local, dan .local/unset COMPOSE_FILE VAULT_ADDR VAULT_ROLE_ID VAULT_SECRET_IDMenguji dengan Vault asli
Setelah semua lolos di lokal, baru saya coba memakai Vault asli dan AppRole development dari Bagian 2. Bedanya hanya dari mana kredensial datang:
export VAULT_ADDR=https://vault.example.comread -r VAULT_ROLE_ID # tempel role_id DEVELOPMENT, Enterread -rs VAULT_SECRET_ID # tempel secret_id DEVELOPMENT (tidak tampil), Enterexport VAULT_ROLE_ID VAULT_SECRET_IDprintenv VAULT_ROLE_ID >/dev/null && echo "sudah di-export" docker network create scg-local 2>/dev/null # diminta file override lokalexport COMMIT=$(git rev-parse HEAD) BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)docker compose -f docker-compose.development.yml -f docker-compose.local.yml up -d --build --waitcurl -s localhost:8080/healthread -rs membuat secret_id nggak tampil di layar dan nggak masuk history shell.
Di langkah ini saya kena tiga masalah. Semuanya bukan bug di kode, tapi tetap makan waktu.
Perhatian
`required variable VAULT_ROLE_ID is missing a value`, padahal `echo $VAULT_ROLE_ID` keluar
read -r VAULT_ROLE_ID hanya membuat variabel shell, yang dikenal shell itu sendiri. echo dijalankan oleh shell yang sama, jadi nilainya terlihat. docker compose adalah proses lain, dan proses anak hanya menerima variabel yang sudah di-export. Solusinya baris export VAULT_ROLE_ID VAULT_SECRET_ID. Untuk mengecek tanpa menampilkan nilai, pakai printenv X >/dev/null, karena printenv melihat dari sudut pandang program lain.
Perhatian
`Image ghcr.io/dimss113/secure-cicd-go:development ... denied`
Ini terjadi kalau lupa file docker-compose.local.yml, atau urutan -f-nya terbalik. Compose lalu mencoba menarik image dari GHCR, padahal image itu baru ada setelah Bagian 8. GHCR menjawab denied, bukan "not found", untuk package yang belum ada maupun yang privat, supaya orang luar nggak bisa menebak nama package. Pesan "denied" ini sempat membuat saya mengira masalahnya di login.
Masalah ketiga lebih halus. /version menjawab {"buildTime":"unknown","commit":"dev"}. Ternyata itu nilai default ARG di Dockerfile app, yang hanya berubah kalau ada yang mengirim build arg. Waktu itu docker-compose.local.yml hanya berisi build: ., jadi nggak ada yang dikirim. Perbaikannya adalah blok args yang sudah kamu lihat di atas, plus export COMMIT dan BUILD_TIME sebelum up --build. Image harus di-build ulang, karena image lama nggak berubah isinya. Di CI (Bagian 8) build arg ini dikirim oleh workflow, dan itulah dasar smoke test /version.
Bersih-bersih:
docker compose -f docker-compose.development.yml -f docker-compose.local.yml down -vdocker network rm scg-localunset VAULT_ROLE_ID VAULT_SECRET_IDBatasan yang saya terima dengan sadar
Desain ini sengaja sederhana, dan ada harganya. Saya tulis di sini supaya nggak ada yang kaget belakangan:
Batasan | Kenapa diterima | Mitigasi |
|---|---|---|
Rotasi secret nggak otomatis sampai ke app | App membaca | Vault Agent tetap me-render ulang file-nya. App memakai nilai baru setelah restart. Di Dokploy itu Stop lalu Start, karena Deploy tanpa perubahan nggak me-restart container |
Mencabut | Secret sudah ada di memori app | Yang terblokir adalah start berikutnya. Untuk memutus akses segera: cabut |
Healthy di vault-agent berarti "config pernah ter-render", bukan "Vault bisa dihubungi sekarang" | Volume tmpfs tetap ter-mount selama app memegangnya, jadi render lama tetap ada setelah agent restart | Pantau log agent ( |
App dan vault-agent memakai UID yang sama | Supaya | Volume |
| Disepakati supaya sederhana | Policy hanya |
Hardening Compose nggak dicek otomatis | Trivy nggak memindai Compose | Review PR, policy-as-code di proyek berikutnya |
Poin healthcheck ini yang paling sering disalahpahami, termasuk oleh saya sendiri. Waktu restart agent di uji isolasi tadi, agent langsung kelihatan healthy lagi. Itu karena config.json lama masih ada di tmpfs yang dipegang app, bukan karena agent sudah sukses login ulang. Jadi kalau secret_id sudah dicabut, agent yang baru restart bisa tetap terbaca healthy selama render lama masih ada. Makanya log agent, bukan status healthy, yang jadi sumber kebenaran.
Rangkuman
Yang sudah ada sekarang:
Image
vault-agentberbasishashicorp/vault:2.2.1yang dipatok digest, jalan sebagai UID 65532, dengan config dan template di-bake.agent.hcldengan AppRole auto_auth,exit_on_retry_failure, dan template yang menulisconfig.jsonberizin0400serta gagal kalau ada key hilang.Template yang hanya berisi path, memakai
APP_ENVyang dipatok, dan aman untuk karakter JSON khusus berkattoJSON.Compose dev dan production: dua volume tmpfs,
vault_authhanya untuk agent,config_volread-only untuk app, root filesystem read-only,cap_drop: [ALL],no-new-privileges, dan app yang baru start setelah agent sehat.scripts/local-vault.shdandocker-compose.local.ymluntuk menguji jalur bahagia, isolasi, kredensial salah environment (403), dan key hilang, semuanya tanpa menyentuh Vault asli.Daftar batasan yang tercatat: rotasi butuh restart, arti healthcheck, dan UID yang sama.
Stack-nya sudah jalan di laptop, tapi kodenya belum punya penjaga. Di Bagian 5 kita buat repo GitHub-nya dan workflow PR yang menjalankan test, Gitleaks, Semgrep, govulncheck, dan Trivy di setiap pull request, termasuk Trivy config yang tadi kita jalankan manual untuk Dockerfile vault-agent.