Deploying Harbor on k3s: a private container registry for the homelab
How I set up Harbor as a self-hosted Docker registry on my k3s cluster, exposed it through Traefik with a clean hostname, and got image pushes working end-to-end.
After a few weeks of pushing images straight to Docker Hub, I got tired of rate limits and wanted something I controlled. Harbor was the obvious choice — it’s a CNCF project, it has a real web UI, vulnerability scanning, and RBAC. Setting it up on k3s turned out to be mostly straightforward, with one or two non-obvious steps I’m writing down so future me doesn’t have to figure them out again.
What is Harbor?
Harbor is a self-hosted container registry. Think Docker Hub but running on your own hardware. Beyond just storing images it gives you:
- Private repositories with project-level access control
- Vulnerability scanning on every pushed image
- A web UI for browsing images, tags, and scan results
- Webhook notifications you can wire into CI
For a homelab it’s a bit of overkill, but “overkill” is basically the point.
Installing Harbor with Helm
Harbor ships a well-maintained Helm chart. Add the repo and install:
helm repo add harbor https://helm.goharbor.io
helm repo update
helm install harbor harbor/harbor \
--namespace harbor-system \
--create-namespace \
--set expose.type=nodePort \
--set expose.nodePort.ports.http.nodePort=30002 \
--set externalURL=http://192.168.1.206:30002 \
--set persistence.enabled=false
persistence.enabled=false skips PVCs for now — fine while you’re experimenting, not fine for anything you care about keeping. I’ll wire in proper persistent volumes once the cluster storage story is sorted.
Once the rollout finishes (kubectl rollout status deployment harbor-core -n harbor-system) the UI is reachable at http://192.168.1.206:30002. Default credentials are admin / Harbor12345 — change them immediately.
Exposing it at a clean hostname via Traefik
Accessing a registry by IP and port works but it’s annoying. Since k3s ships with Traefik, I can front Harbor with an Ingress and give it a proper hostname.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: harbor
namespace: harbor-system
spec:
ingressClassName: traefik
rules:
- host: harbor.dev.lan
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: harbor
port:
number: 80
kubectl apply -f harbor-ingress.yaml
kubectl get ingress -n harbor-system
# NAME CLASS HOSTS ADDRESS
# harbor traefik harbor.dev.lan 192.168.1.206,192.168.1.208
One thing worth knowing: Traefik 3.x streams request bodies and has no default body-size limit, so large image layer pushes work without any extra annotations. This is different from nginx-ingress which defaults to 1MB and needs an override for registry traffic.
After applying the Ingress, add a hosts entry on every machine that needs access:
192.168.1.206 harbor.dev.lan
The non-obvious part: Harbor’s token auth
Here’s where I lost an hour. docker login harbor.dev.lan kept failing even though the UI loaded fine. The reason: Harbor uses token-based auth. When a Docker client hits /v2/, Harbor returns a WWW-Authenticate header pointing at its token service. That URL is built from the externalURL Helm value, which still pointed at the old NodePort address.
Fix it by capturing the current values and upgrading with the new URL:
helm get values harbor -n harbor-system > harbor-values.yaml
# edit harbor-values.yaml: set externalURL: http://harbor.dev.lan
helm upgrade harbor harbor/harbor \
--namespace harbor-system \
--version 1.13.1 \
-f harbor-values.yaml
Verify the fix:
curl -I http://harbor.dev.lan/v2/
# Www-Authenticate: Bearer realm="http://harbor.dev.lan/service/token",...
# ^^ should say harbor.dev.lan, not the IP
Configuring k3s nodes to trust the HTTP registry
k3s uses containerd under the hood and containerd won’t pull from a plain HTTP registry unless you explicitly tell it to. Edit /etc/rancher/k3s/registries.yaml on every node:
mirrors:
"192.168.1.206:30002":
endpoint:
- "http://192.168.1.206:30002"
"harbor.dev.lan":
endpoint:
- "http://harbor.dev.lan"
Then restart k3s on each node:
# Control plane
sudo systemctl restart k3s
# Workers
sudo systemctl restart k3s-agent
Without this step pods will fail with ImagePullBackOff even if you can push images just fine from your workstation — the daemon on the node still refuses to pull them.
Configuring Docker on your dev machine
Same idea on the push side. Add the hostname to Docker’s insecure registries in /etc/docker/daemon.json:
{
"insecure-registries": ["192.168.1.206:30002", "harbor.dev.lan"]
}
Restart the Docker daemon (sudo systemctl restart docker) — easy step to forget, and the most common reason docker login fails right after the edit.
Verifying end-to-end
docker login harbor.dev.lan
# Login Succeeded
docker pull alpine:3.20
docker tag alpine:3.20 harbor.dev.lan/library/alpine:3.20
docker push harbor.dev.lan/library/alpine:3.20
# Verify the cluster can pull it
kubectl run alpine-test --rm -it --restart=Never \
--image=harbor.dev.lan/library/alpine:3.20 -- echo ok
If the smoke test pod runs and prints ok, everything is wired up correctly.
What’s next
- Proper persistent storage so images survive a pod restart
- Automated vulnerability scanning on push
- Pi-hole DNS so I don’t have to manage
/etc/hostsentries manually across the cluster