🔄 Update — June 2026. The cross-repo build for Tapaz now runs on Gitea Actions (and
prsm-studiohas become Gitea-primary); the digest is patched into thek3s-homelabrepo hosted on Gitea. The cross-repo mechanics described here remain valid — only the forge and the runner change. See Self-hosted CI with Gitea Actions.
The starting point: a separate source repo
The cluster’s earlier apps (blog, silex, n8n…) all have their manifests AND their Dockerfile committed in the k3s-homelab repo. Very convenient for a demo, but not the norm as soon as an app has a real life of its own elsewhere.
tapaz is the first one to invert the model:
- Source code in
YellowBeanie/prsm-studio(a React/Vite/TypeScript site built separately) - Kubernetes manifests in
YellowBeanie/k3s-homelab/tapaz/ - Image on
ghcr.io/yellowbeanie/tapaz, built by the source repo’s CI
Which raises the question: how do you keep GitOps pure (pinned digest, git traceability, git revert rollback) without turning every release into a manual “commit source → note the digest → edit the manifest → push homelab” cha-cha-cha?
Quick framing
The source repo produces the image. The homelab repo consumes the image. ArgoCD watches the homelab repo. The only file linking the two is k3s-homelab/tapaz/deployment.yaml, on this line:
image: ghcr.io/yellowbeanie/tapaz@sha256:<digest>
The whole question boils down to: who rewrites that line after each build?
Three schools of thought:
- Mutable
:latest+imagePullPolicy: Always— simple, but you lose the exact record of “which image was running when”, and rollback becomes fuzzy. - ArgoCD Image Updater — a controller inside the cluster that watches GHCR and patches the manifests on its own. Elegant, but it adds one more component to maintain, to monitor, and to reconfigure when something goes wrong.
- Cross-repo CI — the source repo’s workflow clones the homelab repo, patches it with
sed, commits, pushes. Zero cluster components; the pipeline does all the work.
I went with option 3: everything already lives in GitHub Actions, so I might as well push it one step further.
The extended workflow
The build-image.yml workflow on the prsm-studio side now does three things: a multi-arch build (the cluster is 100% ARM64 Raspberry Pi, a lesson learned from earlier episodes), a push to GHCR, and a digest bump in the homelab repo.
name: build-image
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push
id: push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/yellowbeanie/tapaz:latest
ghcr.io/yellowbeanie/tapaz:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Bump digest in k3s-homelab
env:
DIGEST: ${{ steps.push.outputs.digest }}
SRC_SHA: ${{ github.sha }}
HOMELAB_REPO_TOKEN: ${{ secrets.HOMELAB_REPO_TOKEN }}
run: |
git clone --depth 1 --branch actual-state \
https://x-access-token:${HOMELAB_REPO_TOKEN}@github.com/YellowBeanie/k3s-homelab.git homelab
cd homelab
sed -i -E "s|(image: ghcr\.io/yellowbeanie/tapaz@)sha256:[a-f0-9]+|\1${DIGEST}|" tapaz/deployment.yaml
if git diff --quiet; then
echo "Digest already up to date — nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add tapaz/deployment.yaml
git commit -m "tapaz: bump image to ${DIGEST} (prsm-studio@${SRC_SHA})"
git push origin actual-state
Three details that matter:
git diff --quiet: a rebuild producing exactly the same digest (GHA cache hit, identical content) generates no commit at all. The CI stays idempotent.sed -E "s|(image: ghcr\.io/yellowbeanie/tapaz@)sha256:[a-f0-9]+|\1${DIGEST}|": a replacement strictly anchored on theimage: ghcr.io/yellowbeanie/tapaz@prefix, to avoid touching other fields should the YAML evolve.github-actions[bot]as the author: the homelab history makes it obvious that a commit comes from the cross-repo CI rather than from a human. That matters for grepping and for future blames.
The PAT on the secret side
To allow the runner to push to k3s-homelab, you need a PAT (Personal Access Token) with write scope on that repo. The workflow’s native GITHUB_TOKEN is not enough — it is confined to the repo running it (prsm-studio).
Creating it (to be refined into a fine-grained token later if desired):
gh secret set HOMELAB_REPO_TOKEN \
-R YellowBeanie/prsm-studio \
--body "$PAT_VALUE"
To harden it: create a fine-grained PAT with only Contents: Read and write on YellowBeanie/k3s-homelab, and nothing else. The minimal scope protects against a compromised runner trying to reach anything else.
The full cycle in practice
1. git push origin main (on prsm-studio)
2. CI build-image triggers
├─ build amd64 + arm64 (~1m15s with cache, ~3-4 min without)
├─ push GHCR ghcr.io/yellowbeanie/tapaz:<sha>
└─ bump digest in k3s-homelab/tapaz/deployment.yaml
└─ git push origin actual-state (on k3s-homelab)
3. ArgoCD detects the homelab commit and syncs (~30s)
4. The tapaz pod pulls the new image and restarts (~10s)
From the initial git push to a pod running on the cluster: roughly 2-3 minutes, without a single extra command to type.
HOMELAB_REPO_TOKEN. ArgoCD ne watche que k3s-homelab —
il ignore totalement le repo source.
The only thread between the two repos is the cross-repo commit that patches the digest,
authorized by the HOMELAB_REPO_TOKEN PAT. ArgoCD only watches k3s-homelab —
it completely ignores the source repo.
Bonus: sealing the ghcr-pull SealedSecret offline
The GHCR image is private (and I would rather keep it that way — no desire to expose even the JS bundles of a site before it is published). So the tapaz namespace needs an imagePullSecret, itself committed as a SealedSecret.
Except that, when I wanted to create it, I had no kubectl access to the cluster from the machine I was working on. The classic kubeseal workflow goes through kubeseal --fetch-cert, which assumes a connection to the cluster.
The way around it — an under-documented trick: kubeseal accepts --cert <file.pem> and encrypts 100% offline. The controller’s public key, being public, is risk-free to commit into the repo. And it is already there: docs/sealed-secrets/sealed-secrets-pub.pem.
# Generate the docker-registry Secret as YAML "by hand"
USER=YellowBeanie
TOKEN=ghp_xxxxxxxx # PAT with read:packages
AUTH=$(printf '%s:%s' "$USER" "$TOKEN" | base64)
DOCKERCFG=$(printf '{"auths":{"ghcr.io":{"username":"%s","password":"%s","auth":"%s"}}}' \
"$USER" "$TOKEN" "$AUTH" | base64 -w0)
cat <<EOF > /tmp/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: ghcr-pull
namespace: tapaz
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: ${DOCKERCFG}
EOF
# Seal it offline
kubeseal --cert docs/sealed-secrets/sealed-secrets-pub.pem \
--format yaml \
--scope strict \
< /tmp/secret.yaml \
> tapaz/sealedsecret-ghcr-pull.yaml
rm /tmp/secret.yaml # never commit the plaintext secret
The resulting SealedSecret is strictly identical to what the kubeseal --fetch-cert workflow would have produced. strict scope = it can only be decrypted in the tapaz namespace, for the name ghcr-pull. No risk of it being read anywhere else if the manifest leaks.
Why it scales
The pattern described here is not specific to Tapaz. The day another app shows up with its own separate source repo, you copy the “Bump digest” step, change the sed target and the PAT secret, and you are done. No new controller to learn, no webhook to configure, no polling to tune. Just git and sed inside a runner.
It is also very observable: every automatic bump shows up under github-actions[bot] in the homelab history. A git log --author github-actions tapaz/ gives you the timeline of successive releases in two seconds. And rolling back to an earlier version is nothing more than a git revert <commit>.
Lessons learned
- Before reaching for a controller (Image Updater, Flux image automation, etc.), ask yourself whether a CI step would do. For a 15-app homelab, operational simplicity almost always wins.
- Committing the sealed-secrets public key into
docs/sealed-secrets/sealed-secrets-pub.pemunlocks offline sealing. It is a micro-decision that pays off the moment you are on a machine without kubectl, or onboarding someone else onto the secrets workflow. - Multi-arch is not optional on a Pi cluster. The
platforms: linux/amd64,linux/arm64reflex must be part of the default workflow, otherwiseexec format erroris guaranteed. - Bot author != human in the history. Giving CI commits a
github-actions[bot]@users.noreply.github.comemail avoids confusion six months later when you are trying to work out who touched what.
Tapaz now runs fully autonomously: a push to prsm-studio becomes a freshly deployed pod on the cluster in under three minutes. And the k3s-homelab repo remains the single source of truth for the desired state, as the GitOps contract requires.