Harbor as a Pull-Through Cache: Solving the Public Registry Problem in My Homelab

|7 min read|

Docker Hub rate limits, slow pulls, and scattered registry endpoints were grinding my Kubernetes cluster down. Here is how I deployed Harbor as a centralized pull-through cache for Docker Hub, GHCR, and Quay.io, and why it was one of the best infrastructure decisions I have made.

Yoga Novaindra

Author

Let me tell you about an annoying class of problems that crept up quietly as my homelab Kubernetes cluster grew.

It started small. A pod would occasionally fail to start because it hit Docker Hub's pull rate limit. Then I noticed that pulling a fresh image from ghcr.io on a cold node felt painfully slow, not because my internet was bad, but because the upstream registry was just far away. And as I scaled to 60+ applications across 15 namespaces, I realized I had no single place to see what images were actually running in my cluster and where they came from.

The solution turned out to be something I had been putting off for a while: running Harbor not as a private image registry, but as a pull-through proxy cache sitting in front of Docker Hub, GitHub Container Registry, and Quay.io.

Here is an honest look at why I set it up, how it actually works, and the gotchas I ran into along the way.


The Problem: Why Public Registries Are a Liability at Scale

When you are running a handful of containers, pulling images directly from public registries is fine. When you are running a Kubernetes cluster with 60+ apps and nodes that regularly spin up cold, the cracks start to show.

Docker Hub's Pull Rate Limits

Docker Hub's rate limits have gotten stricter over time. As of the current policy, unauthenticated pulls are limited to 10 per hour per IP address, and authenticated free (Personal) accounts get 100 per hour. Only Pro, Team, and Business accounts pull without a hard limit, subject to fair use.

In a Kubernetes cluster, all your nodes share the same outbound IP, so it does not take much to exhaust that budget, especially during rolling deployments, node restarts, or when Kubernetes starts probe-checking a new image. At 10 pulls an hour unauthenticated, a single rollout across a handful of nodes can burn through the entire window on its own.

I hit this wall more than once. Pods stuck in ImagePullBackOff with the frustrating message toomanyrequests: You have reached your pull rate limit.

Latency for Every Cold Pull

My homelab runs on physical hardware. When a new node comes up or a pod moves to a node that does not have the image cached locally, it has to pull from the public registry. Depending on the image size and network conditions, this could take 30 seconds to several minutes. Not ideal.

No Visibility Into What's Running

When you pull from multiple upstream registries with different authentication rules, you have no single pane of glass for what is in your cluster. Harbor fixes this by funneling everything through one endpoint: reg.ygnv.my.id.


What Proxy Cache Actually Means in Harbor

Before diving into the setup, it is worth clarifying what Harbor's "Proxy Cache" project type actually does.

When you configure a Harbor project as a proxy cache and point it at an upstream registry, Harbor acts as a transparent intermediary:

  1. A node in your cluster requests reg.ygnv.my.id/docker/nginx:latest
  2. Harbor checks if it has a cached copy of docker.io/nginx:latest
  3. If yes, it serves it directly from local storage
  4. If no (or if the cache is stale), it pulls from Docker Hub, caches it, and serves it to your node

The pull still happens from Docker Hub the first time, but every subsequent pull from any node in your cluster is served from Harbor's local storage. No rate limit hits, no external latency.


The Setup

I will not walk through a generic installation here, Harbor's official Helm chart is well-documented. What I will share are the specific decisions I made for my setup that are not obvious from the docs.

External Everything

The default Harbor Helm chart ships with bundled PostgreSQL and Redis. I skipped both. My cluster already runs a shared PostgreSQL instance and Valkey (a Redis-compatible fork), so Harbor connects to those instead:

database:
  type: external
  external:
    host: "postgres.db.svc.cluster.local"
    port: "5432"
    coreDatabase: "harbor"
    existingSecret: "harbor-cred"

redis:
  type: external
  external:
    addr: "valkey.db.svc.cluster.local:6379"

This keeps the Harbor namespace lean. No bundled databases to maintain or back up separately.

Storage on CephFS

All three PVCs are statically bound to CephFS paths mounted at /mnt/cephfs/docker/harbor/:

PVCCephFS Path
harbor-registry-pvc.../data/registry
harbor-jobservice-pvc.../jobservice
harbor-trivy-pvc.../trivy

Since these are backed by CephFS via hostPath static binding, capacity is managed at the filesystem level rather than the PVC quota level.

Traefik Instead of the Built-In NGINX Ingress

The Harbor Helm chart's ingress support assumes NGINX by default. My cluster uses Traefik, and I wanted to avoid doubling up on ingress controllers. I set expose.type: clusterIP and deployed a separate IngressRoute CRD:

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: harbor
  namespace: harbor
spec:
  entryPoints:
    - http
    - https
  routes:
    - match: Host(`reg.ygnv.my.id`)
      kind: Rule
      services:
        - name: harbor
          port: 80
  tls: {}

