Skip to main content

Image Pipeline: GitHub Actions, Kaniko, and DOCR

This chapter builds the supply line that turns a commit into a runnable image. You'll create the DigitalOcean Container Registry, learn why Kaniko is the right build tool inside a Kubernetes-aware CI pipeline, and put a GitHub Actions workflow in place that produces immutable, tagged images on every push to master.

Prerequisites

  • Both clusters reachable with their domains live and TLS working (Part 4).

  • The Express app source lives in a GitHub repository (expressapp). If you haven't pushed it yet:

    cd ~/code/expressapp
    git init -b master
    gh repo create expressapp --source=. --private --remote=origin --push

Step 1 — Create the DigitalOcean Container Registry

A container registry is the file server for images: builds push images there, and clusters pull from there to run them. DOCR (DigitalOcean Container Registry) is DO's hosted registry; it's regional and integrates cleanly with doctl. The starter tier ($0 for the first 500 MB) is plenty for the course.

doctl registry create k8slearn-${USER} --subscription-tier basic --region fra1
doctl registry get
# Name Endpoint Region ExpiryDate
# k8slearn-yourname registry.digitalocean.com/k8slearn-... fra1 <none>
REGISTRY=$(doctl registry get --format Endpoint --no-header)
export REGISTRY
echo "$REGISTRY"
# registry.digitalocean.com/k8slearn-yourname

Generate a read/write Docker config for CI (used to push images from GitHub Actions):

doctl registry docker-config --read-write \
--expiry-seconds 31536000 \
> /tmp/dockerconfig-rw.json

Generate a separate read-only Docker config (used by workloads in each cluster to pull):

doctl registry docker-config \
--expiry-seconds 31536000 \
> /tmp/dockerconfig-ro.json

Store the read/write config and the registry URL as repository-level GitHub secrets and variables in the app repo. Variables aren't encrypted and are fine for things you'd echo to a log; secrets are encrypted and used for the dockerconfig:

gh secret set DOCR_DOCKERCONFIGJSON < /tmp/dockerconfig-rw.json
gh variable set DOCR_REGISTRY --body "$REGISTRY"
# DO NOT delete /tmp/dockerconfig-ro.json yet — Step 5 still needs it.
# DO delete the read/write file:
rm -f /tmp/dockerconfig-rw.json

