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

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:

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

vault-agent/agent.hcl hcl
# 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_auth dengan method "approle": agent login sendiri memakai role_id dan secret_id yang dibaca dari file. Token hasil login (TTL 1 jam, maksimal 4 jam, sesuai role di Bagian 2) diperpanjang otomatis. Kalau sudah mentok di token_max_ttl, agent login ulang.

  • remove_secret_id_file_after_reading = false: defaultnya agent menghapus file secret_id setelah 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: render config.json.ctmpl ke /vault/out/config.json dengan izin 0400 (hanya pemilik yang boleh baca). error_on_missing_key = true membuat 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.

vault-agent/config.json.ctmpl text
{{- /* 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. Kalau APP_ENV bisa diubah lewat .env, satu salah ketik saja sudah cukup untuk membuat stack dev mencoba membaca path production. Untungnya Vault akan menolak (policy dev hanya read di folder dev), tapi lebih baik celahnya nggak dibuka sama sekali.

  • data/ di tengah path karena mount static-secret adalah 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:

vault-agent/entrypoint.sh bash
#!/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.hcl
  • Baris : "${X:?pesan}" membuat script langsung keluar dengan pesan jelas kalau salah satu env kosong. Lebih enak dibaca daripada error login yang samar.

  • umask 077 membuat kedua file hanya bisa dibaca pemiliknya.

  • printf '%s' (bukan echo) supaya nggak ada newline tambahan di belakang nilai.

  • exec mengganti proses shell dengan vault 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

vault-agent/Dockerfile dockerfile
# 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.1 bisa 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 0400 bisa dipakai: agent (pemilik) menulis, app (UID yang sama) membaca, user lain nggak bisa apa-apa.

  • Healthcheck test -s: sehat artinya file config.json ada dan nggak kosong. Isinya divalidasi app.

Cek hasil:

bash
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.

docker-compose.development.yml yaml
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

vault_auth

role_id, secret_id

hanya vault-agent

Ini kredensial untuk login ke Vault. App nggak butuh, jadi app nggak dapat

config_vol

config.json hasil render

vault-agent (tulis), app (:ro)

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

text
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 :8080

condition: 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.

bash
docker compose -f docker-compose.development.yml config -qdocker compose -f docker-compose.production.yml config -q

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

scripts/local-vault.sh bash
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 lupa toJSON, 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/*.env adalah http://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:

docker-compose.local.yml yaml
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: true

Compose 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

app.image

ghcr.io/...:development

secure-cicd-go:local

ditimpa

app.build

nggak ada

. dengan build args

ditambah

app.pull_policy

always

build

ditimpa, nggak tarik GHCR

app.ports

hanya expose

127.0.0.1:8080:8080

ditambah

vault-agent.networks

default

[default, scg-local]

ditambah, bisa ke Vault dev

APP_ENV, tmpfs, hardening

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

bash
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 --wait

Beberapa hal kecil yang membuat sesi uji ini enak:

  • set -a membuat setiap variabel yang dibaca dari file langsung di-export, jadi kredensial lokal masuk ke environment tanpa nilainya pernah diketik atau tampil di layar.

  • COMPOSE_FILE berisi daftar file yang dipisah titik dua. Ini sama dengan menulis -f docker-compose.development.yml -f docker-compose.local.yml di setiap perintah, dengan urutan yang sama.

  • --wait menunggu 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:

bash
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"}'   # 201

POST /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

bash
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 ada

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

bash
docker compose restart vault-agentdocker compose ps      # vault-agent kembali healthy, app tetap jalan

Uji 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.

bash
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 -v

Variabel 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.

bash
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 20

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

bash
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_ID

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

bash
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/health

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

bash
docker compose -f docker-compose.development.yml -f docker-compose.local.yml down -vdocker network rm scg-localunset VAULT_ROLE_ID VAULT_SECRET_ID

Batasan 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 config.json sekali saat start, sederhana dan fail closed

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_id nggak menghentikan app yang sedang jalan

Secret sudah ada di memori app

Yang terblokir adalah start berikutnya. Untuk memutus akses segera: cabut secret_id, rotasi secret-nya, lalu redeploy

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 (permission denied, auth). Dicatat di komentar Dockerfile

App dan vault-agent memakai UID yang sama

Supaya config.json bisa 0400

Volume vault_auth nggak pernah di-mount ke app

secret_id AppRole statis di env Dokploy

Disepakati supaya sederhana

Policy hanya read folder env sendiri, token TTL pendek, secret_id bisa dicabut kapan saja

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-agent berbasis hashicorp/vault:2.2.1 yang dipatok digest, jalan sebagai UID 65532, dengan config dan template di-bake.

  • agent.hcl dengan AppRole auto_auth, exit_on_retry_failure, dan template yang menulis config.json berizin 0400 serta gagal kalau ada key hilang.

  • Template yang hanya berisi path, memakai APP_ENV yang dipatok, dan aman untuk karakter JSON khusus berkat toJSON.

  • Compose dev dan production: dua volume tmpfs, vault_auth hanya untuk agent, config_vol read-only untuk app, root filesystem read-only, cap_drop: [ALL], no-new-privileges, dan app yang baru start setelah agent sehat.

  • scripts/local-vault.sh dan docker-compose.local.yml untuk 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.

Artikel terkait