Bagian 8: Workflow Deploy, Build Sekali, Scan, SBOM, GHCR, lalu Promosi ke Production

· 20 menit baca

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

Sampai Bagian 7, semua bagian pipeline sudah ada tapi belum tersambung. PR sudah dijaga security gate (Bagian 5 dan 6), dan Dokploy sudah punya dua service, dev dan production (Bagian 7). Yang belum ada: siapa yang menekan tombol deploy. Auto-deploy Dokploy sengaja saya matikan, jadi sekarang belum ada apa-apa yang men-deploy.

Di bagian ini kita tulis deploy.yml. Merge ke development membangun image, men-scan-nya, membuat SBOM, push ke GHCR, lalu deploy ke dev. Merge ke main nggak build ulang: ia mencari image yang sudah lolos di dev, menunggu approval saya, lalu mempromosikan image yang sama itu ke production.

Untuk API key Dokploy, bagian ini memakai Tahap A: key disimpan sebagai GitHub Environment secret. Ini sengaja, supaya pipeline-nya jalan dulu. Di Bagian 10, secret itu saya hapus dan diganti OIDC ke Vault, jadi GitHub nggak menyimpan kredensial apa pun.

Hasil akhir bagian ini

text
PR merge ke development  └─► push development ─► deploy.yml        build          docker build (COMMIT = SHA)            ┐          Trivy image gate (CRITICAL)            │ image dibangun SEKALI          Syft → SBOM CycloneDX (artifact)       │          push ghcr.io/...:sha-<SHA>             │        deploy-dev  [environment: development]   │          tag :development → sha-<SHA>           │          Dokploy compose.deploy (dev)           │          smoke test: /version == SHA            │                                                 │PR merge development → main                      │  └─► push main ─► deploy.yml                    │        resolve                                  │          parent ke-2 merge commit = SHA dev     │          SHA itu WAJIB sukses deploy di dev     │        promote  [environment: production]       │          ⏸ menunggu APPROVAL                    │          tag :production → sha-<SHA>  ◄─────────┘ image yang SAMA          Dokploy compose.deploy (prod)          smoke test: /version == SHA

Konsep dulu: build sekali, promosikan

Analogi yang paling gampang buat saya: pabrik obat. Satu batch dibuat, lalu diuji di lab. Kalau lolos, batch yang sama dikirim ke apotek. Pabrik nggak membuat batch baru khusus untuk apotek, karena batch baru berarti belum diuji.

Kalau production build ulang dari main, hasil build-nya bisa beda dari yang diuji di dev. Base image bisa sudah bergeser, tool build bisa beda versi. Yang di-approve jadi nggak sama dengan yang jalan.

Jadi production nggak pernah build. Production hanya memindahkan tag :production ke image sha-<SHA> yang sudah jalan di dev. Pemindahan tag pakai docker buildx imagetools create, dan saya sudah cek di registry lokal bahwa perintah itu mempertahankan digest yang sama.

Ada tiga tag di GHCR:

Tag

Siapa yang memindahkan

Artinya

sha-<SHA lengkap>

job build, sekali, nggak pernah diubah

"Image dari commit ini". Jejak audit

development

job deploy-dev

Yang jalan di dev

production

job promote, setelah approval

Yang jalan di production

Rencana awal saya, tag development dipasang di job build. Akhirnya saya pindah ke deploy-dev, supaya simetris dengan production: build hanya push sha-<SHA>, dan tag environment dipindah oleh job yang memang men-deploy environment itu.

Perhatian

Tag production hanya bergerak setelah approval

Urutannya penting. Kalau tag :production dipindah dulu baru minta approval, lalu approval ditolak, tag-nya sudah menunjuk image yang nggak disetujui siapa pun. Restart container berikutnya akan menarik image itu. Karena itu pemindahan tag ada di dalam job yang menunggu approval.

GitHub Environments

Environment di GitHub adalah "tujuan deploy" dengan aturannya sendiri. Ini yang saya pakai:

Fitur

Dipakai untuk

Secret environment

