user@homelab:~/docs$ cat gitops.md

CI/CD and GitOps

git push → GitHub Actions → GHCR → Flux → k3s: deployments with no access into the cluster from outside

# contents

How deployment works

Deployment flow: git push → GitHub Actions → GHCR → Flux → k3s → Cloudflare
Click to open the diagram in full size
  1. Mac: a change in the repository, git commit and git push.
  2. GitHub Actions: builds the site image, pushes it to GHCR (GitHub Container Registry) and commits the new tag to k8s/apps/homelab-docs/kustomization.yaml.
  3. Flux in the cluster checks the repository every minute. It pulls the new commit and applies it to the cluster.
  4. Kubernetes pulls the new image from the private GHCR and replaces the pod (rolling update, no downtime).
  5. Cloudflare Tunnel forwards internet traffic to Traefik, which routes it to the site.
StageTypical time
GitHub Actions build (cached)30–60 s
Flux notices the new commitup to 1 min
pod rolling update10–20 s
from git push to live~1.5–3 min

Why GitOps (pull model)

Push (CI deploys into the cluster)Pull (GitOps, Flux)
Who changes the clustera CI pipeline from outsidean agent inside the cluster
Access into the cluster from outsideCI needs a kubeconfig or VPNnone
Open portsoftennone, Flux only pulls
Source of truthwhatever the last pipeline run didgit: what is in git is what runs
Manual change in the clusterstays until someone reverts itFlux reverts it on the next sync
Rollbackre-run an older pipelinegit revert

For a homelab behind NAT without a public IP, the pull model is the natural choice. GitHub cannot see into the home network, and it does not need to.

Repository

Private homelab repository on GitHub:

text
homelab/
├── .github/workflows/docs-site.yml   # CI: build → GHCR → bump tag
├── docs-site/                        # Hugo site (CS/EN), Dockerfile, nginx
├── k8s/
│   ├── apps/homelab-docs/            # Namespace, Deployment, Service, Ingress, kustomization
│   └── infra/cloudflared/            # Cloudflare Tunnel connector
└── clusters/homelab/
    ├── flux-system/                  # Flux components (generated by bootstrap)
    ├── infra.yaml                    # Flux Kustomization → ./k8s/infra
    └── apps.yaml                     # Flux Kustomization → ./k8s/apps (dependsOn: infra)

CI: GitHub Actions

The .github/workflows/docs-site.yml workflow runs on a push to main when something in docs-site/ or the workflow itself changes:

StepWhat it does
Checkoutfetches the repository
Compute image name and tagghcr.io/<owner>/homelab-docs, tag YYYYMMDD-<run number>-<sha>
Log in to GHCRuses the built-in GITHUB_TOKEN, no custom token
Build and pushmulti-stage build (Hugo → nginx), cached in GitHub Actions; tag and commit passed as build args and shown in the site footer
Bump image in kustomizationsed writes the new newName/newTag, commits as github-actions[bot] and pushes
  • The tag carries provenance: 20261029-2-bd47ef2 = date, run number and the commit the image was built from.
  • No loop: a push made with GITHUB_TOKEN does not trigger other workflows, and a change in k8s/ does not match the paths filter anyway.
  • Least privilege: contents: write (tag commit) and packages: write (image push), nothing more.
  • Action versions: actions/checkout@v5, docker/login-action@v4, docker/setup-buildx-action@v4, docker/build-push-action@v7, all on Node.js 24.

Flux

Install the CLI on the server and check prerequisites:

shell @ server
curl -s https://fluxcd.io/install.sh | sudo bash
flux check --pre

Bootstrap installs Flux into the cluster and connects it to the repository. It needs a one-off fine-grained token (only the homelab repository, Administration and Contents: Read and write, 7-day expiry). The token is read without echoing; run the read line on its own:

shell @ server
read -rsp "GitHub token: " GITHUB_TOKEN; echo
shell @ server
export GITHUB_TOKEN
flux bootstrap github --owner=<github-user> --repository=homelab --branch=main --path=clusters/homelab --personal
unset GITHUB_TOKEN