Clean, minimal. Traefik handles TLS termination via its certificate resolver.

Secrets: One Secret to Rule Them All

Every credential Harbor needs, admin password, core secret, XSRF key, jobservice secret, registry HTTP secret, is stored in a single Kubernetes Secret called harbor-cred. The Helm chart supports referencing an existingSecret for each component, which means no credentials ever appear in the values.yaml that lives in my public Git repo.


Configuring the Proxy Cache Projects

This part is where the Harbor docs are slightly thin and where I had to do some trial and error.

Creating a Proxy Cache Project

In Harbor's UI:

  1. Go to Administration → Registries and add an endpoint
    • For Docker Hub: endpoint URL is https://hub.docker.com, provider is Docker Hub
    • For GHCR: endpoint URL is https://ghcr.io, provider is GitHub GHCR
    • For Quay: endpoint URL is https://quay.io, provider is Quay
  2. Create a new Project, enable the "Proxy Cache" toggle, and select the registry endpoint

That is it. Harbor creates a project that acts as a transparent proxy for the upstream.

The Authentication Catch

Docker Hub rate limits apply to the credentials you use when Harbor connects upstream. If you connect anonymously, you are on the 10/hour anonymous limit. If you add a Docker Hub login to the registry endpoint, Harbor uses that account's quota instead, which is a meaningful jump even on a free Personal account.

For GHCR, I use a GitHub Personal Access Token with read:packages scope. This avoids rate limits entirely and gives Harbor access to private packages if needed.

Pulling Through the Cache

Since all proxy cache projects are public in my setup, nodes pull anonymously with no image pull secret needed. The only change is the image reference itself. Instead of:

docker pull nginx:latest

Manifests reference:

reg.ygnv.my.id/docker/library/nginx:latest

That extra library/ segment is not a Harbor quirk, it is Docker Hub's own namespace for official images. Every "official" image (nginx, postgres, redis, and so on) actually lives under the library namespace on Docker Hub itself; the short name is just a convenience alias that Docker's client resolves for you. Harbor's proxy cache does not do that resolution, so you have to spell out the full upstream path yourself. Anything already namespaced, like bitnami/postgresql, keeps its normal path: reg.ygnv.my.id/docker/bitnami/postgresql.


Garbage Collection: The Part Nobody Warns You About

This is the section I wish existed when I was setting this up.

Harbor's Garbage Collection runs on a schedule to remove blobs and manifests that are no longer referenced, freeing up storage. On the surface, this sounds straightforward. With a proxy cache setup, there is a subtle catch that can bite you.

How Harbor GC Works

When GC runs, it scans for blobs and manifests that have no active references and deletes them. There is a built-in 2-hour safety window: anything uploaded or cached within the last 2 hours is excluded from the current GC run, preventing deletion of artifacts that are still in-flight or just cached.

In my setup, GC runs weekly on a schedule with 1 worker. A typical run frees several GB, sometimes more than 10 GB depending on how many cached image layers have expired.

The Untagged Artifact Problem

Here is the part that is not obvious: Kubernetes (via containerd) often resolves image tags to digests before pulling. When Harbor caches one of these digest-based pulls, the artifact is stored without a tag in the Harbor database.

I have the "Allow garbage collection on untagged artifacts" option enabled. This means every weekly GC run will delete any cached artifact that has no tag reference, as long as it is older than 2 hours. If your nodes rely on those cached layers and GC clears them, the next pull goes back to the upstream registry.

This is a known trade-off. Disabling the option lets untagged artifacts pile up indefinitely, which defeats the point of GC in a proxy cache context. Keeping it enabled means the cache is periodically thinned, which in practice is fine since Harbor will re-cache on the next pull.

The important thing is to understand what is happening so you are not confused when GC logs show hundreds of blobs deleted from your proxy cache projects.


What Actually Improved

After running this for a few months, the improvements are tangible, and they map directly back to the three problems I started with.

Pull times dropped significantly. The first pull of any image still hits the upstream registry, but every subsequent pull across any node comes from Harbor's local storage. For frequently-used images like nginx, postgres, or redis, this means sub-second layer resolution instead of a multi-second upstream fetch.

No more rate limit errors. Not once since migrating all workloads to pull through reg.ygnv.my.id have I seen a toomanyrequests error, even with the stricter 10-per-hour anonymous limit now in effect upstream.

Central visibility. I can now see every image variant being used across all 60+ apps in one place, at reg.ygnv.my.id, instead of guessing which registry each workload happens to pull from. Trivy also runs periodic vulnerability scans on everything cached, which has surfaced a few issues I would not have caught otherwise.

Getting the Helm values right, wiring in external PostgreSQL and Valkey, managing the CephFS storage, and correctly configuring each upstream endpoint took time. But once it was running, it has been one of the most quietly useful pieces of infrastructure in my entire cluster. If your cluster is regularly pulling from Docker Hub, GHCR, or Quay, this is worth your weekend.


References

© 2026 Yoga Novaindra Powered by Ghost