DOKPLOY_API_KEY hanya bisa dibaca job yang memakai environment: itu. Job build nggak bisa membacanya

Variable environment

DOKPLOY_URL, DOKPLOY_COMPOSE_ID, APP_URL. Nama sama, nilai beda per env

Deployment branch

development hanya dari branch development, production hanya dari main

Required reviewers

Job yang memakai production berhenti sampai di-approve

Deployment history

Setiap job dengan environment: tercatat sebagai deployment (SHA, status). Job resolve memakai catatan ini

Dua API key Dokploy

Di Dokploy: menu profil → Profile → API/CLI → Generate. Saya buat dua key:

Field

Key 1

Key 2

Kenapa

Name

ci-development

ci-production

Jelas key mana yang harus dicabut

Prefix

scg_dev

scg_prod

Bukan rahasia. Kalau bocor di log, langsung ketahuan milik siapa

Expiration

90 hari

sama

Jangan Never. Masa berlaku memaksa rotasi

Rate limit

10 request per jam

sama

Workflow hanya memanggil Dokploy sekali per deploy

Kenapa dua key, padahal keduanya berlaku untuk seluruh akun Dokploy? Supaya bisa dicabut terpisah. Kalau key dev bocor, cabut ci-development saja, production tetap jalan. Log Dokploy juga jadi menunjukkan key mana yang dipakai.

Tips

Kalau kena HTTP 429

Waktu debug dan menjalankan ulang workflow berkali-kali, request ke-11 dalam satu jam ditolak. Itu tanda rate limit bekerja. Tunggu jendelanya habis, atau naikkan batasnya sementara.

Buat environment lewat API

Saya buat environment lewat gh api, bukan klik di UI, supaya tercatat sebagai perintah dan bisa diulang, sama seperti ruleset di Bagian 6.

bash
R=dimss113/secure-cicd-goME=$(gh api user --jq .id)   # reviewer ditulis sebagai ID numerik, bukan username gh api -X PUT repos/$R/environments/development --input - <<'EOF'{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}EOFgh api -X POST repos/$R/environments/development/deployment-branch-policies -f name=development -f type=branch gh api -X PUT repos/$R/environments/production --input - <<EOF{  "wait_timer": 0,  "prevent_self_review": false,  "can_admins_bypass": false,  "reviewers": [{"type": "User", "id": $ME}],  "deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}EOFgh api -X POST repos/$R/environments/production/deployment-branch-policies -f name=main -f type=branch

Yang penting di sini:

  • custom_branch_policies: true plus deployment-branch-policies membatasi branch mana yang boleh memakai environment. Workflow dari branch lain nggak bisa membaca API key-nya.

  • can_admins_bypass: false: admin repo (saya sendiri) nggak bisa melewati approval. Konsisten dengan ruleset Bagian 6 yang bypass list-nya kosong.

  • prevent_self_review: false: saya boleh approve deploy yang saya picu sendiri. Di proyek solo ini harus false. Kalau true dan reviewer-nya cuma saya, deploy menunggu selamanya.

Catatan

Self-review itu known limitation

Di tim sungguhan, prevent_self_review harus true dengan reviewer dari tim lain (segregation of duties). Orang yang menulis perubahan bukan orang yang menyetujui rilis. Di proyek solo nggak mungkin, jadi saya catat sebagai keterbatasan. Approval tetap berguna: deploy production jadi keputusan sadar yang tercatat (siapa, kapan, komentar apa), bukan efek samping merge.

Cek hasil:

bash
gh api repos/$R/environments --jq '.environments[] | {name, rules: [.protection_rules[].type], admins_bypass: .can_admins_bypass}'
text
{"admins_bypass":true,"name":"development","rules":["branch_policy"]}{"admins_bypass":false,"name":"production","rules":["required_reviewers","branch_policy"]}

development nggak punya reviewer, jadi bypass admin di sana nggak berpengaruh.

Isi secret dan variable

bash
gh secret set DOKPLOY_API_KEY --env development --repo $R   # tempel key ci-developmentgh secret set DOKPLOY_API_KEY --env production  --repo $R   # tempel key ci-productionpbcopy < /dev/null                                          # kosongkan clipboard

