Bagian 10: CI Mengambil Token Dokploy dari Vault lewat GitHub OIDC, dan Penutup Seri

· 21 menit baca

English
//TUTORIAL · 10 DARI 10 — Secure CI/CD Pipeline dengan Go, GitHub Actions, Vault, dan Dokploy

Sampai Bagian 9, pipeline-nya sudah lengkap. PR dicek, merge ke development deploy ke dev, merge ke main dipromosikan ke production setelah approval, dan exception punya tanggal kedaluwarsa. Tapi ada satu hal yang masih mengganjal sejak Bagian 8: API key Dokploy tersimpan sebagai GitHub Environment Secret DOKPLOY_API_KEY.

Itu yang saya sebut Tahap A di Bagian 1. Sengaja dibuat dulu supaya deploy jalan. Di bagian terakhir ini saya ganti dengan Tahap B: job deploy minta identitas sementara ke GitHub (OIDC token), menukarnya ke Vault, lalu membaca key Dokploy untuk environment-nya sendiri. Setelah itu DOKPLOY_API_KEY dihapus dari GitHub. Nggak ada lagi kredensial Dokploy yang tersimpan di CI.

Di akhir artikel, saya tutup seri ini: apa saja yang sudah dibangun dari Bagian 1 sampai 10, risiko apa yang sengaja diterima, dan apa yang bisa dikerjakan berikutnya.

Kenapa key statis di GitHub itu masalah

Secret environment di GitHub sebenarnya sudah lumayan aman. Hanya job yang masuk environment production yang bisa membacanya, dan environment itu cuma boleh dipakai main setelah approval. Lalu apa masalahnya?

Masalahnya ada di sifat key-nya: permanen. Key Dokploy berlaku 90 hari. Kalau bocor sekali saja, dari log, cache, artifact, atau dari akun GitHub yang dicuri, key itu bisa dipakai siapa pun sampai kedaluwarsa. Dan nggak ada catatan siapa mengambilnya kapan.

Skenario

Tahap A (secret GitHub)

Tahap B (Vault lewat OIDC)

Key bocor dari log, cache, atau artifact

Bisa dipakai sampai 90 hari

Key tetap di-mask. Token Vault mati dalam 5 menit

Workflow baru di branch fitur mencoba membaca key

Aman kalau proteksi environment benar

Ditolak Vault: sub dan job_workflow_ref nggak cocok

Job dev mencoba key prod

Beda secret, tapi dua-duanya tersimpan permanen

Ditolak: role dev hanya punya policy dev

Admin GitHub atau token GitHub dicuri

Bisa membaca dan mengganti secret

Nggak ada secret yang bisa dibaca. Akses diatur di Vault

Audit "siapa ambil key kapan"

Nggak ada

Tercatat di Vault (entity login, audit log)

Catatan

Yang nggak berubah

Key Dokploy-nya sendiri tetap key biasa, karena Dokploy belum punya token per-service atau token berumur pendek. Yang dikecilkan di bagian ini adalah tempat key disimpan dan siapa yang bisa mengambilnya, bukan kekuatan key-nya.

Cara kerjanya: OIDC token GitHub ditukar ke Vault

Setiap job GitHub Actions yang punya izin id-token: write bisa meminta JWT ke GitHub. JWT ini:

  • ditandatangani GitHub dengan kunci privatnya. Vault mengecek tanda tangannya pakai kunci publik GitHub, jadi nggak bisa dipalsukan.

  • berumur pendek, sekitar 5 menit.

  • berisi klaim: fakta tentang job yang meminta (repo, branch, environment, file workflow). Klaim ini ditulis GitHub sendiri, bukan oleh isi workflow. Jadi workflow nggak bisa mengaku-ngaku.

Analogi yang saya pakai: ini KTP sementara yang dicetak GitHub untuk satu job. Vault itu satpam yang mengecek KTP itu, lalu memberi kartu tamu 5 menit untuk satu pintu.

Yang penting: nggak ada rahasia bersama antara GitHub dan Vault. Kepercayaannya murni dari tanda tangan digital. Alurnya:

