Mengotomatiskan siklus hidup image dengan GitHub Actions: job yang berjalan di dalam container, workflow build-push yang terhubung ke registry, caching BuildKit lintas pipeline, perbandingan DinD vs DooD, serta workflow lengkap dengan scan Trivy dan SBOM sebelum image masuk produksi.

Setelah di episode 18 sebelumnya kita mengganti mesin build dengan BuildKit dan Buildx — build paralel, secret mount, cache lanjutan, dan image multi-platform — pada episode kali ini kita menyerahkan mesin itu ke tangan yang lebih tidak kenal lelah: pipeline CI/CD. Sejauh ini semua perintah kita eksekusi manual di terminal. Di dunia nyata, image tidak dibangun oleh manusia yang ingat membangun — image dibangun oleh automasi setiap kali kode berubah, di-scan, diuji, dan didorong ke registry, semua tanpa campur tangan. Itulah yang dimaksud dengan Continuous Integration untuk Docker, dan episode ini akan membangunnya di GitHub Actions.
Kenapa ini bukan sekadar kenyamanan? Karena manusia adalah variabel yang paling tidak bisa diandalkan dalam proses pengiriman software. Bayangkan tim dengan lima developer: tanpa pipeline, "siapa yang membangun image terakhir?" selalu dijawab dengan "saya lupa, coba cek history" — dan image yang di-deploy bisa saja berasal dari kode yang belum dites atau belum di-scan. Pipeline menghilangkan pertanyaan itu: setiap commit menjalani rangkaian yang sama, otomatis, dan hasilnya terdokumentasi. Tambahan lagi, dengan BuildKit yang sudah kita kuasai, pipeline yang sama bisa membangun image multi-platform dengan cache yang diimpor dari pipeline sebelumnya — membawa pembelajaran episode 18 ke dalam konteks yang sesungguhnya.
Di episode ini kita akan mengotomatiskan seluruh siklus: menjalankan job di dalam container, membangun dan mendorong image dengan workflow yang terhubung ke registry, mengoptimalkan cache BuildKit lintas pipeline, dan membedah satu pertanyaan yang membingungkan banyak orang — Docker-in-Docker vs Docker-outside-of-Docker — sebelum menutup dengan workflow lengkap yang mencakup scan dan SBOM. Siapkan repo GitHub dan image dari episode 18, karena kita akan merakit pipeline sungguhan.
Sebelum menulis YAML, sepakati alurnya. Alur klasik pipeline Docker di GitHub Actions:
push ke branch tertentu atau tag versi.docker service update — jembatan ke episode 20.Urutannya bukan kebetulan: test sebelum build mencegah image basi, scan sebelum push mencegah image buruk mendarat di registry (mengamankan supply chain, lanjutan episode 14 dan 18), dan tag yang benar memastikan rollback selalu mungkin.
GitHub Actions memungkinkan job berjalan di dalam container dengan menyebutkan image-nya. Ini adalah cara paling cepat mendapatkan environment reproducible — bukan "mesin runner yang kebetulan punya Node", melainkan persis image yang sama dengan produksi:
jobs:
test:
runs-on: ubuntu-latest
container:
image: node:20
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm testPerhatikan dua hal: kunci container menempatkan seluruh job di dalam image node:20 (versi image di-pin dengan tag — prinsip reproducibility), dan services meluncurkan kontainer pendamping (Postgres) yang sehat-terbantu oleh healthcheck (konsep episode 15 bekerja di sini juga). Test kini berjalan terhadap database sungguhan — bukan mock — tanpa menyentuh host runner. Inilah Docker sebagai environment, bukan sekadar artefak.
Sekarang bagian inti: membangun image dan mendorongnya. GitHub Actions punya action resmi Docker — docker/login-action untuk autentikasi dan docker/build-push-action untuk build+push yang memakai BuildKit secara native (persis mesin episode 18):
jobs:
build-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login ke GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxTiga keputusan penting di sini:
secrets.GITHUB_TOKEN adalah token yang dibuat otomatis oleh GitHub untuk setiap workflow — tidak perlu menyimpan token pribadi. Hanya permission packages: write yang ditambahkan di level job.${{ github.sha }} memberi tag immutable per commit (rollback selalu punya target), latest untuk kenyamanan. Pola lengkapnya — termasuk tag semver dari git tag — akan kita lihat di workflow final.cache-from/cache-to: type=gha menghubungkan BuildKit ke backend cache GitHub Actions. Pipeline berikutnya mengimpor semua layer yang tidak berubah — build kedua dan seterusnya jauh lebih cepat. Ini mengubah "pipeline yang selalu build dari nol" menjadi "pipeline yang lompat ke tahap yang berubah".Tip
Untuk project yang mengadopsi semantic versioning (ingat release.config.cjs di repo ini), pola tag terbaik adalah menggabungkan ref git: saat push ke main gunakan tag semver (refs/tags/v1.2.3 → 1.2.3), selain itu gunakan sha. Sederhananya: image yang bisa di-deploy harus bisa diidentifikasi ulang — tag yang tidak stabil (latest) tidak pernah cukup sendirian untuk produksi.
Kita sudah melihat type=gha. Mari letakkan dalam peta lengkap pilihan cache yang tersedia:
| Strategi | Cocok untuk | Kelebihan | Kekurangan |
|---|---|---|---|
type=gha | GitHub Actions | Cache tersimpan di backend GH, otomatis dibersihkan | Hanya untuk GH Actions |
type=registry | Semua CI, multi-mesin | Cache jadi bagian image di registry | Butuh push/pull ekstra |
| Docker Layer Caching (DLC) | Self-hosted runner | Layer di-reuse dari mesin yang sama | Hanya berlaku per mesin |
--cache-from lokal | Build berurutan di satu host | Gratis, tanpa konfigurasi | Tidak lintas mesin |
Poin penting untuk dipahami: Docker Layer Caching (DLC) adalah istilah yang sering disalahartikan. Secara teknis, layer caching selalu terjadi saat docker build dijalankan di mesin yang sama — BuildKit menyimpan hasil build sebelumnya. Di CI, DLC hanya menguntungkan jika runner self-hosted dan persisten (mesin yang sama dipakai berulang). Di GitHub-hosted runner, setiap job mendapat mesin baru yang bersih — di situlah type=gha atau type=registry menjadi wajib, karena merekalah yang membawa cache lintas mesin. Ini keputusan yang menentukan: workflow yang "selalu build dari nol" akan 3-5x lebih lambat daripada yang mengimpor cache.
Ketika sebuah job perlu menjalankan perintah docker sendiri — bukan sekadar memanggil action build-push — muncul pertanyaan klasik: di mana daemon Docker-nya? Dua jawaban yang dominan:
Docker-outside-of-Docker (DooD). Runner memakai daemon Docker host (socket /var/run/docker.sock di-mount ke container job). Keuntungannya sederhana dan cache daemon host bisa dipakai. Risikonya: semua container yang dijalankan job adalah tetangga container lain di host — tidak ada isolasi antara build yang berbahaya dengan mesin runner. Mem-mount Docker socket adalah memberikan akses root penuh ke host (ingat risiko grup docker di episode 0).
Docker-in-Docker (DinD). Job menjalankan daemon Docker di dalam container — lewat image docker:*-dind sebagai service. Setiap job punya daemon sendiri yang terisolasi. Keuntungannya isolasi penuh. Risikonya: daemon berjalan privileged (dibutuhkan untuk dind), dan setiap job memulai dari daemon kosong — tidak ada cache Docker (cache BuildKit di sini harus dikelola eksplisit).
jobs:
dind-example:
runs-on: ubuntu-latest
services:
docker:
image: docker:27-dind
options: >-
--privileged
steps:
- uses: actions/checkout@v4
- name: Jalankan perintah docker
run: |
docker build -t app:test .
docker run --rm app:test npm testMana yang direkomendasikan? Sebagian besar kasus, tidak perlu keduanya. docker/build-push-action sudah memakai BuildKit container tanpa DinD sama sekali — ia tidak butuh daemon Docker di dalam job; ia hanya butuh connect ke daemon host yang sudah ada di runner. DinD baru diperlukan ketika kalian benar-benar harus menjalankan CLI docker secara langsung di dalam container job (misalnya docker compose up untuk integration test), dan DooD baru masuk akal di runner self-hosted yang memang ingin memakai cache daemon. Aturan praktisnya:
build-push-action (BuildKit, tanpa DinD).docker compose / CLI di dalam job → DinD service (isolasi), atau DooD di self-hosted bila cache host lebih penting.--privileged bila bisa — ia melemahkan isolasi runner secara signifikan.Sekarang kita rakit semuanya menjadi satu workflow production-grade: test → build multi-platform → scan Trivy → push → SBOM. Jika scan menemukan kerentanan di atas ambang batas, pipeline gagal dan image tidak pernah sampai ke registry:
name: Docker Build & Publish
on:
push:
branches: [main, staging]
tags: ["v*"]
jobs:
build-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login ke GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
sbom: true
provenance: true
- name: Scan image dengan Trivy
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: 1
ignore-unfixed: true
- name: Upload hasil scan sebagai artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: trivy-sarif
path: trivy-results.sarif
- name: Setup GitHub CLI & verifikasi SBOM
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh attestation download \
--repo ${{ github.repository }} \
--predicate-type https://spdx.dev/Document \
-O sbom.spdx.json
test -s sbom.spdx.json && echo "SBOM terverifikasi"Bedah workflow final ini — ia merangkum tiga episode terakhir sekaligus:
${{ github.sha }} (immutable per commit) + latest; untuk push tags: ["v*"], semver dari git tag akan menjadi tag image (perlu penyesuaian nama via ekspresi refs/tags/v...). Proposisi kuncinya tetap: setiap image dapat diidentifikasi kembali.sbom: true + provenance: true memanfaatkan fitur BuildKit dari episode 18 — SBOM dan jejak bangunan dikirim ke registry bersama image, tanpa action tambahan.exit-code: 1 mengubah scan dari laporan menjadi gerbang: satu kerentanan HIGH/CRITICAL yang unfixable menghentikan pipeline. ignore-unfixed: true mencegah kegagalan pada kerentanan yang belum ada perbaikannya (strategi umum agar pipeline tidak terus-terusan merah).gh attestation download — verifikasi bahwa SBOM yang kita klaim benar-benar terpasang ke image di registry (menutup lingkaran supply chain dari episode 14).Secret bocor ke env build. Secret yang tersimpan di secrets.* hanya boleh dipakai sebagai build arg via --secret (episode 18), bukan diekspor ke env lalu direferensikan dalam layer image. Sekali token masuk ARG/ENV, ia mengendap di image selamanya.
Tag yang tidak stabil. Menandai setiap push dengan latest saja membuat dua image berbeda menyandang nama sama — tidak ada cara membedakan, tidak ada rollback yang jelas. Selalu sertakan sha atau semver di samping latest.
Mengabaikan cache. Pipeline tanpa cache-from/cache-to adalah build dari nol setiap kali — 3-5x lebih lambat dan membuang kuota runner. Gunakan type=gha (hosted) atau type=registry (multi-mesin).
DooD sembarangan di runner bersama. Mounting Docker socket di runner hosted memberi akses root ke host runner — dan job lain di runner itu. Untuk workload mandiri, lebih aman DinD service atau, lebih baik lagi, build-push-action yang tidak butuh keduanya.
Scan setelah push. Jangan men-scan image yang sudah terlanjur mendarat di registry dan berlabel latest — ganti urutannya: build → scan → (lolos) → push. Workflow di atas menjaga urutan ini dengan benar.
Pada episode 19 ini kita telah mengotomatiskan siklus hidup image: menjalankan job di dalam kontainer (container: node:20) dengan service pendamping, membangun & mendorong image lewat docker/login-action dan docker/build-push-action yang memakai BuildKit secara native, mengoptimalkan pipeline dengan cache lintas mesin (type=gha/type=registry dan memahami posisi DLC di self-hosted), membedah DinD vs DooD beserta risiko --privileged dan cache yang hilang, serta merakit workflow lengkap yang menutup dengan scan Trivy dan verifikasi SBOM sebelum image diizinkan masuk registry.
Inti yang harus kalian bawa:
build-push-action = BuildKit tanpa DinD; jangan --privileged bila bisa dihindari.type=gha) mengubah build dari nol menjadi lompat-ke-berubah.Sekarang kalian punya image yang dibangun, dipindai, dan didorong secara otomatis. Persoalan terakhir yang tersisa di perjalanan ini: menghantarkan image itu ke pengguna — publikasi yang benar-benar production-grade. Di episode 20 selanjutnya kita akan menutup jembatan itu: Production Deployment & Reverse Proxy Integration — satu IP publik untuk banyak service, NGINX vs Traefik dengan TLS otomatis, dan strategi zero-downtime deployment. Semua fondasi sudah kalian bangun dari episode 0 sampai 19; tinggal kita rakit menjadi arsitektur yang utuh. Sampai jumpa di episode 20!