Tanpa --body, gh menanyakan nilainya secara tersembunyi, jadi key nggak masuk history shell.

bash
gh variable set DOKPLOY_URL        --env development --repo $R --body "https://dokploy.domainkamu.com"gh variable set DOKPLOY_COMPOSE_ID --env development --repo $R --body "<composeId dev>"gh variable set APP_URL            --env development --repo $R --body "https://secure-cicd-dev.domainkamu.com"# ulangi untuk --env production dengan composeId prod dan https://secure-cicd.domainkamu.com

Kenapa composeId dan URL jadi variable, bukan secret? Karena keduanya bukan rahasia: tanpa API key, nggak bisa dipakai. Kalau dijadikan secret, log penuh *** dan susah di-debug. Jangan menjadikan semuanya secret, nanti orang nggak tahu mana yang benar-benar harus dijaga.

Perhatian

Panel Dokploy wajib HTTPS

Workflow mengirim API key ke panel Dokploy. Kalau panel masih http://<IP_VPS>:3000, key berjalan tanpa enkripsi di internet. Pasang domain dan Let's Encrypt untuk panelnya dulu, lalu pakai URL https:// itu sebagai DOKPLOY_URL, tanpa / di akhir.

Cara cepat mengecek DOKPLOY_URL tanpa key:

bash
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://dokploy.domainkamu.com/api/compose.deploy

Cek hasil: harus 401. Artinya endpoint ada, HTTPS valid, dan cuma butuh key. 404 berarti URL salah.

Script deploy: scripts/dokploy-deploy.sh

Rencana awal, logika deploy ditulis langsung di workflow. Akhirnya saya pindah ke satu script, karena dipakai dua job (dev dan prod) dan karena lebih gampang diuji. Saya mengujinya dengan server tiruan untuk tiga kasus: sukses, commit nggak cocok, dan API key salah.

scripts/dokploy-deploy.sh bash
#!/usr/bin/env bash# Asks Dokploy to deploy one compose service, then waits until the app reports the expected commit.# A deploy only counts once /version proves the scanned commit is the one running.# Env: DOKPLOY_URL DOKPLOY_API_KEY DOKPLOY_COMPOSE_ID APP_URL EXPECTED_COMMIT [TIMEOUT_SECONDS] [POLL_SECONDS]set -euo pipefail : "${DOKPLOY_URL:?DOKPLOY_URL is not set}": "${DOKPLOY_API_KEY:?DOKPLOY_API_KEY is not set}": "${DOKPLOY_COMPOSE_ID:?DOKPLOY_COMPOSE_ID is not set}": "${APP_URL:?APP_URL is not set}": "${EXPECTED_COMMIT:?EXPECTED_COMMIT is not set}"timeout="${TIMEOUT_SECONDS:-300}"poll="${POLL_SECONDS:-10}" body="$(jq -n --arg id "$DOKPLOY_COMPOSE_ID" --arg sha "$EXPECTED_COMMIT" \  '{composeId: $id, title: ("CI deploy " + $sha[0:7])}')" # The key is read from a file descriptor, not passed as an argument, so it never shows up in the process list.curl -sS --fail-with-body --max-time 30 -X POST "$DOKPLOY_URL/api/compose.deploy" \  -H "Content-Type: application/json" \  -H @<(printf 'x-api-key: %s\n' "$DOKPLOY_API_KEY") \  --data "$body"echo deadline=$((SECONDS + timeout))while :; do  got="$(curl -sS --max-time 5 "$APP_URL/version" 2>/dev/null | jq -r '.commit // empty' 2>/dev/null || true)"  if [ "$got" = "$EXPECTED_COMMIT" ]; then    curl -sS --fail --max-time 5 "$APP_URL/health"    echo "running commit $EXPECTED_COMMIT"    exit 0  fi  if [ "$SECONDS" -ge "$deadline" ]; then    echo "::error::$APP_URL still reports '${got:-unreachable}' after ${timeout}s, expected $EXPECTED_COMMIT"    exit 1  fi  echo "waiting: app reports '${got:-unreachable}'"  sleep "$poll"done

