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
- Mac: a change in the repository,
git commitandgit push. - 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. - Flux in the cluster checks the repository every minute. It pulls the new commit and applies it to the cluster.
- Kubernetes pulls the new image from the private GHCR and replaces the pod (rolling update, no downtime).
- Cloudflare Tunnel forwards internet traffic to Traefik, which routes it to the site.
| Stage | Typical time |
|---|---|
| GitHub Actions build (cached) | 30–60 s |
| Flux notices the new commit | up to 1 min |
| pod rolling update | 10–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 cluster | a CI pipeline from outside | an agent inside the cluster |
| Access into the cluster from outside | CI needs a kubeconfig or VPN | none |
| Open ports | often | none, Flux only pulls |
| Source of truth | whatever the last pipeline run did | git: what is in git is what runs |
| Manual change in the cluster | stays until someone reverts it | Flux reverts it on the next sync |
| Rollback | re-run an older pipeline | git 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:
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:
| Step | What it does |
|---|---|
| Checkout | fetches the repository |
| Compute image name and tag | ghcr.io/<owner>/homelab-docs, tag YYYYMMDD-<run number>-<sha> |
| Log in to GHCR | uses the built-in GITHUB_TOKEN, no custom token |
| Build and push | multi-stage build (Hugo → nginx), cached in GitHub Actions; tag and commit passed as build args and shown in the site footer |
| Bump image in kustomization | sed 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_TOKENdoes not trigger other workflows, and a change ink8s/does not match thepathsfilter anyway. - Least privilege:
contents: write(tag commit) andpackages: 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:
curl -s https://fluxcd.io/install.sh | sudo bash
flux check --preBootstrap 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:
read -rsp "GitHub token: " GITHUB_TOKEN; echoexport GITHUB_TOKEN
flux bootstrap github --owner=<github-user> --repository=homelab --branch=main --path=clusters/homelab --personal
unset GITHUB_TOKENBootstrap:
- 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
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-systemdependsOn: infra: applications are deployed only after infrastructure. The messagedependency 'flux-system/infra' is not readyright 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 isReadyonly 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.
- GitHub → Developer settings → Personal access tokens → Tokens (classic): scope
read:packagesonly, 1-year expiry. GHCR supports classic tokens only. - The Secret in the cluster (run the
readline on its own):
read -rsp "GHCR token: " GHCR_TOKEN; echokubectl -n homelab-docs create secret docker-registry ghcr-pull \
--docker-server=ghcr.io --docker-username=<github-user> --docker-password="$GHCR_TOKEN"
unset GHCR_TOKEN- The Deployment references the Secret:
spec:
template:
spec:
imagePullSecrets:
- name: ghcr-pullCheck the stored token without printing it:
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:
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:
git config --show-origin user.emailDay-to-day workflow
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 progressflux get kustomizations --watch # Flux applies the new revisionLocal preview without deploying: cd docs-site/site && hugo server → http://localhost:1313
Checking and rolling back
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:
git log --oneline -- k8s/apps/homelab-docs/kustomization.yaml
git revert <release-commit>
git pushSecrets
The repository contains no secrets. They are created once by hand in the cluster:
| Secret | Namespace | Purpose | Renewal |
|---|---|---|---|
tunnel-token | cloudflared | Cloudflare Tunnel | on leak, Rotate token in Cloudflare |
ghcr-pull | homelab-docs | pulling the private image | yearly (token expiry) |
flux-system | flux-system | Flux deploy key (read-only) | created by bootstrap |