text
Runner GitHub                    GitHub OIDC              Vault (vault.example.com)─────────────                    ───────────              ─────────────────────────[0] job punya id-token: write[1] minta JWT, audience=https://vault.example.com ──▶ buat JWT bertanda tangan    ◀── JWT (umur ±5 menit)[2] POST /v1/auth/jwt-github/login {role, jwt} ──────────────────▶ [3] cek tanda tangan (kunci publik GitHub)                                                                      cek iss, aud, sub,                                                                      repository_id, job_workflow_ref    ◀────────────────────────────────────────────── [4] token Vault, TTL 5 menit, 1 policy[5] GET /v1/static-secret/data/secure-cicd-go/ci/<env>/dokploy ──▶ [6] policy: boleh read path ini?    ◀── api_key (di-mask jadi *** di log)[7] step deploy memakai key → Dokploy[8] job selesai, VM runner dibuang, token Vault mati sendiri

Syarat jaringannya cuma dua, dan dua-duanya koneksi keluar biasa lewat HTTPS 443: runner ke Vault, dan Vault ke token.actions.githubusercontent.com untuk mengambil kunci publik GitHub. Artinya Vault kamu harus bisa dijangkau dari internet. Kalau Vault cuma bisa dibuka lewat VPN, runner GitHub-hosted nggak akan bisa login.

bash
curl -sS https://vault.example.com/v1/sys/health | jq '{initialized, sealed, version}'

Endpoint ini publik dan nggak butuh token. Harus keluar "initialized": true, "sealed": false.

Klaim yang dipakai

Klaim

Contoh nilai

Dipakai untuk

iss

https://token.actions.githubusercontent.com

Memastikan penerbitnya GitHub

aud

https://vault.example.com

Token memang dibuat untuk Vault ini. Token untuk layanan lain (misalnya AWS) nggak bisa dipakai ulang

sub

repo:dimss113@<OWNER_ID>/secure-cicd-go@<REPO_ID>:environment:development

Siapa yang login: repo dan environment

repository_id

<REPO_ID>

ID angka repo. Tetap sama walau repo di-rename, beda kalau repo dihapus lalu dibuat ulang

job_workflow_ref

dimss113/secure-cicd-go/.github/workflows/deploy.yml@refs/heads/development

File workflow dan branch. Workflow lain di repo yang sama tetap ditolak

Kenapa sub berisi environment:production itu kuat? Di Bagian 8, environment production sudah diatur: hanya branch main yang boleh memakainya, dan butuh approval manusia. GitHub hanya menulis environment:production di sub kalau job benar-benar masuk environment itu. Jadi token dengan sub itu hanya bisa didapat dari main, setelah di-approve. Vault mendapat jaminan itu tanpa perlu tahu apa-apa soal approval.

Langkah 1: policy dan role Vault disimpan di repo

Sama seperti policy aplikasi di Bagian 2, policy dan role untuk CI saya simpan di repo (vault/policies/, vault/roles/). Dengan begitu, mengubah akses CI harus lewat PR, ikut di-review, dan tercatat di Git.

Policy-nya sesempit mungkin. Satu path persis, tanpa *, hanya read:

vault/policies/secure-cicd-go-ci-development.hcl hcl
# CI deploy job (development): read the Dokploy API key, nothing else.path "static-secret/data/secure-cicd-go/ci/development/dokploy" {  capabilities = ["read"]}