Jangan lupa chmod +x scripts/dokploy-deploy.sh. Git menyimpan bit itu, jadi di runner file-nya langsung bisa dijalankan.

Beberapa baris yang perlu dijelaskan:

Baris

Kenapa

: "${VAR:?pesan}"

Validasi input. Kalau variabel kosong, berhenti dengan pesan jelas, bukan jalan diam-diam dengan string kosong

jq -n --arg ...

JSON dibangun dengan jq, bukan tempel string. --arg meng-escape nilai, jadi karakter aneh nggak bisa merusak body

--fail-with-body

HTTP 4xx/5xx jadi exit gagal, tapi isi balasan ({"message":"Unauthorized"}) tetap dicetak

-H @<(printf ...)

Header dibaca dari process substitution. printf itu builtin bash, jadi nggak ada proses baru. Key nggak muncul di ps aux, beda dengan -H "x-api-key: $KEY" yang ikut jadi argumen proses curl

|| true di loop

Waktu app restart, curl gagal. Itu normal saat menunggu, jangan sampai set -e menghentikan script

::error::

Muncul sebagai anotasi merah di halaman run GitHub

Kenapa smoke test membandingkan /version dengan SHA

Ini bagian yang paling saya suka. compose.deploy di Dokploy hanya memasukkan antrean: saya baca di source Dokploy v0.30.10, ia memanggil myQueue.add(...) lalu langsung membalas "Deployment queued". Deploy-nya sendiri jalan sesudah API menjawab, dan bisa gagal: pull gagal, vault-agent nggak bisa login, app crash.

Tanpa smoke test, workflow hijau walaupun yang jalan masih versi lama. Aplikasi Go dari Bagian 3 menanam SHA commit lewat -ldflags dan melaporkannya di /version. Jadi script menunggu sampai /version menunjukkan persis commit yang di-scan dan di-approve. Cuma itu yang membuktikan deploy-nya benar-benar terjadi.

Bonusnya, ini juga menangkap deploy manual dari UI Dokploy. Kalau ada yang men-deploy versi lain, /version akan berbeda dan deploy berikutnya ketahuan.

Workflow: .github/workflows/deploy.yml

Saya tampilkan per bagian. Ini versi Tahap A, persis seperti waktu pertama di-merge. Di Bagian 9 satu baris trivyignores: .trivyignore ditambahkan ke step Trivy, dan di Bagian 10 cara mengambil API key diganti.

Bagian atas

.github/workflows/deploy.yml yaml
name: deploy on:  push:    branches: [development, main] permissions: {} # One deploy per branch at a time; a newer push waits instead of cancelling a deploy halfway.concurrency:  group: deploy-${{ github.ref_name }}  cancel-in-progress: false env:  IMAGE: ghcr.io/dimss113/secure-cicd-go
  • on: push ke development dan main. Karena ruleset Bagian 6, satu-satunya cara commit masuk ke dua branch itu adalah merge PR yang lolos gate.

  • permissions: {}: default tanpa izin sama sekali. Setiap job meminta izinnya sendiri.

  • cancel-in-progress: false: beda dengan PR checks. Membatalkan scan lama itu aman, tapi deploy yang dipotong di tengah bisa meninggalkan server setengah ter-update. Push baru harus menunggu.

Job build (hanya di development)

