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/expressappgit init -b mastergh 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:
--destinationis passed twice on purpose. The SHA is the immutable identity production references; the branch name is a convenience pointer.--cache-repoputs intermediate layers in a sibling repo (expressapp/cache) so subsequent builds reuse them.--reproduciblestrips timestamps so identical source produces identical digests.- The job runs as a container action; the Kaniko image needs Docker config at
$DOCKER_CONFIG/config.jsonto authenticate to DOCR, which is why we writekaniko/config.jsonfirst.
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=...): eachRUNlayer 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 cacheCOPYoperations 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:
| Tag | Mutability | Used by |
|---|---|---|
<git-sha> (e.g. 9f3a…) | Immutable | Production deploys; the SHA is what kubectl set image and the Kustomize images: field point to |
master (or other branch name) | Mutable | Pull-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.