Hapus API keys permanen dari repository dan repository secrets, lalu biarkan pipeline mengambil kredensial sementara langsung dari Vault setiap kali berjalan — via hashicorp/vault-action di GitHub Actions atau integrasi native keyword secrets di GitLab CI.

Setelah di episode 17 sebelumnya kita membahas bagaimana Vault terintegrasi dengan Kubernetes — mulai dari Kubernetes Auth Method, Vault Agent Sidecar Injector, hingga Vault Secrets Operator — pada episode kali ini kita akan membahas front pertarungan lainnya yang sama pentingnya: CI/CD pipeline.
Bayangkan skenario yang sangat umum di industri: sebuah tim DevOps menyimpan API key produksi di GitHub Secrets atau GitLab CI/CD Variables, lalu pipeline meng-export key itu ke environment variable setiap kali build. Praktik ini terasa nyaman, tapi mari kita bedah risikonya. Repository secrets adalah long-lived secrets — mereka tidak pernah kedaluwarsa, tidak pernah dirotasi otomatis, dan nilainya sama persis hari ini maupun enam bulan lagi. Satu kejadian saja: seorang developer yang tak sengaja menjalankan pipeline di fork atau di runner publik, atau satu workflow yang meng-echo variabel rahasia ke log, maka kredensial produksi itu bocor permanen dan harus dirotasi manual — yang seringkali tidak pernah terjadi.
Di dunia nyata, serangan terhadap CI/CD kini menjadi salah satu vektor utama peretasan supply chain software. Menurut berbagai laporan keamanan, CI/CD system adalah target favorit penyerang karena di dalamnya berkumpul banyak kredensial sekali jalan. Maka prinsip yang kita bangun di episode ini adalah: CI/CD pipeline tidak boleh menyimpan kredensial panjang, ia hanya boleh meminjamnya dalam waktu singkat. Pipeline akan login ke Vault setiap kali berjalan, menerima token dengan TTL pendek, mengambil secret yang dibutuhkan, lalu "lupa" semuanya setelah selesai.
Sebelum masuk ke implementasi, mari kita pahami dulu kenapa pola lama ini bermasalah secara fundamental. Kuncinya ada di perbedaan antara kredensial statis dan dinamis:
| Aspek | Repository Secrets (Statis) | Vault (Dinamis/Short-Lived) |
|---|---|---|
| Masa hidup | Permanen sampai dihapus manual | Beberapa menit hingga beberapa jam (TTL) |
| Rotasi | Manual, sering dilupakan | Otomatis, setiap login mendapat kredensial baru |
| Jika bocor | Berbahaya permanen, perlu rotasi darurat | Berbahaya hanya selama TTL; otomatis tidak valid lagi |
| Audit trail | Tidak ada jejak siapa memakai kapan | Setiap akses tercatat di audit log Vault |
| Scoping | Satu secret dipakai semua job | Token dan policy bisa di-scope per job/role |
Analoginya seperti memakai kunci gudang permanen yang disalin ke semua karyawan, dibandingkan memakai kartu akses hotel yang otomatis mati setelah masa menginap selesai. Kunci permanen yang jatuh ke tangan orang lain berarti gudang kalian tidak aman lagi; kartu hotel yang hilang otomatis menjadi tidak berguna setelah beberapa jam.
Dengan Vault, pola yang benar adalah:
Pipeline mulai
│
▼
Vault mendanai identitas pipeline (AppRole / OIDC / JWT)
│ → Vault menerbitkan token dengan TTL pendek
▼
Pipeline membaca secret yang diizinkan policy (KV / DB / PKI)
│ → Secret punya lease & TTL sendiri
▼
Pipeline selesai → token & lease hangus dengan sendirinyaUntuk GitHub Actions, alat utama kita adalah hashicorp/vault-action — action resmi dari HashiCorp yang menangani login ke Vault, membaca secret, dan meletakkannya ke environment variable job. Action ini menangani detail yang merepotkan: renew token saat TTL mendekati habis, dan membersihkan token di akhir job.
Ada dua cara otentikasi yang umum dipakai: AppRole (mudah, cocok untuk self-hosted runner) dan OIDC (tanpa kredensial statis sama sekali, cocok untuk GitHub-hosted runner). Kita bahas keduanya.
AppRole adalah mekanisme machine-to-machine yang sudah kita kenal dari episode 10. Untuk CI, kita membuat role khusus yang hanya memiliki policy minimal — prinsip least privilege. Pertama, tulis policy yang hanya mengizinkan membaca data yang dibutuhkan pipeline:
path "kv/data/ci/*" {
capabilities = ["read", "list"]
}
path "database/creds/deploy-reader" {
capabilities = ["read"]
}Perhatikan: policy ini hanya membaca, tidak menulis. Pipeline tidak boleh punya akses untuk mengubah data di Vault. Setelah itu, daftarkan policy dan buat role AppRole-nya:
vault policy write ci-app ci-policy.hcl
vault write auth/approle/role/ci-app \
token_policies="ci-app" \
token_period="30m" \
token_ttl="10m" \
token_max_ttl="1h" \
secret_id_ttl="5m" \
secret_id_num_uses="1" \
bound_cidr_list="10.0.0.0/8"
vault read -field=role_id auth/approle/role/ci-app/role-id
vault write -f -field=secret_id auth/approle/role/ci-app/secret-idMari bedah parameter pentingnya:
token_period="30m" — menjadikan token bersifat periodic; vault-action bisa memperbarui (renew) token selama pipeline berjalan, sehingga job yang berjalan lebih dari 10 menit tidak putus di tengah jalan.token_ttl="10m" / token_max_ttl="1h" — token pertama valid 10 menit, dan meskipun direnew berkala, tidak akan pernah melewati batas 1 jam. Ini memastikan pipeline hanya "meminjam" akses dalam jangka pendek.secret_id_ttl="5m" dan secret_id_num_uses="1" — SecretID yang di-generate hanya berlaku 5 menit dan hanya untuk satu kali login. Pola ini paling aman: kalian push SecretID segar ke runner setiap pipeline akan berjalan, bukan menyimpannya permanen.bound_cidr_list — membatasi alamat IP sumber yang boleh login. Untuk self-hosted runner di jaringan internal, ini pertahanan yang bagus.Note
Pola secret_id_num_uses="1" mengharuskan kalian mem-push SecretID segar ke setiap pipeline (misalnya via API Vault ke runner, atau memanggil vault write -f auth/approle/role/ci-app/secret-id pada step sebelum login). Jika kalian memilih menyimpan SecretID statis di GitHub Secrets, set secret_id_ttl="0" dan secret_id_num_uses="0" agar tidak kedaluwarsa — tapi ingat, itu kembali menciptakan kredensial long-lived yang harus dirotasi manual. Untuk hasil terbaik, gunakan OIDC yang dibahas di bawah.
Sekarang kita tulis workflow deploy yang mengambil secret dari Vault setiap kali berjalan, bukan dari repository secrets:
name: Deploy Application
on:
push:
branches: [main]
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Import secrets from Vault
uses: hashicorp/vault-action@v3
with:
url: https://vault.example.com:8200
method: approle
roleId: ${{ secrets.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets: |
kv/data/ci/app DB_USERNAME | DB_USERNAME ;
kv/data/ci/app DB_PASSWORD | DB_PASSWORD ;
database/creds/deploy-reader POSTGRES_PASSWORD | password
- name: Build application
run: |
npm ci
npm run build
- name: Deploy to server
run: |
./scripts/deploy.sh "$DB_USERNAME" "$DB_PASSWORD"Beberapa hal penting dari workflow di atas:
secrets: vault-action adalah <path_vault> <nama_env_tujuan> | <key_secret>, dipisahkan ;. Perhatikan path KV v2 menggunakan kv/data/ci/app — segmen data/ wajib ada, ini kesalahan paling umum (dibahas di bagian pitfalls).database/creds/deploy-reader POSTGRES_PASSWORD | password — di sini kita mengambil dynamic credentials dari database secrets engine (episode 5). Setiap pipeline run mendapat username/password database baru yang otomatis hangus sesuai TTL. Ini adalah puncak dari filosofi "short-lived".${{ secrets.VAULT_ROLE_ID }} dan ${{ secrets.VAULT_SECRET_ID }} — hanya dua kredensial bootstrap yang tersimpan di GitHub Secrets. Itu pun harus dipilih minimal dan dirotasi berkala.Warning
Jangan pernah menulis echo "$DB_PASSWORD" atau mencetak secret ke log hanya untuk "debugging". Log GitHub Actions bersifat persisten dan bisa dibaca siapa pun yang punya akses ke repository — itu sama saja dengan mempublikasikan secret. Jika harus memastikan nilai ada, cetak panjangnya saja: echo "panjang password: ${#DB_PASSWORD}".
Pola AppRole di atas sudah jauh lebih baik daripada repository secrets, tetapi masih menyisakan dua kredensial bootstrap di GitHub Secrets. Solusi paling bersih adalah OIDC: GitHub Actions menerbitkan ID token berisi klaim seperti repository dan ref, lalu Vault memvalidasi token tersebut. Tidak ada RoleID, tidak ada SecretID, tidak ada yang perlu disimpan.
Pertama, aktifkan JWT auth di Vault dan buat role yang mengikat ke repo spesifik:
vault auth enable jwt
vault write auth/jwt/role/gha-deploy \
bound_issuer="https://token.actions.githubusercontent.com" \
bound_audiences="https://vault.example.com" \
user_claim="sub" \
token_policies="ci-app" \
token_ttl="10m" \
claim_mappings=repository=repositoryLalu di workflow, gunakan method: oidc dan izinkan permission id-token: write:
name: Deploy Application (OIDC)
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Import secrets from Vault
uses: hashicorp/vault-action@v3
with:
url: https://vault.example.com:8200
method: oidc
role: gha-deploy
secrets: |
kv/data/ci/app DB_PASSWORD | DB_PASSWORD
- name: Build application
run: npm run buildYang menakjubkan dari pola ini: repository kalian tidak menyimpan satu pun kredensial Vault. Vault mempercayai token yang diterbitkan oleh GitHub karena token itu sudah ditandatangani dan klaimnya (repo, branch) diperiksa terhadap role. Perubahan kredensial? Tidak ada. Rotasi? Tidak perlu. Inilah standar industri untuk integrasi CI/CD dengan secret manager saat ini.
Tip
vault-action juga mendukung namespace, tlsSkipVerify (jangan pernah dipakai di produksi!), dan exportToken: true untuk mengekspor VAULT_TOKEN ke job bila ada tool lain yang perlu akses langsung ke Vault. Simpan default yang aman: url selalu HTTPS, dan sertakan role saat memakai method: oidc agar Vault tahu klaim aud mana yang harus divalidasi.
secrets:GitLab menawarkan integrasi yang lebih dalam karena GitLab sudah terhubung langsung dengan Vault sebagai CI/CD secret resolver. Tidak ada action eksternal — GitLab runner akan login ke Vault menggunakan JWT job, mengambil secret yang dideklarasikan, dan menyuntikkannya ke environment job.
GitLab runner mengirim CI_JOB_JWT_V2 ke Vault, dan Vault memvalidasinya lewat JWT auth method. Role-nya harus di-bind ke project GitLab tertentu:
vault auth enable jwt
vault write auth/jwt/role/gitlab-ci \
bound_issuer="https://gitlab.example.com" \
bound_audiences="https://gitlab.example.com" \
user_claim="sub" \
bound_subject="project_path:myorg/myapp:*" \
token_policies="ci-app" \
token_ttl="10m"Penjelasan singkat:
bound_issuer dan bound_audiences — GitLab menerbitkan JWT dengan issuer dan audience berupa URL instance GitLab.bound_subject — mengikat role ke project tertentu lewat project_path:group/project. Tanda * di akhir memperbolehkan semua branch project tersebut; kalian bisa persempit ke ref:main bila perlu.token_policies="ci-app" — memakai policy yang sama dengan contoh GitHub di atas.Satu langkah administrasi yang tidak boleh terlewat: beri tahu GitLab di mana Vault berada dan role mana yang dipakai. Ini dilakukan di Project → Settings → CI/CD → Variables → Vault server:
| Field | Nilai Contoh |
|---|---|
| Vault server URL | https://vault.example.com:8200 |
| Vault auth role | gitlab-ci |
| Vault namespace | kosong (untuk Vault Open Source) |
Setelah itu, tulis .gitlab-ci.yml dengan keyword secrets::
stages:
- build
- deploy
secrets:
DB_PASSWORD:
vault: kv/data/ci/app DB_PASSWORD@kv
API_KEY:
vault: kv/data/ci/app API_KEY@kv
file: false
POSTGRES_DYNAMIC:
vault: database/creds/deploy-reader password@database
file: true
build:
stage: build
image: node:20
script:
- npm ci
- npm run build
deploy:
stage: deploy
image: alpine:3
script:
- apk add --no-cache postgresql-client
- psql "postgres://app:$POSTGRES_DYNAMIC@db.example.com/appdb" -c "SELECT 1"Bedah sintaks keyword vault::
vault: <path_secret> <field_secret>@<mount_point>. Untuk KV v2, path lengkapnya kv/data/ci/app, field-nya DB_PASSWORD, dan mount-nya kv.database/creds/deploy-reader password@database — mengambil field password dari dynamic database credentials (mount database). GitLab akan memperoleh lease baru setiap pipeline, dan Vault akan mencabutnya setelah TTL.file: true — menulis secret ke file sementara ($DB_PASSWORD_FILE-style) alih-alih environment variable. Ini mencegah secret muncul di halaman variabel job dan mengurangi risiko ter-print ke log.Note
GitLab juga mendukung id_tokens: untuk menciptakan OIDC token dengan klaim aud khusus, berguna bila ingin menyambungkan GitLab ke Vault dengan mekanisme yang lebih eksplisit (mirip OIDC GitHub) atau ke sistem lain yang menerima OIDC. Native secrets: tetap yang paling sederhana karena tidak butuh step tambahan.
Kedua platform memecahkan masalah yang sama dengan filosofi berbeda. Tabel berikut meringkas perbandingannya:
| Aspek | GitHub Actions | GitLab CI |
|---|---|---|
| Mekanisme | Step hashicorp/vault-action | Keyword native secrets: |
| Auth default | AppRole atau OIDC (method: oidc) | JWT job (CI_JOB_JWT_V2 → auth/jwt/login) |
| Konfigurasi server | Per step di workflow (url, method) | Project settings → Vault server |
| Inject ke job | Environment variable dari action outputs | Environment variable atau file (file: true) |
| Dynamic secrets | Ambil lewat path di secrets: | Ambil lewat path di vault: |
| Kredensial bootstrap | RoleID/SecretID (atau tanpa apa pun via OIDC) | Tidak ada — hanya butuh URL + role |
| Renewal token | Otomatis oleh vault-action | Ditangani GitLab/Vault JWT login |
Secara singkat: GitLab lebih "native" dan zero-config dari sisi pipeline; GitHub Actions lebih fleksibel karena kalian mengontrol penuh proses login di dalam workflow.
Terakhir, mari kita bicarakan jebakan yang paling sering menghantui tim yang baru berintegrasi dengan Vault:
| Kesalahan | Gejala | Solusi |
|---|---|---|
Path KV v2 tanpa segmen data/ | Permission denied atau secret not found | Gunakan kv/data/ci/app, bukan kv/ci/app |
| RoleID/SecretID di-commit ke repo | Kredensial bocor permanen ke git history | Simpan di Secrets/Variables; rotasi jika pernah tercommit |
| TTL token terlalu pendek | Job yang lama gagal di tengah jalan | Gunakan token_period + renewal, atau token_max_ttl yang cukup |
| Service token abadi dipakai pipeline | Kembali ke masalah long-lived secrets | Selalu AppRole/JWT dengan TTL pendek |
| Secret di-print ke log | Bocor ke log viewer yang persisten | Masking, file: true, jangan echo |
bound_cidr_list di GitHub-hosted runner | Login ditolak karena IP runner berubah | Pakai OIDC, atau gunakan CIDR khusus self-hosted runner |
Lupa id-token: write (OIDC) | vault-action error 401 | Tambahkan permission id-token: write di workflow |
| Satu role untuk semua pipeline | Serangan pada satu project = akses semua | Satu role per project/pipeline dengan policy minimal |
Important
Sekalipun pipeline hanya "meminjam" kredensial dalam waktu singkat, prinsip least privilege tetap wajib. Role CI hanya boleh membaca path yang dibutuhkan job tersebut — jangan pernah memberi policy * pada path kv/* untuk pipeline. Karena jika runner atau workflow disusupi, batas itu adalah garis pertahanan terakhir kalian.
Pada episode 18 ini kita telah membahas bagaimana menghentikan kebiasaan menyimpan API keys permanen di repository secrets dan menggantinya dengan kredensial sementara yang diambil langsung dari Vault saat pipeline berjalan. Kita sudah melihat implementasi lengkap dengan hashicorp/vault-action di GitHub Actions — baik lewat AppRole maupun OIDC tanpa kredensial statis — serta integrasi native keyword secrets: di GitLab CI yang menyuntikkan secret dari Vault ke job secara otomatis.
Poin kunci yang perlu kalian bawa pulang:
vault-action mengelola login, renewal, dan revocation token; OIDC menghilangkan kredensial bootstrap sama sekali.secrets: + JWT auth role membuat integrasi native tanpa action tambahan.data/, TTL token harus cukup untuk durasi job, dan secret tidak boleh pernah di-print ke log.Di episode 19 selanjutnya, kita akan naik satu tingkat: integrasi Vault dengan Infrastructure as Code (IaC) menggunakan Terraform Vault Provider dan Ansible lookup plugin — bagaimana infrastruktur kalian bisa menerbitkan kredensial database dinamis atau sertifikat PKI langsung saat provisioning, dan bagaimana menghindari jebakan secret yang bocor ke tfstate dan log. Pastikan tetap semangat!