yaml
jobs:  build:    if: github.ref_name == 'development'    runs-on: ubuntu-24.04    timeout-minutes: 20    permissions:      contents: read      packages: write    steps:      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1        with:          persist-credentials: false      - name: Build image        run: |          docker build \            --build-arg COMMIT="$GITHUB_SHA" \            --build-arg BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \            --label org.opencontainers.image.source="https://github.com/$GITHUB_REPOSITORY" \            --label org.opencontainers.image.revision="$GITHUB_SHA" \            -t "$IMAGE:sha-$GITHUB_SHA" .      - name: Trivy image (gate)        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0        with:          version: v0.76.1          scan-type: image          image-ref: ${{ env.IMAGE }}:sha-${{ github.sha }}          severity: CRITICAL          ignore-unfixed: true          format: table          exit-code: 1      - name: Install syft        env:          SYFT_VERSION: 1.54.1          SYFT_SHA256: c069905b391cc4c20a5ba65ad5c10be2a7ba074f8ea6ad203e24d14e303dad47        run: |          curl -sSfL -o syft.tar.gz \            "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz"          echo "${SYFT_SHA256}  syft.tar.gz" | sha256sum --check -          tar -xzf syft.tar.gz syft          rm syft.tar.gz      - name: SBOM (CycloneDX)        run: ./syft scan "docker:$IMAGE:sha-$GITHUB_SHA" -o cyclonedx-json=sbom.cdx.json      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2        with:          name: sbom-${{ github.sha }}          path: sbom.cdx.json          retention-days: 90      - name: Push sha-<commit> to GHCR        env:          REGISTRY_TOKEN: ${{ github.token }}        run: |          echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin          docker push "$IMAGE:sha-$GITHUB_SHA"          docker logout ghcr.io          digest="$(docker buildx imagetools inspect "$IMAGE:sha-$GITHUB_SHA" | sed -n 's/^Digest: *//p')"          echo "Pushed \`$IMAGE:sha-$GITHUB_SHA\` (\`$digest\`)" >> "$GITHUB_STEP_SUMMARY"

Urutannya build → scan → SBOM → push. Kalau Trivy gagal, image nggak pernah sampai ke registry. Yang ada di GHCR hanya image yang lolos scan dan punya SBOM.

Kenapa Trivy jalan lagi, padahal PR sudah di-scan? Karena di antara PR dibuka dan merge bisa muncul CVE baru. Ini scan terakhir sebelum image dipublikasikan, dengan aturan yang sama: CRITICAL yang sudah ada fix-nya, gagal.

Untuk Syft, saya nggak pakai action pihak ketiga. Binary-nya diunduh dengan versi dipatok, lalu diverifikasi sha256sum --check, sama seperti Gitleaks di Bagian 5. Kalau file di GitHub Releases diganti orang, checksum nggak cocok dan job berhenti. Saya sengaja pilih 1.54.1, bukan rilis terbaru yang baru berumur beberapa hari: rilis yang terlalu baru belum teruji waktu.

SBOM-nya format CycloneDX JSON, disimpan sebagai artifact run selama 90 hari dengan nama berisi SHA. Token GHCR pakai github.token (otomatis per run), dimasukkan lewat env: dan --password-stdin, jadi nggak muncul di argumen proses. Digest yang ter-push ditulis ke Summary run sebagai bukti audit SHA ↔ digest.

Job deploy-dev

yaml
  deploy-dev:    needs: build    runs-on: ubuntu-24.04    timeout-minutes: 15    environment:      name: development      url: ${{ vars.APP_URL }}    permissions:      contents: read      packages: write    steps:      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1        with:          persist-credentials: false      - name: Point the development tag at this image        env:          REGISTRY_TOKEN: ${{ github.token }}        run: |          echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin          docker buildx imagetools create -t "$IMAGE:development" "$IMAGE:sha-$GITHUB_SHA"          docker logout ghcr.io      - name: Deploy and smoke test        env:          DOKPLOY_URL: ${{ vars.DOKPLOY_URL }}          DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}          DOKPLOY_COMPOSE_ID: ${{ vars.DOKPLOY_COMPOSE_ID }}          APP_URL: ${{ vars.APP_URL }}          EXPECTED_COMMIT: ${{ github.sha }}        run: scripts/dokploy-deploy.sh

environment: development membuat job ini bisa membaca secret dan variable environment itu, dan GitHub mencatatnya sebagai deployment. DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} inilah Tahap A: key masuk sebagai env hanya untuk step ini, dan di log disensor jadi ***.

Setelah compose.deploy, Dokploy menjalankan docker compose up -d. Compose dev memakai pull_policy: always, jadi image :development yang baru ditarik, dan karena digest-nya berubah container app dibuat ulang.