File production sama persis, cuma development diganti production. Perhatikan folder ci/: sengaja di luar secure-cicd-go/<env>/*. Akibatnya policy aplikasi (yang dipakai Vault Agent) nggak bisa membaca key CI, dan policy CI nggak bisa membaca secret JWT/AES aplikasi. Dua dunia yang terpisah.

Lalu role-nya. Ini file dari repo, dengan ID owner dan repo saya ganti placeholder:

vault/roles/secure-cicd-go-ci-production.json json
{  "role_type": "jwt",  "user_claim": "sub",  "bound_audiences": ["https://vault.example.com"],  "bound_subject": "repo:dimss113@<OWNER_ID>/secure-cicd-go@<REPO_ID>:environment:production",  "bound_claims_type": "string",  "bound_claims": {    "repository_id": "<REPO_ID>",    "job_workflow_ref": "dimss113/secure-cicd-go/.github/workflows/deploy.yml@refs/heads/main"  },  "token_policies": ["secure-cicd-go-ci-production"],  "token_ttl": "5m",  "token_max_ttl": "10m"}

Field

Artinya

role_type: jwt

Login pakai JWT yang dikirim langsung, bukan alur OIDC lewat browser

user_claim: sub

Klaim yang jadi "nama" login di Vault. Muncul di log dan di entity Vault

bound_audiences

aud token wajib sama. Workflow mengirim nilai ini lewat jwtGithubAudience

bound_subject

sub wajib sama persis: repo ini, environment ini

bound_claims_type: string

Pencocokan persis, bukan pola *

bound_claims.repository_id

ID repo (gh api repos/<owner>/<repo> --jq .id). ID repo publik bukan rahasia

bound_claims.job_workflow_ref

Hanya deploy.yml, dan hanya dari branch yang benar (main untuk prod, development untuk dev)

token_policies

Token hasil login hanya membawa policy ini (plus default)

token_ttl / token_max_ttl

Mati sendiri dalam 5 menit, nggak bisa diperpanjang lewat 10 menit. Deploy cuma butuh beberapa detik

Saya nggak menulis awalan sub dengan tangan. Saya ambil langsung dari API GitHub, karena di situlah jebakan terbesar fase ini (ceritanya di bawah):

bash
SUB_PREFIX=$(gh api repos/dimss113/secure-cicd-go/actions/oidc/customization/sub --jq .sub_claim_prefix)echo "$SUB_PREFIX"   # repo:dimss113@<OWNER_ID>/secure-cicd-go@<REPO_ID>

Dari situ dua file role dan dua file policy dibuat lewat loop for ENV in development production dengan heredoc. Satu detail kecil kalau kamu pakai zsh: tulis ${SUB_PREFIX}:environment:... pakai kurung kurawal. Tanpa kurung, zsh membaca $SUB_PREFIX:e sebagai modifier :e, dan hasilnya jadi "nvironment:...". Rusak, dan nggak kelihatan sampai login gagal.

Cek hasil:

bash
for f in vault/roles/*.json; do jq -e . "$f" >/dev/null && echo "$f ok"; done

Harus keluar ok dua kali.

Langkah 2: pasang auth JWT, policy, dan role di Vault

Auth method jwt di path jwt-github cukup dipasang sekali. Cek dulu, karena kalau sudah ada (misalnya dipasang proyek lain), vault auth enable akan gagal dengan path is already in use.

bash
export VAULT_ADDR=https://vault.example.comvault loginvault auth list | grep jwt-github || echo "belum ada"

Kalau belum ada:

bash
vault auth enable -path=jwt-github jwtvault write auth/jwt-github/config \  oidc_discovery_url="https://token.actions.githubusercontent.com" \  bound_issuer="https://token.actions.githubusercontent.com"

oidc_discovery_url adalah tempat Vault mengambil kunci publik GitHub. Vault memperbaruinya sendiri kalau GitHub mengganti kunci. bound_issuer memastikan token wajib punya iss itu.

Lalu unggah policy dan role dari file di repo:

bash
for ENV in development production; do  vault policy write "secure-cicd-go-ci-$ENV" "vault/policies/secure-cicd-go-ci-$ENV.hcl"  vault write "auth/jwt-github/role/secure-cicd-go-ci-$ENV" "@vault/roles/secure-cicd-go-ci-$ENV.json"done

@file.json artinya "kirim isi file ini sebagai data". Jadi yang ada di Vault selalu sama dengan yang ada di Git.

Cek hasil:

bash
vault read -format=json auth/jwt-github/role/secure-cicd-go-ci-production \  | jq '.data | {bound_subject, bound_claims, bound_audiences, token_policies, token_ttl}'

bound_subject harus berakhiran environment:production, job_workflow_ref berakhiran refs/heads/main, dan "token_ttl": 300 (detik).

Langkah 3: rotasi key Dokploy, simpan hanya di Vault

Key lama pernah tersimpan di GitHub, jadi saya anggap pernah terekspos. Saya buat key baru di Dokploy (Profile → API/CLI → Generate, expiration 90 hari, rate limit nyala), satu untuk dev dan satu untuk prod. Key lama jangan dihapus dulu: masih dipakai deploy sampai perubahan workflow di-merge.

bash
read -rs DOKPLOY_KEY            # tempel key DEV baru (nggak tampil), Enterprintf '%s' "$DOKPLOY_KEY" | vault kv put -mount=static-secret secure-cicd-go/ci/development/dokploy api_key=-unset DOKPLOY_KEY

read -rs membaca input tanpa menampilkannya, jadi nggak masuk history shell. api_key=- artinya nilainya diambil dari stdin, jadi key nggak muncul di argumen perintah dan nggak kelihatan di ps. Ulangi untuk ci/production/dokploy.

Saya juga menandai pemilik dan tanggal rotasi sebagai metadata. Metadata bisa dilihat tanpa membuka nilai key:

bash
ROTATE=$(date -v+90d +%F)for ENV in development production; do  vault kv metadata put -mount=static-secret \    -custom-metadata=owner=@dimss113 -custom-metadata=rotate_before="$ROTATE" \    "secure-cicd-go/ci/$ENV/dokploy"done

Langkah 4: uji isolasi sebelum menyentuh workflow

Sebelum mengubah apa pun di GitHub, saya buktikan dulu policy-nya benar. Buat token uji yang hanya punya policy CI dev:

bash
T=$(vault token create -policy=secure-cicd-go-ci-development -ttl=2m -field=token)VAULT_TOKEN=$T vault kv get -mount=static-secret -field=api_key secure-cicd-go/ci/development/dokploy | wc -cVAULT_TOKEN=$T vault kv get -mount=static-secret secure-cicd-go/ci/production/dokploy     # harus 403VAULT_TOKEN=$T vault kv get -mount=static-secret secure-cicd-go/development/secrets/jwt   # harus 403vault token revoke "$T"; unset T

wc -c menghitung jumlah karakter key tanpa menampilkannya. Ulangi dengan -policy=secure-cicd-go-development (policy aplikasi): membaca ci/development/dokploy juga harus 403.

Cek hasil: baris pertama mencetak angka lebih dari 0, sisanya Code: 403 ... permission denied.

Langkah 5: ubah deploy.yml

Perubahannya kecil, dan cuma di dua job deploy (deploy-dev dan promote). Ini deploy-dev dari repo, step retag image saya potong karena sama dengan Bagian 8:

.github/workflows/deploy.yml yaml
env:  IMAGE: ghcr.io/dimss113/secure-cicd-go  # The deploy jobs log in to Vault with their GitHub OIDC token; no Dokploy key is stored in GitHub.  VAULT_ADDR: https://vault.example.com jobs:  # ... build (sama seperti Bagian 8)   deploy-dev:    needs: build    runs-on: ubuntu-24.04    timeout-minutes: 15    environment:      name: development      url: ${{ vars.APP_URL }}    permissions:      contents: read      packages: write      id-token: write    steps:      # ... checkout + "Point the development tag at this image" (sama seperti Bagian 8)      # The role only accepts this workflow, on development, in the development environment.      - name: Read the Dokploy key from Vault        id: vault        uses: hashicorp/vault-action@892a26828f195e65540a40b4768ae4571f51ebfc # v4.0.0        with:          url: ${{ env.VAULT_ADDR }}          method: jwt          path: jwt-github          role: secure-cicd-go-ci-development          jwtGithubAudience: ${{ env.VAULT_ADDR }}          exportEnv: false          secrets: static-secret/data/secure-cicd-go/ci/development/dokploy api_key | DOKPLOY_API_KEY      - name: Deploy and smoke test        env:          DOKPLOY_URL: ${{ vars.DOKPLOY_URL }}          DOKPLOY_API_KEY: ${{ steps.vault.outputs.DOKPLOY_API_KEY }}          DOKPLOY_COMPOSE_ID: ${{ vars.DOKPLOY_COMPOSE_ID }}          APP_URL: ${{ vars.APP_URL }}          EXPECTED_COMMIT: ${{ github.sha }}        run: scripts/dokploy-deploy.sh

Job promote identik, dengan role: secure-cicd-go-ci-production dan path ci/production/dokploy.

Bagian

Kenapa

permissions: id-token: write

Izin meminta OIDC token. Hanya di dua job deploy. build dan resolve nggak dapat. Ingat workflow ini mulai dari permissions: {}

vault-action@<SHA> # v4.0.0

Dipin ke SHA commit, sama seperti semua action lain di repo. Tag bisa dipindah, SHA nggak

path: jwt-github

Default-nya jwt, jadi wajib diisi sesuai path auth method kita

jwtGithubAudience

Isi aud. Wajib sama dengan bound_audiences. Kalau dikosongkan, GitHub memakai https://github.com/<owner> dan login ditolak

exportEnv: false

Key nggak dijadikan env var untuk semua step berikutnya. Hanya step deploy yang menerimanya lewat env:

secrets: <path> api_key | DOKPLOY_API_KEY

Path KV v2 pakai data/, lalu field, lalu nama output

steps.vault.outputs.DOKPLOY_API_KEY

Key diambil dari output step Vault, bukan dari secrets. lagi

vault-action otomatis me-mask key dan token Vault di log. Token Vault-nya juga nggak di-export, dan mati dalam 5 menit.

Step Vault sengaja ditaruh setelah retag image, tepat sebelum deploy. Key baru ada di job sesaat sebelum dipakai. Kalau retag gagal, Vault nggak pernah dihubungi.

Cek hasil: sebelum commit, jalankan actionlint dan scripts/check-exceptions.sh dari Bagian 9. Lalu commit lima file (workflow, dua policy, dua role), buka PR ke development, tunggu check hijau, dan merge. PR ini sendiri nggak menjalankan deploy, karena deploy.yml hanya jalan saat push ke development/main.

Langkah 6: buktikan, lalu hapus DOKPLOY_API_KEY

Merge tadi memicu deploy di development. Tonton run-nya, lalu lihat log step Vault:

bash
RUN_ID=$(gh run list --workflow deploy.yml --branch development --limit 1 --json databaseId --jq '.[0].databaseId')gh run watch "$RUN_ID" --exit-statusgh run view "$RUN_ID" --log | grep -A6 "Read the Dokploy key from Vault" | head -20

Harus ada login jwt yang berhasil dan nggak ada nilai key (paling jauh ***). Smoke test /version menunjukkan commit merge ini.

Bukti dari sisi Vault: login pertama membuat entity dengan nama dari sub.

bash
ACC=$(vault auth list -format=json | jq -r '."jwt-github/".accessor')vault write -format=json identity/lookup/entity \  alias_name="${SUB_PREFIX}:environment:development" alias_mount_accessor="$ACC" \  | jq '.data | {name, alias_created: .aliases[0].creation_time}'

Sekarang dev sudah nggak butuh secret GitHub. Hapus:

bash
gh secret delete DOKPLOY_API_KEY --env developmentgh secret list --env development

Lalu hapus key dev yang lama di Dokploy, dan jalankan ulang run tadi dengan gh run rerun "$RUN_ID". Kalau tetap hijau, terbukti deploy dev hanya bergantung pada Vault.

Untuk production prosesnya sama seperti promosi biasa di Bagian 8: PR development → main, check hijau, merge, approve deployment production. Step Vault di promote memakai role prod. Setelah hijau, gh secret delete DOKPLOY_API_KEY --env production, dan hapus key prod lama di Dokploy.

Cek hasil:

bash
for ENV in development production; do echo "== $ENV"; gh secret list --env "$ENV"; donegh secret list

Nggak boleh ada DOKPLOY_API_KEY di mana pun. DOKPLOY_URL dan DOKPLOY_COMPOSE_ID tetap ada sebagai variable, dan memang bukan rahasia.

Yang sempat bikin saya bingung: format sub yang immutable

Deploy dev pertama setelah merge gagal. Pesannya:

text
vaultResponse: {"errors":["error validating token: invalid subject (sub) claim"]}

Padahal semuanya sudah saya uji di Vault dev server lokal, dengan token palsu yang saya tanda tangani sendiri: token dev diterima, token PR ditolak, audience salah ditolak, workflow lain ditolak. Semua sesuai harapan.

Ternyata masalahnya di format sub. Panduan Vault, vault-action, dan GitHub memberi contoh format lama: repo:dimss113/secure-cicd-go:environment:development. Tapi sejak 2026 GitHub punya format immutable subject yang menyertakan ID angka owner dan repo: repo:dimss113@<OWNER_ID>/secure-cicd-go@<REPO_ID>:.... Repo baru (dibuat setelah pertengahan Juli 2026) otomatis memakainya, termasuk repo ini.

Uji lokal saya memakai token palsu berformat lama. Jadi uji itu cuma membuktikan bahwa asumsi saya cocok dengan role saya, bukan bahwa role saya cocok dengan token GitHub yang asli.

Kenapa GitHub mengganti format? Nama bisa dipakai ulang, ID nggak. Kalau akun atau repo dihapus lalu orang lain membuat ulang dengan nama yang sama, dengan format lama sub-nya identik. Dengan format immutable, ID-nya pasti beda, jadi sub-nya beda dan ditolak. (Role kita sudah punya bound_claims.repository_id, jadi serangan ini sebenarnya sudah tertutup. Format immutable menambah lapisan kedua.)

Perbaikannya: ambil awalan dari API (oidc/customization/sub, seperti di Langkah 1), ganti bound_subject di dua file role, vault write ulang, lalu gh run rerun "$RUN_ID" --failed supaya hanya deploy-dev yang diulang. Setelah itu perbaikan file role-nya di-commit lewat PR, supaya isi vault/roles/ tetap sama dengan isi Vault.

Tips

Pelajaran

Uji integrasi identitas dengan token dari penerbit aslinya, atau baca formatnya dari sumber resmi (di sini API oidc/customization/sub). Jangan dari contoh dokumentasi. Demo di bagian berikut mencetak sub asli. Kalau demo itu saya jalankan sebelum membuat role, perbedaannya langsung kelihatan.

Demo negatif: job dari PR nggak bisa mengambil key

Ini bukti terkuat bagian ini. Skenarionya: seseorang membuka PR yang menambah workflow untuk mencuri key Dokploy. Workflow itu boleh meminta OIDC token, dan bahkan memakai audience yang benar. Vault tetap harus menolak.

PR ini cuma demo, ditutup tanpa merge.

.github/workflows/oidc-demo.yml yaml
name: oidc-demo # Demo only, never merged: a pull request job asks Vault for the Dokploy keys and must be refused.on: pull_request permissions: {} jobs:  vault-denies-pull-requests:    runs-on: ubuntu-24.04    timeout-minutes: 5    permissions:      id-token: write    env:      VAULT_ADDR: https://vault.example.com    steps:      - name: Ask GitHub for an OIDC token and try both deploy roles        run: |          jwt="$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$VAULT_ADDR" | jq -r .value)"          echo "::add-mask::$jwt"           # The payload is not a credential without its signature, so printing these claims is safe.          payload="$(cut -d. -f2 <<<"$jwt" | tr '_-' '/+')"          while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="$payload="; done          base64 -d <<<"$payload" | jq '{sub, aud, ref, environment, job_workflow_ref}'           for role in secure-cicd-go-ci-production secure-cicd-go-ci-development; do            echo "== $role"            resp="$(jq -n --arg role "$role" --arg jwt "$jwt" '{role: $role, jwt: $jwt}' |              curl -sS -X POST "$VAULT_ADDR/v1/auth/jwt-github/login" --data @-)"            if jq -e '.auth.client_token' <<<"$resp" >/dev/null; then              echo "::error::Vault accepted a pull request token for $role"              exit 1            fi            jq -c '.errors' <<<"$resp"          done          echo "Both roles refused this pull request, as expected."

Script ini melakukan hal yang sama persis dengan vault-action, tapi manual dengan curl, jadi setiap langkahnya terlihat di log. ACTIONS_ID_TOKEN_REQUEST_URL dan _TOKEN disuntik GitHub karena job punya id-token: write. Token utuh di-mask. Bagian tengah JWT (payload) di-decode supaya kita bisa melihat klaim yang dilihat Vault. Kalau Vault sampai memberi token, job dibuat gagal, karena itu artinya ada celah.

Cek hasil: di log job oidc-demo:

text
"sub": "repo:dimss113@<OWNER_ID>/secure-cicd-go@<REPO_ID>:pull_request","environment": null,"job_workflow_ref": "dimss113/secure-cicd-go/.github/workflows/oidc-demo.yml@refs/pull/<n>/merge"== secure-cicd-go-ci-production["error validating token: invalid subject (sub) claim"]== secure-cicd-go-ci-development["error validating token: invalid subject (sub) claim"]Both roles refused this pull request, as expected.

Job-nya hijau, karena hijau di sini artinya "Vault menolak". sub untuk job PR berakhiran :pull_request, bukan :environment:<env>, jadi nggak cocok dengan role mana pun.

Bagaimana kalau penyerang menambahkan environment: production ke workflow demo? GitHub menolak job itu sebelum jalan, karena production hanya boleh dipakai branch main. Kalaupun entah bagaimana lolos, job_workflow_ref (oidc-demo.yml@refs/pull/...) tetap nggak cocok dengan deploy.yml@refs/heads/main. Dua lapis.

Setelah screenshot, tutup PR-nya dengan gh pr close --delete-branch.

Keterbatasan yang tetap ada

Saya tulis jujur, karena ini juga yang biasanya ditanyakan:

Keterbatasan

Kenapa diterima

Mitigasi

Key Dokploy tetap statis 90 hari dan berlaku untuk seluruh akun Dokploy

Dokploy belum punya token per-service atau sementara

Hanya ada di Vault, hanya bisa diambil job deploy, dirotasi tiap 90 hari (rotate_before)

Selama job berjalan, key ada di memori runner

Key memang harus dikirim ke Dokploy

Runner GitHub-hosted dibuang setelah job. Di-mask, exportEnv: false

Orang dengan hak tulis bisa mengubah deploy.yml lewat PR

Yang boleh mengubah pipeline memang dipercaya pipeline

PR wajib lolos gate dan review. Role Vault juga lewat PR dan diterapkan admin Vault

Vault jadi titik gagal deploy

Disengaja, fail closed

Aplikasi yang sudah jalan nggak terdampak. Hanya deploy baru yang tertahan

job_workflow_ref mengikat nama file deploy.yml

Rename file = deploy ditolak

Ubah role di vault/roles/ dalam PR yang sama dengan rename-nya

Satu hal lagi: nggak ada auto refresh untuk key Dokploy. Yang dibuat ulang tiap run cuma OIDC token dan token Vault. Key Dokploy itu secret statis di KV, Vault hanya menyimpannya, karena nggak ada secrets engine untuk Dokploy. Kalau kedaluwarsa, Vault tetap memberi key-nya, Dokploy menjawab 401, dan step deploy merah.

Bedanya sekarang, rotasi cukup satu kali vault kv put dengan key baru, lalu perbarui rotate_before. Nggak perlu mengubah GitHub, workflow, atau membuat PR. KV v2 menyimpan versi lama, jadi kalau key baru ternyata salah, vault kv rollback -version=<versi lama> mengembalikannya.

Ini bagian terakhir. Kalau dilihat dari awal, inilah yang sekarang berdiri:

Bagian

Hasilnya

1

Tujuan, anti-goal, daftar security gate, tools, DNS

2

Secret di Vault, policy dan AppRole terpisah per environment, bukti role dev nggak bisa baca prod

3

Aplikasi Go kecil yang membaca secret dari file, bukan dari env atau repo

4

Vault Agent sidecar yang me-render config.json ke tmpfs, Compose untuk dev dan prod

5

Repo publik dan workflow PR: test, Gitleaks, Semgrep, govulncheck, dependency review, Trivy config dan image

6

Ruleset yang memblokir merge, tanpa bypass, plus demo PR yang terbukti diblokir

7

Dokploy dev dan prod, auto-deploy mati, hanya CI yang memicu deploy

8

Build sekali, scan, SBOM, GHCR, deploy dev, promosi image yang sama ke prod setelah approval, smoke test /version

9

Proses exception dengan pemilik, alasan, dan tanggal kedaluwarsa yang ditegakkan CI

10

CI tanpa kredensial Dokploy tersimpan: OIDC → Vault → token 5 menit untuk satu path

Kalau dirangkum dalam satu kalimat: deploy job nggak menyimpan kredensial Dokploy apa pun, mereka menukar OIDC token GitHub yang berumur pendek dengan token Vault 5 menit yang cuma bisa membaca satu path, terikat ke repo, environment, dan file workflow ini.

Setelah ini masih ada satu langkah yang nggak saya jadikan artikel tersendiri: merapikan README, diagram, runbook, dan screenshot bukti supaya orang lain bisa menilai proyek ini dalam lima menit.

Risiko yang sengaja diterima

Nggak ada sistem yang sempurna. Yang penting risikonya ditulis, ada alasannya, dan ada mitigasinya. Ini versi akhirnya:

Item

Kenapa diterima

Mitigasi

secret_id AppRole statis di env Dokploy

Kesederhanaan

Policy hanya read folder env sendiri, TTL token pendek, bisa dicabut kapan saja (secret-id/destroy), opsional secret_id_bound_cidrs ke IP VPS

Key API Dokploy statis 90 hari, berlaku untuk seluruh akun

Dokploy belum punya token per-service

Sejak Bagian 10 hanya di Vault, diambil lewat OIDC, token Vault 5 menit, rotasi manual dengan rotate_before

Image dev pertama (bootstrap) dibuat dari laptop

CI deploy belum ada sebelum Bagian 8

Dari development bersih, lolos Trivy, hanya tag development, langsung ditimpa CI. Production nggak pernah memakainya

Deploy pakai tag, bukan digest

Digest + signature itu proyek tersendiri

Smoke test /version memastikan commit yang jalan = commit yang di-scan

Image vault-agent dibangun di server Dokploy

Template di-bake ke image

Versi base dipatok, Dockerfile-nya ikut dipindai Trivy config

Hardening docker-compose nggak dicek otomatis

Trivy nggak mendukung compose

Review PR

App dan vault-agent memakai UID yang sama

Supaya config.json bisa 0400

Volume auth vault-agent nggak pernah di-mount ke app

Rotasi secret aplikasi nggak otomatis sampai ke app

App membaca config.json sekali saat start (sederhana, fail closed)

Agent me-render ulang, app memakai nilai baru setelah restart. Ditulis di runbook

Mencabut secret_id nggak menghentikan app yang sedang jalan

Secret sudah di memori app

Untuk memutus akses segera: cabut secret_id dan rotasi secret-nya, lalu redeploy

Healthcheck vault-agent = "config pernah ter-render", bukan "Vault bisa dihubungi"

Render lama tetap ada di tmpfs

Pantau log agent (permission denied, auth)

Langkah berikutnya

Beberapa hal sengaja saya tinggalkan supaya scope-nya nggak melebar. Kalau mau melanjutkan, ini urutan yang menurut saya masuk akal:

  • Signing image dengan Cosign dan deploy pakai digest. Sekarang deploy masih pakai tag. Dengan signature dan digest, Dokploy hanya menjalankan image yang benar-benar dibangun dan ditandatangani CI ini. Ini topik container supply-chain security yang layak jadi proyek sendiri.

  • DAST dengan OWASP ZAP. Semua scanner di seri ini statis. ZAP baseline scan ke environment dev setelah deploy akan menangkap hal yang hanya muncul saat aplikasi jalan, seperti header yang hilang atau endpoint yang terbuka.

  • Satu dashboard temuan: DefectDojo. Sekarang hasil Gitleaks, Semgrep, dan Trivy sudah masuk tab Security GitHub lewat SARIF, tapi govulncheck, dependency review, dan SBOM belum. DefectDojo (OWASP, open source) bisa mengumpulkan semuanya, deduplikasi, dan melacak SLA. API key-nya bisa diambil dari Vault lewat OIDC, pola yang sama persis dengan bagian ini. Dependency-Track juga menarik untuk memantau SBOM terus-menerus, supaya CVE yang terbit setelah deploy tetap ketahuan.

  • Rotasi key Dokploy otomatis. Dokploy punya prosedur untuk membuat dan menghapus API key, jadi job terjadwal secara teori bisa merotasinya. Harganya: job itu butuh hak tulis ke Vault dan hak membuat key Dokploy, jauh lebih besar daripada job deploy yang cuma membaca satu path. Untuk sekarang, rotasi manual yang terdokumentasi sudah cukup.

  • Policy-as-code untuk Compose (misalnya Conftest/OPA), supaya hardening docker-compose ikut jadi gate, bukan cuma review.

Rangkuman

Di bagian ini:

  • Policy secure-cicd-go-ci-<env> yang hanya bisa read satu path, disimpan di vault/policies/.

  • Role JWT secure-cicd-go-ci-<env> di vault/roles/, terikat aud, bound_subject (repo + environment), repository_id, dan job_workflow_ref (deploy.yml di branch yang benar), dengan token Vault 5 menit.

  • Auth method jwt-github di Vault yang memverifikasi tanda tangan GitHub lewat oidc_discovery_url.

  • Key Dokploy baru, hanya di Vault, dengan metadata rotate_before.

  • deploy.yml dengan id-token: write hanya di dua job deploy, vault-action dipin SHA, exportEnv: false.

  • DOKPLOY_API_KEY sudah dihapus dari kedua GitHub Environment.

  • Bukti bahwa job dari PR ditolak kedua role dengan invalid subject (sub) claim.

Dan itu menutup seri ini. Dari "security tergantung ingatan orang" di Bagian 1, sekarang setiap perubahan harus lewat gate yang memblokir merge, image yang jalan di production adalah image yang sudah diuji di dev, secret aplikasi datang dari Vault, dan CI nggak menyimpan kredensial deploy sama sekali. Repo lengkapnya ada di github.com/dimss113/secure-cicd-go. Terima kasih sudah mengikuti sampai sini. Kalau kamu membangun versi kamu sendiri, saya penasaran gate mana yang paling sering memblokir PR kamu.

Artikel terkait

DevSecOps · 19 menit baca

Bagian 7: Menyiapkan Environment Dev dan Production di Dokploy

Dua service Compose di Dokploy, satu untuk dev dan satu untuk production, masing-masing dengan AppRole Vault sendiri, domain HTTPS lewat Traefik, dan auto-deploy yang sengaja dimatikan supaya hanya CI yang boleh deploy.