Skip to main content
Back to Blog

Automated Kubernetes Deployments with Keel — No CI/CD SSH Required

kuberneteshomelabdevopsautomationkeelgithub-actions

Automated Kubernetes Deployments with Keel — No CI/CD SSH Required

I had a problem: my Next.js site runs in a container on my home Kubernetes cluster, and GitHub Actions builds a new image on every push to main. But how do you get that new image deployed to a cluster that isn't exposed to the internet?

The obvious answer — giving GitHub SSH access to my cluster — felt wrong. Opening inbound access to a home lab just for deployments is a security trade-off I didn't want to make.

The solution: Keel, a lightweight in-cluster controller that polls your container registry and automatically updates deployments when new images appear.

The Setup

My deployment pipeline already looked like this:

  1. Push to main
  2. GitHub Actions builds the Docker image
  3. Image pushed to GHCR as ghcr.io/rfxtech/rfox.net:latest

The missing piece was step 4: getting the cluster to notice and pull the new image.

Installing Keel

Keel installs via Helm in about 30 seconds:

helm repo add keel https://charts.keel.sh
helm repo update keel
helm install keel keel/keel \
  --namespace keel \
  --create-namespace \
  --set helmProvider.enabled=false \
  --set polling.enabled=true \
  --set polling.defaultSchedule='@every 1m'

Key settings:

  • helmProvider.enabled=false — I'm not using Helm-managed deployments for my apps
  • polling.enabled=true — Keel actively checks the registry for changes
  • polling.defaultSchedule='@every 1m' — Check every minute

Configuring Registry Auth

My GHCR packages are private, so Keel needs credentials to poll for new digests. I created a secret in the keel namespace with my GHCR token:

kubectl -n keel create secret generic keel-registry \
  --from-literal=username=rfxtech \
  --from-literal=password=<ghcr-pat> \
  --from-literal=registry=ghcr.io

Then told Keel to use it:

helm upgrade keel keel/keel \
  --namespace keel \
  --set helmProvider.enabled=false \
  --set polling.enabled=true \
  --set polling.defaultSchedule='@every 1m' \
  --set secret.name=keel-registry \
  --set secret.create=false

Annotating the Deployment

The magic is three annotations on your deployment:

kubectl annotate deployment rfox-net \
  keel.sh/policy=force \
  keel.sh/trigger=poll \
  keel.sh/match-tag=true
  • keel.sh/policy=force — Update on any change (since we're using :latest, this triggers on digest changes)
  • keel.sh/trigger=poll — Use polling to detect changes
  • keel.sh/match-tag=true — Only update images that match the current tag

Switching to the latest Tag

One important detail: my deployment was previously pinned to a specific SHA tag (sha-b2267c2). For Keel's polling to work with digest comparison, I switched to the latest tag:

kubectl set image deployment/rfox-net nginx=ghcr.io/rfxtech/rfox.net:latest

Combined with imagePullPolicy: Always on the container, Kubernetes will always pull fresh when Keel triggers an update.

The Result

The full flow is now:

  1. git push origin main
  2. GitHub Actions builds and pushes ghcr.io/rfxtech/rfox.net:latest
  3. Within 1 minute, Keel detects the new image digest
  4. Keel triggers a rolling update on the deployment
  5. New pods pull the fresh image and start serving traffic

Zero inbound access to the cluster. Keel reaches out to GHCR — the registry never needs to reach in. No webhooks, no SSH keys in CI, no VPN tunnels.

Why Not Flux/ArgoCD?

For a single app on a home lab, Keel hits the sweet spot:

  • Single Helm install — no CRDs, no multi-component architecture
  • Annotation-based — no extra manifests to maintain
  • Works with existing CI — no changes to my GitHub Actions workflow were needed
  • Minimal resources — one pod, barely any memory

Flux and ArgoCD are fantastic for multi-team GitOps at scale. For "I just want my home site to auto-deploy," Keel is the right tool.

Gotchas

  • Private registries need explicit auth — Keel doesn't inherit imagePullSecrets from your deployments. You need to provide credentials separately.
  • Use imagePullPolicy: Always — Without this, Kubernetes might use a cached image even after Keel updates the deployment.
  • :latest tag is fine here — Yes, :latest in production is generally discouraged. But with Keel + digest comparison, it's actually the cleanest pattern. Keel tracks the actual digest, not the tag name.

Final Thoughts

The best deployment pipeline is one you don't have to think about. Push code, go make coffee, come back to a live site. Keel makes that possible without compromising your cluster's security posture.