Job resolve (hanya di main)

yaml
  resolve:    if: github.ref_name == 'main'    runs-on: ubuntu-24.04    timeout-minutes: 5    permissions:      contents: read      deployments: read    outputs:      sha: ${{ steps.find.outputs.sha }}    steps:      - name: Find the development commit this merge brought in        id: find        env:          GH_TOKEN: ${{ github.token }}        run: |          parents="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.parents | length')"          if [ "$parents" != 2 ]; then            echo "::error::main must only change through a merge commit from development"            exit 1          fi          sha="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.parents[1].sha')"          echo "sha=$sha" >> "$GITHUB_OUTPUT"          echo "Promoting development commit \`$sha\`" >> "$GITHUB_STEP_SUMMARY"      - name: That commit must have deployed successfully to development        env:          GH_TOKEN: ${{ github.token }}          SHA: ${{ steps.find.outputs.sha }}        run: |          id="$(gh api "repos/$GITHUB_REPOSITORY/deployments?environment=development&sha=$SHA" --jq '.[0].id // empty')"          state="$(if [ -n "$id" ]; then gh api "repos/$GITHUB_REPOSITORY/deployments/$id/statuses" --jq '.[0].state'; fi)"          if [ "$state" != success ]; then            echo "::error::$SHA has no successful development deployment (state: '${state:-none}')"            exit 1          fi          docker buildx imagetools inspect "$IMAGE:sha-$SHA" >/dev/null

Job ini menjawab pertanyaan: image mana yang harus dipromosikan?

Merge commit punya dua parent. Parent pertama adalah main sebelumnya, parent kedua adalah ujung branch yang di-merge, yaitu development, yang juga head SHA dari PR development → main. Commit itulah yang image-nya dibangun dan diuji di dev. Kalau commit di main bukan merge commit (misalnya ruleset dimatikan lalu ada push langsung, atau ada squash), job berhenti dan nggak mempromosikan apa pun.

Rencana awal saya cuma mengecek "image sha-<SHA> ada di GHCR". Ternyata itu kurang. Image sudah di-push di job build, sebelum deploy dev. Kalau deploy dev atau smoke test gagal, image-nya tetap ada. Jadi cek "image ada" bisa meloloskan commit yang gagal di dev. Karena itu resolve bertanya ke GitHub: "deployment development untuk SHA ini statusnya success?" Kalau nggak, promosi berhenti sebelum sempat minta approval.

Job promote

yaml
  promote:    needs: resolve    runs-on: ubuntu-24.04    timeout-minutes: 15    environment:      name: production      url: ${{ vars.APP_URL }}    permissions:      contents: read      packages: write    steps:      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1        with:          persist-credentials: false      # Only after approval: the production tag never points at an image nobody approved.      - name: Point the production tag at the approved image        env:          REGISTRY_TOKEN: ${{ github.token }}          SHA: ${{ needs.resolve.outputs.sha }}        run: |          echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin          docker buildx imagetools create -t "$IMAGE:production" "$IMAGE:sha-$SHA"          docker logout ghcr.io      - name: Deploy and smoke test        env:          DOKPLOY_URL: ${{ vars.DOKPLOY_URL }}          DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}          DOKPLOY_COMPOSE_ID: ${{ vars.DOKPLOY_COMPOSE_ID }}          APP_URL: ${{ vars.APP_URL }}          EXPECTED_COMMIT: ${{ needs.resolve.outputs.sha }}        run: scripts/dokploy-deploy.sh

Di environment: production workflow berhenti dan menunggu approval. Belum ada satu step pun yang jalan, jadi secret production belum dibaca dan tag belum dipindah. Baru setelah approve, tag :production diarahkan ke image yang sama persis dengan yang diuji di dev.

Perhatikan EXPECTED_COMMIT. Production harus melaporkan SHA development dari resolve, bukan github.sha (SHA merge commit di main). Merge commit main itu nggak pernah di-build, jadi nggak akan pernah muncul di /version. Kalau saya salah pakai github.sha di sini, smoke test akan selalu timeout.

