Automating the image lifecycle with GitHub Actions: jobs running inside containers, a build-push workflow connected to a registry, BuildKit caching across pipelines, the DinD vs DooD comparison, and a complete workflow with Trivy scanning and SBOM before images reach production.

After replacing the build machine with BuildKit and Buildx in episode 18 — parallel builds, secret mounts, advanced caching, and multi-platform images — in this episode we hand that machine over to a more tireless set of hands: the CI/CD pipeline. So far all our commands have been executed manually in a terminal. In the real world, images aren't built by humans who remember to build — images are built by automation every time code changes, scanned, tested, and pushed to a registry, all without intervention. That's what Continuous Integration means for Docker, and this episode builds it on GitHub Actions.
Why isn't this just convenience? Because humans are the least reliable variable in the software delivery process. Imagine a team with five developers: without a pipeline, "who built the image last?" is always answered with "I forgot, check the history" — and the deployed image could come from code that was never tested or scanned. A pipeline eliminates that question: every commit goes through the same series, automatically, with documented results. Additionally, with BuildKit we've already mastered, the same pipeline can build multi-platform images with cache imported from the previous pipeline — bringing episode 18's lessons into their real context.
In this episode we'll automate the entire lifecycle: running jobs inside containers, building and pushing images with a workflow connected to a registry, optimizing BuildKit cache across pipelines, and dissecting one question that confuses many people — Docker-in-Docker vs Docker-outside-of-Docker — before closing with a complete workflow that includes scanning and SBOM. Get your GitHub repo and the episode 18 image ready, because we're assembling a real pipeline.
Before writing YAML, let's agree on the flow. The classic Docker pipeline flow in GitHub Actions:
push to a specific branch or a version tag.docker service update — the bridge to episode 20.The order isn't accidental: testing before building prevents stale images, scanning before pushing prevents bad images landing in the registry (securing the supply chain, continuing episodes 14 and 18), and correct tags ensure rollback is always possible.
GitHub Actions allows a job to run inside a container by naming its image. This is the fastest way to get a reproducible environment — not "a runner machine that happens to have Node", but the exact same image as production:
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 testNote two things: the container key places the entire job inside the node:20 image (the image version is pinned with a tag — the reproducibility principle), and services launches a companion container (Postgres) made healthy with a healthcheck (episode 15's concepts work here too). Tests now run against a real database — not a mock — without touching the host runner. This is Docker as the environment, not just an artifact.
Now the core part: building the image and pushing it. GitHub Actions has official Docker actions — docker/login-action for authentication and docker/build-push-action for build+push using BuildKit natively (exactly the episode 18 machine):
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=maxThree important decisions here:
secrets.GITHUB_TOKEN is a token GitHub automatically creates for every workflow — no need to store a personal token. Only the packages: write permission is added at the job level.${{ github.sha }} gives an immutable tag per commit (rollback always has a target), latest for convenience. The full pattern — including semver tags from git tags — will appear in the final workflow.cache-from/cache-to: type=gha connects BuildKit to the GitHub Actions cache backend. The next pipeline imports all unchanged layers — the second and subsequent builds are much faster. This turns "a pipeline that always builds from scratch" into "a pipeline that jumps to what changed".Tip
For projects adopting semantic versioning (remember the release.config.cjs in this repo), the best tag pattern combines git refs: on a push to main use a semver tag (refs/tags/v1.2.3 → 1.2.3), otherwise use the sha. Simply put: a deployable image must be re-identifiable — an unstable tag (latest) is never enough alone for production.
We've seen type=gha. Let's place it in a full map of available cache options:
| Strategy | Suitable for | Advantages | Disadvantages |
|---|---|---|---|
type=gha | GitHub Actions | Cache stored on the GH backend, auto-cleaned | Only for GH Actions |
type=registry | All CI, multi-machine | Cache becomes part of the image in the registry | Needs extra push/pull |
| Docker Layer Caching (DLC) | Self-hosted runners | Layers reused from the same machine | Only applies per machine |
Local --cache-from | Sequential builds on one host | Free, no configuration | Not cross-machine |
The key point to understand: Docker Layer Caching (DLC) is a term that's often misunderstood. Technically, layer caching always happens when docker build runs on the same machine — BuildKit stores previous build results. In CI, DLC only helps if the runner is self-hosted and persistent (the same machine used repeatedly). On GitHub-hosted runners, every job gets a fresh clean machine — that's where type=gha or type=registry become mandatory, because they carry cache across machines. This is a decisive choice: a workflow that "always builds from scratch" is 3-5x slower than one that imports cache.
When a job needs to run docker commands itself — not just call the build-push action — the classic question arises: where is the Docker daemon? Two dominant answers:
Docker-outside-of-Docker (DooD). The runner uses the host's Docker daemon (the socket /var/run/docker.sock is mounted into the job container). The advantage: simple, and the host daemon's cache can be used. The risk: every container the job runs is a neighbor of other containers on the host — no isolation between a dangerous build and the runner machine. Mounting the Docker socket is giving full root access to the host (remember the docker group risk in episode 0).
Docker-in-Docker (DinD). The job runs a Docker daemon inside the container — via the docker:*-dind image as a service. Every job has its own isolated daemon. The advantage: full isolation. The risk: the daemon runs privileged (required for dind), and every job starts from an empty daemon — no Docker cache (BuildKit cache here must be managed explicitly).
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 testWhich is recommended? In most cases, neither. docker/build-push-action already uses a BuildKit container without any DinD — it doesn't need a Docker daemon inside the job; it just needs to connect to the host daemon that's already on the runner. DinD is only needed when you really must run the docker CLI directly inside a job container (e.g. docker compose up for integration tests), and DooD only makes sense on self-hosted runners that genuinely want to use the daemon cache. The rule of thumb:
build-push-action (BuildKit, no DinD).docker compose / CLI inside the job → DinD service (isolation), or DooD on self-hosted when the host cache matters more.--privileged when possible — it significantly weakens runner isolation.Now let's assemble everything into one production-grade workflow: test → multi-platform build → Trivy scan → push → SBOM. If the scan finds vulnerabilities above the threshold, the pipeline fails and the image never reaches the 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"Dissecting this final workflow — it summarizes the last three episodes at once:
${{ github.sha }} (immutable per commit) + latest; for tags: ["v*"] pushes, the semver from the git tag becomes the image tag (needing a name adjustment via the refs/tags/v... expression). The key proposition remains: every image can be re-identified.sbom: true + provenance: true leverage the BuildKit features from episode 18 — SBOM and build traces are pushed to the registry alongside the image, with no extra actions.exit-code: 1 turns scanning from a report into a gate: a single unfixable HIGH/CRITICAL vulnerability stops the pipeline. ignore-unfixed: true prevents failures on vulnerabilities with no remediation yet (a common strategy so pipelines don't stay red forever).gh attestation download — verifying that the SBOM we claim is really attached to the image in the registry (closing the supply chain loop from episode 14).Secrets leaking into the build env. Secrets stored in secrets.* should only be used as a build arg via --secret (episode 18), not exported to env and then referenced in an image layer. Once a token enters ARG/ENV, it settles in the image forever.
Unstable tags. Tagging every push with only latest means two different images carry the same name — no way to tell them apart, no clear rollback. Always include sha or semver alongside latest.
Ignoring cache. A pipeline without cache-from/cache-to is a from-scratch build every time — 3-5x slower and wasting runner quota. Use type=gha (hosted) or type=registry (multi-machine).
Careless DooD on a shared runner. Mounting the Docker socket on a hosted runner gives root access to the host runner — and to other jobs on that runner. For standalone workloads, a DinD service is safer or, even better, build-push-action which needs neither.
Scanning after pushing. Don't scan an image that's already landed in the registry and been labeled latest — change the order: build → scan → (pass) → push. The workflow above keeps this order correctly.
In this episode 19 we automated the image lifecycle: running jobs inside containers (container: node:20) with companion services, building & pushing images via docker/login-action and docker/build-push-action using BuildKit natively, optimizing the pipeline with cross-machine cache (type=gha/type=registry and understanding DLC's place on self-hosted runners), dissecting DinD vs DooD along with the risks of --privileged and lost cache, and assembling a complete workflow that closes with Trivy scanning and SBOM verification before the image is allowed into the registry.
Core takeaways:
build-push-action = BuildKit without DinD; don't use --privileged when it can be avoided.type=gha) turns a from-scratch build into jump-to-what-changed.Now you have images that are built, scanned, and pushed automatically. The last remaining question on this journey: delivering that image to users — truly production-grade publishing. In the next episode, episode 20, we'll close that bridge: Production Deployment & Reverse Proxy Integration — one public IP for many services, NGINX vs Traefik with automatic TLS, and zero-downtime deployment strategies. You've built every foundation from episode 0 to 19; now we just assemble them into a complete architecture. See you in episode 20!