Overview
The cluster could already serve a lab app on laultlab.com. The homelab blog is the first site I wanted on its own name.
It is a static nginx site, image ghcr.io/lucas-ault/homelab-blog:latest, published at lucas.ault.cc. The package is private. The manifests live in Git. The registry token does not.
Flux reconciles it from applications/production. The hostname is an explicit record on the existing Cloudflare tunnel, not a name under the lab wildcard.
Technology Stack
- homelab-blog — Static nginx. Two replicas. No application environment.
- GHCR —
ghcr.io/lucas-ault/homelab-blog:latest, pulled withimagePullPolicy: Always. - Infisical — Path
/ghcr, keysGHCR_USERNAMEandGHCR_TOKEN. The operator writes a docker config Secret. - Flux CD —
apps-productionappliesapplications/productionafter Infisical auth is ready. - Traefik — Ingress host
lucas.ault.cc, classtraefik. - Cloudflare —
ault.cczone.lucas.ault.ccis published on the existinghomelabtunnel. TLS ends at Cloudflare.
Why it is not on the lab zone
*.laultlab.com is the lab wildcard. Production sites get an explicit hostname so a lab name and a real site are not the same kind of DNS record.
lucas.ault.cc is that hostname. ault.cc is its own Cloudflare zone, on the same account as the tunnel. The wildcard CNAME for laultlab.com stays as it is.
The manifests
The app is a directory under applications/production, listed from that environment’s kustomization.yaml. Flux’s apps-production Kustomization points at the directory, depends on platform-secrets, and prunes resources removed from Git.
Three files:
- Namespace
homelab-blog. - An
InfisicalStaticSecretthat builds the image pull Secret. - The ServiceAccount, Deployment, Service, and Ingress.
There is no app Secret. The container does not read environment variables. The only credential is the one Kubernetes needs to pull the image.
The Deployment runs two replicas. Both must land on workers. The control-plane nodes are not tainted, so the pod spec requires node-role.kubernetes.io/control-plane to be absent. Preferred anti-affinity spreads the replicas across nodes. Requests are 10m CPU and 32Mi memory.
Readiness is GET / on port 80 after 2 seconds. Liveness is the same path, starting at 10 seconds. The Service is a ClusterIP on port 80.
spec: ingressClassName: traefik rules: - host: lucas.ault.cc http: paths: - path: / pathType: Prefix backend: service: name: homelab-blog port: number: 80The Ingress does not terminate TLS. Cloudflare does.
Pulling a private image
The image is private on GHCR. GitHub username lucas-ault and a PAT with read:packages on this package go in Infisical project homelab, environment prod, path /ghcr, keys GHCR_USERNAME and GHCR_TOKEN.
The static secret renders those into Secret ghcr-pull in namespace homelab-blog, type kubernetes.io/dockerconfigjson:
apiVersion: secrets.infisical.com/v1beta1kind: InfisicalStaticSecretmetadata: name: ghcr-pull namespace: homelab-blogspec: infisicalAuthRef: name: infisical-auth namespace: infisical sources: - projectSlug: homelab environmentSlug: prod secretPath: /ghcr targets: - name: ghcr-pull namespace: homelab-blog kind: Secret secretType: kubernetes.io/dockerconfigjson template: data: .dockerconfigjson: | {{- $auth := printf "%s:%s" .GHCR_USERNAME.Value .GHCR_TOKEN.Value | b64enc -}} {{- $entry := dict "username" .GHCR_USERNAME.Value "password" .GHCR_TOKEN.Value "auth" $auth -}} {{- dict "auths" (dict "ghcr.io" $entry) | mustToJson -}}The ServiceAccount homelab-blog lists ghcr-pull under imagePullSecrets. The PAT is not committed.
apps-production can apply the manifests before /ghcr is filled. The pods stay unready until the operator has written the pull Secret and the kubelet can pull ghcr.io/lucas-ault/homelab-blog:latest. The operator polls about once a minute.
Cloudflare
On the existing homelab tunnel, publish lucas.ault.cc to:
http://traefik.kube-system.svc.cluster.local:80That is the same origin the lab wildcard uses. Point DNS for lucas.ault.cc at the tunnel. A more specific record wins over any wildcard, and this name is not under laultlab.com anyway.
The request path is the same shape as a lab hostname:
browser -> Cloudflare edge -> cloudflared -> traefik.kube-system:80 -> Ingress -> homelab-blogTLS ends at Cloudflare. The origin is HTTP inside the cluster. Public traffic does not use the LAN ingress VIP.
Shipping a new build
The manifest tag is latest, and imagePullPolicy is Always. A new package in GHCR does not replace pods that are already running. Those pods keep the image they started with. A new pod pulls latest again.
After the package is published:
kubectl rollout restart deployment/homelab-blog -n homelab-blogkubectl rollout status deployment/homelab-blog -n homelab-blogChecks
kubectl get pods,ingress -n homelab-blogkubectl get infisicalstaticsecret -n homelab-blogBoth pods should be Ready, on two different workers. The Ingress address is Traefik. https://lucas.ault.cc should return the site.
If the pods are ImagePullBackOff, the usual cause is /ghcr empty or the PAT missing read:packages. If the site is Traefik’s 404, the Ingress host and the published Cloudflare hostname do not match. If the name does not resolve, DNS for lucas.ault.cc is not on the tunnel yet.
What’s Next?
The site is a static Deployment with a private image and one public name. A few follow-ups belong to this app:
- Image identity —
latestplus a restart works. A pinned tag or digest would make Git say which build is running. - Rollout — The restart is still a manual command. A webhook or a CI job could do it after GHCR publishes a package.
- The other zone — Leave
*.laultlab.comfor lab apps. New production sites get their own hostname on the tunnel, the same way this one did.
Final Thoughts
This was the first production workload on the cluster, and it barely has any configuration. Two nginx replicas, one Ingress host, one pull Secret.
The work was in the split. Git describes the Deployment and the fact that GHCR needs a token. Infisical holds the token. Cloudflare holds the public name and the certificate. Flux applies the manifests once platform-secrets is ready, and the pods stay down until the pull Secret exists.
https://lucas.ault.cc answering from the cluster is the check that those pieces are actually connected.