Cek lokal sebelum commit

bash
go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12docker run --rm -v "$PWD/scripts:/mnt" koalaman/shellcheck:v0.10.0 /mnt/dokploy-deploy.sh && echo SC_OKbash -n scripts/dokploy-deploy.sh && echo SYNTAX_OK

actionlint menangkap typo key dan ekspresi ${{ }} yang salah. ShellCheck menangkap bug bash seperti kutip yang lupa. Cek hasil: actionlint tanpa output, lalu SC_OK dan SYNTAX_OK. Saya juga memastikan dua file ini lolos Semgrep dan Gitleaks, supaya PR-nya nggak diblokir gate sendiri.

Deploy dev pertama

Commit, buka PR ke development, tunggu semua check hijau, lalu merge dengan merge commit:

bash
git add scripts/dokploy-deploy.sh .github/workflows/deploy.ymlgit commit -m "ci: build, scan and deploy through Dokploy with promotion to production"git push -u origin ci/deploy-workflowgh pr create --base development --title "ci: deploy workflow with build-once promotion" --body "..."gh pr checks --watchgh pr merge --merge --delete-branchgh run watch

Merge ini adalah push ke development, jadi deploy.yml jalan pertama kali dan menimpa image bootstrap dari Bagian 7.

Perhatian

Run pertama saya gagal di push GHCR

Error-nya denied: permission_denied: write_package. Login sukses, beberapa layer already exists, lalu ditolak. Penyebabnya: package dibuat dari Mac waktu bootstrap di Bagian 7, jadi GITHUB_TOKEN repo nggak otomatis boleh menulis, walaupun label source sudah menautkan package ke repo. Solusinya: package settings → Manage Actions access → tambah repo secure-cicd-go dengan role Write, lalu gh run rerun <run-id> --failed.

Cek hasil: build dan deploy-dev hijau, resolve dan promote skipped. Log smoke test kira-kira begini:

text
{"success":true,"message":"Deployment queued","composeId":"..."}waiting: app reports '<SHA bootstrap>'waiting: app reports 'unreachable'{"status":"ok"}running commit <SHA development>

Dari laptop, commit di /version harus sama dengan git rev-parse HEAD di development:

bash
git switch development && git pull --ff-only && git rev-parse HEADcurl -s https://secure-cicd-dev.domainkamu.com/version

SBOM bisa diunduh dan diintip:

bash
RUN_ID=$(gh run list --workflow deploy.yml --branch development --status success --limit 1 --json databaseId --jq '.[0].databaseId')SHA=$(gh run view "$RUN_ID" --json headSha --jq .headSha)gh run download "$RUN_ID" -n "sbom-$SHA" -D /tmp/sbomjq '.components | length' /tmp/sbom/sbom.cdx.json

Isinya daftar komponen di image: paket distroless dan modul Go seperti github.com/golang-jwt/jwt/v5.

Promosi ke production

bash
gh pr create --base main --head development \  --title "release: promote development to production" \  --body "Promotes the development build that passed dev deploy and smoke test."gh pr checks --watchgh pr merge --merge

Jangan pakai --delete-branch di sini. development nggak boleh dihapus, dan ruleset juga menolaknya.

Perhatian

Pasang domain prod sebelum Approve

Sebelum merge ini, main belum punya docker-compose.production.yml, jadi tab Domains Dokploy belum bisa memilih service. Sekarang file-nya sudah ada di main dan promote sedang menunggu approval. Pakai jeda ini: service prod → Domains → host secure-cicd.domainkamu.com, service app, port 8080, HTTPS Let's Encrypt. Tekan Create, tapi jangan Deploy dari Dokploy. Tanpa domain, Traefik menjawab 404, smoke test timeout setelah 300 detik, dan promote merah padahal container-nya jalan.

Lalu gh run view --web, klik banner Review pending deployments, centang production, tulis komentar (misalnya Release d4fedbe, dev smoke test passed), Approve and deploy.