(shred is GNU-only; rm -f is portable. On a real workstation you can also overwrite with dd if=/dev/urandom first if you're paranoid about disk forensics.)

Step 2 — Why Kaniko, and why the Chainguard fork

A traditional CI build does docker build, which talks to a Docker daemon. To do that inside a container-based CI runner you either mount /var/run/docker.sock (dangerous; the container can break out) or run Docker-in-Docker as privileged (also dangerous, plus complicated). buildx improves on this but still expects a builder backend.

Kaniko builds images entirely in userspace from inside a container. No daemon, no privileged mode, no socket mount. It's the right tool for CI on Kubernetes-aware infrastructure.

The original GoogleContainerTools/kaniko repository was archived on 2025-06-03. Chainguard maintains an active fork at cgr.dev/chainguard/kaniko. The course pins that image. If you read older tutorials that reference gcr.io/kaniko-project/executor, treat them as historical and use the Chainguard image for new pipelines.

Step 3 — Repository layout for the image build

By the end of this chapter the repo looks like this:

expressapp/
app/
server.js
package.json
Dockerfile
.github/
workflows/
build.yml # this chapter

The Dockerfile from Part 2 is what Kaniko will build.

Step 4 — The build workflow

A GitHub Actions workflow is a YAML file in .github/workflows/ that GitHub runs in response to events (a push, a PR, a manual trigger, etc.). Each workflow has one or more jobs that run on a fresh VM (runs-on: ubuntu-latest), and each job runs an ordered list of steps — either shell commands or pre-packaged actions like actions/checkout@v4. workflow_dispatch: {} adds a "Run workflow" button so you can fire it by hand from the GitHub UI.

Create .github/workflows/build.yml:

name: build

on:
push:
branches: [master]
workflow_dispatch: {}

permissions:
contents: read

jobs:
image:
runs-on: ubuntu-latest
env:
REGISTRY: ${{ vars.DOCR_REGISTRY }}
IMAGE: expressapp
steps:
- uses: actions/checkout@v4

- name: Resolve tags
id: tags
run: |
SHA="${GITHUB_SHA}"
BRANCH="${GITHUB_REF_NAME//\//-}"
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
echo "image=${REGISTRY}/${IMAGE}" >> "$GITHUB_OUTPUT"

- name: Write Docker config
run: |
mkdir -p kaniko
printf '%s' '${{ secrets.DOCR_DOCKERCONFIGJSON }}' > kaniko/config.json

- name: Build and push with Kaniko (Chainguard)
uses: docker://cgr.dev/chainguard/kaniko:latest
with:
args: >
--context=dir://${{ github.workspace }}/app
--dockerfile=Dockerfile
--destination=${{ steps.tags.outputs.image }}:${{ steps.tags.outputs.sha }}
--destination=${{ steps.tags.outputs.image }}:${{ steps.tags.outputs.branch }}
--cache=true
--cache-repo=${{ steps.tags.outputs.image }}/cache
--reproducible
--use-new-run
--snapshot-mode=redo
env:
DOCKER_CONFIG: ${{ github.workspace }}/kaniko

- name: Show resulting tags
run: |
echo "Image: ${{ steps.tags.outputs.image }}"
echo "Tags: ${{ steps.tags.outputs.sha }}, ${{ steps.tags.outputs.branch }}"

Notes:

  • --destination is passed twice on purpose. The SHA is the immutable identity production references; the branch name is a convenience pointer.
  • --cache-repo puts intermediate layers in a sibling repo (expressapp/cache) so subsequent builds reuse them.
  • --reproducible strips timestamps so identical source produces identical digests.
  • The job runs as a container action; the Kaniko image needs Docker config at $DOCKER_CONFIG/config.json to authenticate to DOCR, which is why we write kaniko/config.json first.

Commit and push:

git add .github/workflows/build.yml
git commit -m "ci: build image with Chainguard Kaniko, push to DOCR"
git push origin master

Watch the run:

gh run watch

When it finishes, the SHA tag should be visible in DOCR:

doctl registry repository list-tags expressapp
# Tag Updated At ...
# <sha> ...
# master ...

If the build fails with error checking push permissions, the Docker config secret is wrong or the token has expired. Regenerate it with doctl registry docker-config --read-write and update DOCR_DOCKERCONFIGJSON.

Step 5 — Create the imagePullSecret in each cluster

An imagePullSecret is the Kubernetes way of handing a private-registry credential to the kubelet. It's an ordinary Secret with type: kubernetes.io/dockerconfigjson, holding the same ~/.docker/config.json shape Docker writes locally. Workloads can't pull from a private DOCR without one. Create one Secret per cluster, in each environment's namespace, using the read-only Docker config from Step 1:

test -f /tmp/dockerconfig-ro.json || { echo "regenerate with: doctl registry docker-config > /tmp/dockerconfig-ro.json"; exit 1; }

for ctx in do-staging do-prod; do
ENV=${ctx#do-}
kubectl --context "$ctx" -n "expressapp-${ENV}" \
create secret generic docr \
--type=kubernetes.io/dockerconfigjson \
--from-file=.dockerconfigjson=/tmp/dockerconfig-ro.json
done

rm -f /tmp/dockerconfig-ro.json

The Express Deployment doesn't exist on either cluster yet; it's created in Part 7. That's fine. The imagePullSecret is just a regular Secret named docr that the Deployment will reference once it lands.

Step 6 — Verify cluster pulls with a throwaway Job

A Job is the Kubernetes object for run-to-completion workloads, the opposite of a Deployment. It starts a pod (or N pods in parallel), waits for it to exit, and records success or failure instead of restarting forever. Perfect for one-off checks.

Confirm the cluster can actually pull from DOCR using the docr Secret, without assuming a Deployment exists. Run a one-off Job that pulls the freshly built image and exits cleanly. This is the end-to-end check that should pass before you ever try to deploy the real app.

NEW_SHA=$(gh run list --workflow build.yml --limit 1 --json headSha --jq '.[0].headSha')

for ctx in do-staging do-prod; do
ENV=${ctx#do-}
cat <<EOF | kubectl --context "$ctx" apply -f -
apiVersion: batch/v1
kind: Job
metadata:
generateName: pull-smoke-
namespace: expressapp-${ENV}
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
imagePullSecrets:
- name: docr
containers:
- name: probe
image: ${REGISTRY}/expressapp:${NEW_SHA}
command: ["node","-e","console.log('image pulled OK');"]
EOF
done

Wait for the Job to finish on each cluster and confirm it succeeded:

for ctx in do-staging do-prod; do
ENV=${ctx#do-}
echo "=== ${ctx} ==="
kubectl --context "$ctx" -n "expressapp-${ENV}" wait \
--for=condition=complete --timeout=120s job -l job-name 2>/dev/null \
|| kubectl --context "$ctx" -n "expressapp-${ENV}" get jobs
kubectl --context "$ctx" -n "expressapp-${ENV}" logs job/$(kubectl --context "$ctx" -n "expressapp-${ENV}" get jobs -o jsonpath='{.items[-1].metadata.name}')
done

Expected: every cluster prints image pulled OK.

If you see ErrImagePull or ImagePullBackOff:

kubectl --context do-staging -n expressapp-staging describe pod -l job-name | grep -A5 Events

The most common message is no basic auth credentials, meaning the docr Secret in that namespace is missing or has stale credentials. Re-run Step 5 to regenerate it.

Clean up the throwaway Jobs:

for ctx in do-staging do-prod; do
ENV=${ctx#do-}
kubectl --context "$ctx" -n "expressapp-${ENV}" delete jobs --all
done

Step 7 — Kaniko caching, briefly

Kaniko has two cache layers:

  • Base image cache (--cache=true --cache-repo=...): each RUN layer is pushed as its own image tag in the cache repo. Subsequent builds pull cached layers instead of re-running the commands.
  • --cache-copy-layers: also cache COPY operations whose source hasn't changed.

For the Express app the dominant cost is npm ci. Caching node_modules between runs cuts ~30 seconds off each build. Watch the Kaniko logs for Using caching version of cmd: RUN npm ci.

If a cache layer gets stuck on a broken dependency, blow it away:

doctl registry repository delete-manifest expressapp/cache <sha-of-bad-cache-layer> --force

Step 8 — Tagging strategy

The course uses two tags per build:

TagMutabilityUsed by
<git-sha> (e.g. 9f3a…)ImmutableProduction deploys; the SHA is what kubectl set image and the Kustomize images: field point to
master (or other branch name)MutablePull-from-tag convenience for ad-hoc testing

Production never deploys a branch tag. The deploy-prod.yml workflow in Part 7 keys off a v* release tag whose tag-on-master provenance has been verified (the provenance check itself is defined in Part 6), and resolves the tag to a SHA before applying.

Step 9 — Cleanup hygiene

DOCR starter tier limits storage. Set a garbage-collection policy that keeps the most recent 30 tags per repo:

doctl registry garbage-collection start --include-untagged-manifests
# wait until: doctl registry garbage-collection get-active reports the GC finished

You can also schedule it from the DO control panel under Container Registry → Settings → Garbage Collection.

Completion signal

A push to master produces a new image in DOCR tagged with the commit SHA and the branch name. doctl registry repository list-tags expressapp shows both tags. On each cluster, the throwaway pull-smoke Job in expressapp-staging and expressapp-prod completes and prints image pulled OK, proving the docr imagePullSecret and DOCR credentials work end to end without any Deployment yet existing.

You're ready for 06. CI Gates and PR Status Checks.