← cd ../blog
hero image — bench photo of the build

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/hosts entries manually across the cluster