Cek hasil: commit production sama dengan commit dev, dan digest dua tag identik.

bash
curl -s https://secure-cicd.domainkamu.com/versioncurl -s https://secure-cicd-dev.domainkamu.com/versiondocker buildx imagetools inspect ghcr.io/dimss113/secure-cicd-go:production | sed -n 's/^Digest: *//p'docker buildx imagetools inspect ghcr.io/dimss113/secure-cicd-go:development | sed -n 's/^Digest: *//p'

Uji negatif: tolak approval

Bukti yang paling meyakinkan adalah melihatnya gagal. Saya buat satu perubahan kecil (catatan dokumentasi), merge ke development, tunggu deploy dev hijau, lalu PR development → main dan merge. Kali ini di halaman run saya klik Reject.

Cek hasil:

  • promote berstatus rejected, tanpa satu step pun jalan.

  • /version production masih SHA lama.

  • Tag :production di GHCR masih menunjuk digest lama.

Setelah reject, main berisi commit yang belum jalan di production. Itu normal di model ini: main artinya "disetujui untuk dirilis", dan rilisnya bisa ditunda. Kalau mau merilis nanti, Re-run jobs di run itu lalu approve.

Kalau ada masalah

Gejala

Penyebab

Solusi

401 + Unauthorized dari Dokploy

API key salah, kedaluwarsa, atau dicabut

Buat key baru, gh secret set lagi

404 dari Dokploy

DOKPLOY_URL diakhiri /, jadi //api

Hapus / di akhir

Smoke test timeout, app tetap SHA lama

Deploy Dokploy gagal atau masih antre

Lihat tab Deployments di Dokploy

unreachable terus

App crash setelah deploy

Logs container app dan vault-agent

main must only change through a merge commit

Merge pakai squash/rebase

Ruleset main hanya izinkan merge commit (Bagian 6)

has no successful development deployment

Deploy dev SHA itu gagal atau belum selesai

Tunggu dev hijau, lalu re-run failed jobs di run main

promote menunggu selamanya

prevent_self_review: true dan reviewer cuma kamu

Set ulang environment production dengan false

Env DOKPLOY_API_KEY kosong

Secret dibuat di level repo, bukan environment

gh secret list --env development

Batasan yang saya terima

Item

Jalan ke depan

Reviewer production = penulis perubahan

Di tim: reviewer dari tim lain, prevent_self_review: true

API key Dokploy berlaku untuk seluruh akun

Dua key terpisah dengan masa berlaku. Bagian 10 memindahkan key ke Vault lewat OIDC

Deploy pakai tag, bukan digest

Tag hanya dipindah setelah approval, plus smoke test /version. Digest dan Cosign di proyek lain

SBOM hanya artifact 90 hari

Nanti sebagai attestation di registry

deploy.yml bisa diubah lewat PR

CODEOWNERS untuk /.github/ atau required workflow dari repo pusat

Rangkuman

  • GitHub Environment development (hanya branch development) dan production (hanya main, wajib approval, admin nggak bisa bypass).

  • Dua API key Dokploy dengan masa berlaku dan rate limit, disimpan sebagai environment secret (Tahap A).

  • scripts/dokploy-deploy.sh: memicu deploy tanpa membocorkan key ke ps, lalu menunggu /version sama dengan commit yang diharapkan.

  • deploy.yml dengan empat job: build (Trivy, SBOM Syft, push sha-<SHA>), deploy-dev, resolve (parent ke-2, wajib sukses di dev), dan promote (tag production hanya setelah approval).

  • Bukti: dev dan production menjalankan digest yang sama, dan approval yang ditolak membuat production nggak berubah.

Pipeline-nya sekarang jalan dari PR sampai production. Tapi gate yang keras punya masalah sendiri: bagaimana kalau ada CVE yang belum ada fix-nya atau temuan yang ternyata false positive, padahal fitur harus rilis? Di Bagian 9 saya bikin proses exception: setiap pengecualian wajib punya pemilik, alasan, dan tanggal kedaluwarsa, dan CI yang menegakkannya.

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.