Bootstrap:

  • installs the controllers into flux-system (source, kustomize, helm, notification),
  • creates a read-only deploy key in the repository, which Flux uses from then on,
  • pushes its own manifests to clusters/homelab/flux-system/.

The token is deleted after bootstrap. Flux does not need it for day-to-day operation.

Kustomizations

yaml @ git
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: apps
  namespace: flux-system
spec:
  dependsOn:
    - name: infra
  interval: 5m
  retryInterval: 1m
  path: ./k8s/apps
  prune: true
  wait: true
  sourceRef:
    kind: GitRepository
    name: flux-system
  • dependsOn: infra: applications are deployed only after infrastructure. The message dependency 'flux-system/infra' is not ready right after a new commit is therefore normal and clears within moments.
  • prune: true: whatever is removed from git, Flux removes from the cluster.
  • wait: true: the Kustomization is Ready only when all objects are healthy (e.g. the Deployment has finished rolling out).
  • Existing objects (the site, cloudflared) were adopted without a restart on the first run, because their definitions in git matched the cluster state.

Private image registry

Both the repository and the image are private, so Kubernetes pulls the image with a token stored as a Secret.

  1. GitHub → Developer settings → Personal access tokens → Tokens (classic): scope read:packages only, 1-year expiry. GHCR supports classic tokens only.
  2. The Secret in the cluster (run the read line on its own):
shell @ server
read -rsp "GHCR token: " GHCR_TOKEN; echo
shell @ server
kubectl -n homelab-docs create secret docker-registry ghcr-pull \
  --docker-server=ghcr.io --docker-username=<github-user> --docker-password="$GHCR_TOKEN"
unset GHCR_TOKEN
  1. The Deployment references the Secret:
yaml @ git
spec:
  template:
    spec:
      imagePullSecrets:
        - name: ghcr-pull

Check the stored token without printing it:

shell @ server
kubectl -n homelab-docs get secret ghcr-pull -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d \
  | python3 -c 'import json,sys,base64; a=json.load(sys.stdin)["auths"]["ghcr.io"]; u,p=base64.b64decode(a["auth"]).decode().split(":",1); print(u, len(p), p[:4])'

Git identity

Commits in the personal repository use a different identity than work ones. Git picks it automatically by directory:

shell @ mac
git config --file ~/.gitconfig-personal user.name "<name>"
git config --file ~/.gitconfig-personal user.email "<id>+<user>@users.noreply.github.com"
git config --global 'includeIf.gitdir:~/Projects/.path' '~/.gitconfig-personal'
  • everything under ~/Projects/ uses the personal identity, the work config stays untouched,
  • the e-mail is the GitHub noreply address (Settings → Emails → Keep my email addresses private), so the real address never appears in history,
  • Block command line pushes that expose my email is a safety net: GitHub rejects a push containing the real address.

Check inside the repository:

shell @ mac
git config --show-origin user.email

Day-to-day workflow

shell @ mac
cd ~/Projects/homelab
git pull                                   # fetch commits from CI and Flux
# … edit content in docs-site/site/content/{en,cs}/ …
git commit -am "docs: …" && git push
gh run watch                               # build progress
shell @ server
flux get kustomizations --watch            # Flux applies the new revision

Local preview without deploying: cd docs-site/site && hugo server → http://localhost:1313

Checking and rolling back

shell @ server
flux get kustomizations
flux reconcile kustomization apps --with-source   # sync now instead of waiting for the interval
kubectl -n homelab-docs get deploy homelab-docs -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'

Roll back to the previous version through git, so the change is not lost on the next sync:

shell @ mac
git log --oneline -- k8s/apps/homelab-docs/kustomization.yaml
git revert <release-commit>
git push

Secrets

The repository contains no secrets. They are created once by hand in the cluster:

SecretNamespacePurposeRenewal
tunnel-tokencloudflaredCloudflare Tunnelon leak, Rotate token in Cloudflare
ghcr-pullhomelab-docspulling the private imageyearly (token expiry)
flux-systemflux-systemFlux deploy key (read-only)created by bootstrap