In a nutshell
Every container image your company runs passes through one door on its way to production: the registry. A plain registry is a coat-check — it hands back whatever you handed it, no questions asked. Harbor is that same coat-check with a security desk bolted on. It checks who you are, x-rays every bag for something dangerous, refuses to return anything unsigned or unsafe, keeps a copy at your other offices in case this one burns down, and throws out the junk nobody has claimed in months. Because every image has to come through this one door, it is the single cheapest place to write a rule once and have it apply to everyone.
Concretely, Harbor is a private image registry — a CNCF graduated project you run yourself — and it does five jobs a bare registry does not. Isolation: each team gets a walled project with its own permissions and quota. Scanning: it runs Trivy over every image and lists the known vulnerabilities. Gating: it can refuse the pull of an image that is too vulnerable or isn’t signed. Replication: it copies images to another registry for geo-distribution, disaster recovery, or an air-gapped site. Housekeeping: retention, immutability, and garbage collection keep storage and your release tags under control.
The mental model to hold onto: the registry is a policy choke point, not a dumb blob store. Everything below is about turning that choke point into a control plane you can defend in an audit — the decisions that matter when you run it for many teams across regions, not the five-minute demo.
Level: Advanced · Time: ~30 min
Read it left to right: a scoped robot pushes an image by digest into a walled project; Trivy scans it and Cosign signs the digest; the pull-time gate refuses anything too vulnerable or unsigned (a kubelet sees ErrImagePull); and replication keeps a copy near every cluster — each numbered badge is a control that either holds or, misconfigured, quietly opens the door.
Prerequisites & what you’ll be able to do
You should be comfortable with docker pull/push and image tags, know what a CVE is, and have met Kubernetes image pulls (kubelet, ErrImagePull) at least once. Signing concepts help but are not required — Sigstore keyless signing goes deeper on Cosign and admission control, SLSA & SBOM provenance covers the attestations Harbor stores, and Deploy Nexus Repository covers the same proxy/hosted pattern for a broader, multi-language artifact plane.
After this you can:
- Model multi-team isolation with projects, robot accounts, and OIDC-mapped RBAC — and never let CI authenticate as a human or
admin. - Front Docker Hub with a proxy-cache project so a rate limit or an upstream outage cannot stop a release.
- Turn on Trivy scanning and a deploy gate that blocks pulls of vulnerable or unsigned images at a severity you choose.
- Sign images with Cosign and enforce signatures at the project boundary — mirrored by cluster-side admission for defense in depth.
- Design push/pull replication whose direction matches your firewall, plus retention, immutability, quotas, and a tested backup/restore runbook.
A container registry is not a dumb blob store. It is the choke point every artifact passes through on the way to production, which makes it the cheapest place to enforce supply-chain policy. Harbor (a CNCF graduated project) turns that choke point into a control plane: project-scoped RBAC, built-in Trivy scanning, signature verification, replication, and admission-style deploy gates. This guide walks the operational decisions that matter when you run it for multiple teams across regions, not the five-minute demo.
1. Harbor architecture: the components you actually operate
Harbor is a set of cooperating services in front of a standard OCI distribution registry. Knowing which component owns which failure mode is what lets you debug a stuck push at 2 a.m.
| Component | Responsibility | Failure symptom when it dies |
|---|---|---|
core |
API, auth, RBAC, policy decisions, web UI backend | UI 500s, logins fail, robot tokens rejected |
registry (distribution) |
Stores and serves OCI blobs/manifests | docker push/pull hang or 503 |
registryctl |
Triggers garbage collection, manages registry config | GC jobs never start |
jobservice |
Async worker pool: scans, replication, retention, GC | Scans/replications queue forever |
trivy-adapter |
Vulnerability scanning via Trivy | “Not Scanned” stuck, scan jobs error |
core DB (PostgreSQL) |
Projects, users, policies, scan results metadata | Total outage; this is the source of truth |
| Redis | Job queues, UI session cache, registry blob cache | Jobs lost, sessions drop, slow pulls |
portal |
Static web UI (nginx) | UI unreachable, API still works |
Two rules follow directly from this table. First, PostgreSQL is your single source of truth for everything except blobs; back it up like a production database, not a cache. Second, blobs live in object storage, not the database in any real deployment, so the registry’s storage backend (S3, Azure Blob, GCS) is a separate durability concern. Never run a production Harbor with filesystem storage on a single PVC.
For storage backend config (the registry component reads this), point it at object storage explicitly. In a Helm values file:
persistence:
imageChartStorage:
type: s3
s3:
region: us-east-1
bucket: harbor-prod-registry
# Prefer IRSA / workload identity over static keys.
# Leave accesskey/secretkey unset to use the instance/pod role.
encrypt: true
secure: true
v4auth: true
2. Designing projects, robot accounts, and quota for multi-team isolation
A project is Harbor’s unit of isolation: it scopes RBAC, quota, scanning policy, retention, and immutability. Model one project per team-or-application boundary, not one giant library project everyone pushes to. The default public library project should be deleted or locked on day one.
Roles within a project are fixed and well-defined: Limited Guest (pull only, no catalog), Guest (pull), Developer (push + pull), Maintainer (push, pull, scan, manage), and Project Admin (everything including members and policies). Map these to your IdP groups via OIDC so membership is managed in one place.
Create a project with a storage quota and let CVEs through only via explicit allowlist (covered in step 5). The Harbor API is the scriptable path:
# Create a project with a 200 GiB quota and auto-scan on push.
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X POST "https://harbor.example.com/api/v2.0/projects" \
-H "Content-Type: application/json" \
-d '{
"project_name": "payments",
"metadata": {
"public": "false",
"auto_scan": "true",
"reuse_sys_cve_allowlist": "true"
},
"storage_limit": 214748364800
}'
CI systems must never authenticate as a human or as admin. Use a robot account, which is a scoped, optionally expiring credential bound to specific actions on specific resources. Project-level robots cover one project; system-level robots can span projects (useful for a shared CI runner).
# Project-scoped robot: push+pull on repositories, pull on artifacts, 90-day expiry.
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X POST "https://harbor.example.com/api/v2.0/robots" \
-H "Content-Type: application/json" \
-d '{
"name": "ci-pusher",
"duration": 90,
"level": "project",
"permissions": [{
"kind": "project",
"namespace": "payments",
"access": [
{"resource": "repository", "action": "push"},
{"resource": "repository", "action": "pull"}
]
}]
}'
The response returns the secret exactly once. The robot name is prefixed (e.g. robot$payments+ci-pusher); feed both name and secret to docker login. Rotate them with PATCH .../robots/{id} and a refresh secret call, and keep duration short for anything that touches production projects.
Callout: Quota is enforced at push time against unique blob usage, not the naive sum of tag sizes. Because layers are deduplicated, two tags sharing a base image count that base once. This is why a project can hold far more “image-equivalents” than its quota suggests, and why deleting a tag rarely frees space until garbage collection runs (step 8).
3. Proxy cache projects to front Docker Hub and survive rate limits
Docker Hub’s anonymous and free-tier pull limits will eventually break a busy CI fleet, and pulling public base images directly couples your builds to an external registry’s availability. A Harbor proxy cache project fixes both: it transparently fronts an upstream registry, caches pulled artifacts locally, and serves subsequent pulls from Harbor.
First register the upstream as a registry endpoint, then create a project of type proxy bound to it:
# 1. Register Docker Hub as an endpoint (store credentials to lift anon limits).
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X POST "https://harbor.example.com/api/v2.0/registries" \
-H "Content-Type: application/json" \
-d '{
"name": "dockerhub",
"type": "docker-hub",
"url": "https://hub.docker.com",
"credential": {"access_key": "'"${DH_USER}"'", "access_secret": "'"${DH_TOKEN}"'"}
}'
# 2. Create the proxy cache project (registry_id from the previous response).
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X POST "https://harbor.example.com/api/v2.0/projects" \
-H "Content-Type: application/json" \
-d '{"project_name":"dockerhub-proxy","registry_id": 5, "metadata":{"public":"true"}}'
Developers and CI then pull through Harbor instead of Docker Hub:
# Was: docker pull nginx:1.27
docker pull harbor.example.com/dockerhub-proxy/library/nginx:1.27
A few operational truths about proxy caches. The cache is populated lazily on first pull, so the first request still hits upstream. Cached artifacts are subject to the project’s retention policy, so set a sensible TTL or they accumulate forever. And critically, scanning still applies to proxied images, so you get CVE visibility on third-party base images you previously trusted blind. Point your golden-base-image policy at the proxy and you have a single audited path for all external pulls.
4. Replication: pull/push policies across regions and to DR
Replication moves artifacts between Harbor and a remote registry endpoint (another Harbor, ECR, ACR, GCR, Quay, or Docker Hub). You will use it for three patterns: geo-distribution (push releases to a registry near each region’s clusters), disaster recovery (continuous replication to a cold-standby Harbor), and promotion (replicate from a staging registry to production on a trigger).
A policy has a direction, a filter set, a trigger, and a destination. This push-based policy mirrors only release-tagged, signed images in the payments project to a DR registry on every event:
{
"name": "payments-to-dr",
"src_registry": null,
"dest_registry": {"id": 7},
"dest_namespace": "payments",
"dest_namespace_replace_count": 1,
"trigger": {"type": "event_based"},
"filters": [
{"type": "name", "value": "payments/**"},
{"type": "tag", "value": "v*"}
],
"deletion": false,
"override": true,
"enabled": true
}
Key decisions encoded here:
trigger.type: event_basedreplicates the instant an image is pushed or signed, giving near-real-time DR. Usescheduled(with a crontrigger_settings) for predictable bandwidth, ormanualfor gated promotion.deletion: falsemeans a delete in the source does not propagate. For DR you almost always want this false so an accidental source deletion cannot wipe your standby. Flip it to true only for true mirrors.override: truelets a re-push of the same tag overwrite the destination; set false to make destination tags effectively immutable from the replication side.- Tag filter
v*ensures only release tags cross the wire. Replicating every dev/SHA tag to three regions is how you turn a registry into a bandwidth bill.
Callout: Choose pull-based replication when the destination cannot reach the source but the source-side Harbor can reach it (common with locked-down DR networks where only the DR site initiates connections). Push-based requires the source to open a connection to the destination. The right direction is dictated by your firewall rules, not by preference.
5. Vulnerability scanning with Trivy and CVE allowlists
Harbor ships Trivy as the default scanner. Enable auto_scan per project (step 2) so every push triggers a scan, and schedule a system-wide rescan so newly disclosed CVEs are re-evaluated against existing images without a re-push, because a CVE published today against an image you built last month will only appear after a rescan.
# Nightly full rescan so new CVE data is applied to existing artifacts.
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X PUT "https://harbor.example.com/api/v2.0/system/scanAll/schedule" \
-H "Content-Type: application/json" \
-d '{"schedule": {"type": "Custom", "cron": "0 0 2 * * *"}}'
Trivy’s database refresh matters in air-gapped or rate-limited environments. The adapter pulls vulnerability data periodically; in restricted networks, mirror the DB and point the adapter at your mirror via TRIVY_DB_REPOSITORY rather than letting it fail open with stale data.
False positives are inevitable, and the wrong response is to lower the severity gate globally. Instead, use a CVE allowlist: a scoped, expiring waiver for a specific CVE ID. Maintain a system allowlist for truly universal cases and per-project allowlists for the rest. Always set an expiry so waivers are re-justified rather than living forever.
# Project-level allowlist with an expiry (Unix epoch seconds).
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X PUT "https://harbor.example.com/api/v2.0/projects/payments/cve_allowlist" \
-H "Content-Type: application/json" \
-d '{
"items": [{"cve_id": "CVE-2024-12345"}],
"expires_at": 1767225600
}'
6. Deployment security: prevent vulnerable from running and enforce signatures
This is where Harbor stops being a passive store. Two project-level switches turn it into an enforcement point at pull time.
Prevent vulnerable images from running blocks pulls of any artifact whose scan found a vulnerability at or above a chosen severity. An allowlisted CVE (step 5) is excluded from the decision, which is exactly how a justified waiver lets a known-but-accepted finding through while still blocking everything else.
# Block pulls of images with High+ vulnerabilities (allowlist still applies).
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X PUT "https://harbor.example.com/api/v2.0/projects/payments" \
-H "Content-Type: application/json" \
-d '{"metadata": {"prevent_vul": "true", "severity": "high"}}'
The behavior to internalize: a blocked pull returns an error to the client, including your Kubernetes nodes. A kubelet pulling a freshly-flagged image will get a denial and the Pod will fail to start with ErrImagePull. That is the gate working as designed, but it means a new CVE can stop a previously-passing deployment, so wire scan results into your alerting, not just the registry.
For provenance, Harbor integrates Cosign signature verification. Enabling content trust / signature enforcement at the project level means Harbor only serves artifacts that carry a valid Cosign signature stored alongside them.
# Require a valid Cosign signature on every artifact served from this project.
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X PUT "https://harbor.example.com/api/v2.0/projects/payments" \
-H "Content-Type: application/json" \
-d '{"metadata": {"enable_content_trust_cosign": "true"}}'
Your CI signs after push, before promotion:
# Keyless signing in CI (OIDC identity from the runner), then Harbor will serve it.
IMAGE="harbor.example.com/payments/api@${DIGEST}"
COSIGN_EXPERIMENTAL=1 cosign sign --yes "${IMAGE}"
Callout: Harbor’s
prevent_vuland signature enforcement act at the registry boundary, which catches pulls Harbor can see. They are a strong first layer, but defense in depth means also enforcing at the cluster boundary with an admission controller like Kyverno or the Sigstore policy controller, verifying the same Cosign signature against your trusted identity. Registry-side and admission-side checks fail closed independently; relying on only one leaves a gap (e.g. an image pulled by a node bypassing the cluster you do not control).
7. Tag retention and immutability: control storage, protect releases
Two policies, two different jobs, frequently confused.
Tag retention decides which tags to keep (everything else becomes eligible for deletion). Model it as “retain the last N of each, keep recent pushes” and scope it with repository and tag filters. This rule keeps the 10 most recent SHA-tagged dev images and the 20 most recent release tags:
{
"algorithm": "or",
"rules": [
{
"template": "latestPushedK",
"params": {"latestPushedK": 10},
"scope_selectors": {"repository": [{"kind": "doublestar", "decoration": "repoMatches", "pattern": "**"}]},
"tag_selectors": [{"kind": "doublestar", "decoration": "matches", "pattern": "sha-*"}]
},
{
"template": "latestPushedK",
"params": {"latestPushedK": 20},
"scope_selectors": {"repository": [{"kind": "doublestar", "decoration": "repoMatches", "pattern": "**"}]},
"tag_selectors": [{"kind": "doublestar", "decoration": "matches", "pattern": "v*"}]
}
],
"trigger": {"kind": "Schedule", "settings": {"cron": "0 0 1 * * 0"}}
}
Run retention in dry-run first (the API and UI both support it) and read the “what would be deleted” report before you ever let it delete. A misfiled tag selector that matches your release tags will happily mark them for cleanup.
Immutability is the opposite guarantee: it prevents matching tags from being overwritten or deleted at all, regardless of retention. Protect your release tags so a re-push of v1.4.2 can never silently change what that tag points to:
{
"disabled": false,
"scope_selectors": {"repository": [{"kind": "doublestar", "decoration": "repoMatches", "pattern": "**"}]},
"tag_selectors": [{"kind": "doublestar", "decoration": "matches", "pattern": "v*"}]
}
The correct combination for most teams: immutability on v* release tags so they are tamper-proof and pinned, plus retention on sha-* and dev-* tags so the long tail of build artifacts gets pruned. Immutability wins conflicts: a retention rule cannot delete an immutable tag.
8. Garbage collection, HA topology, and backup/restore
Deleting a tag only removes the manifest reference. The underlying blobs stay on disk until garbage collection runs and removes layers no longer referenced by any manifest. Until GC runs, your storage bill does not drop.
# Trigger GC; dry_run first to see reclaimable space, then run for real.
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X POST "https://harbor.example.com/api/v2.0/system/gc/schedule" \
-H "Content-Type: application/json" \
-d '{"schedule": {"type": "Manual"}, "parameters": {"dry_run": true, "delete_untagged": true}}'
GC operational rules:
delete_untagged: truereclaims layers from untagged manifests (the typical result of a re-pushed tag). Without it, orphaned manifests linger.- Historically Harbor took a brief read-only window during GC; on modern versions the impact is reduced, but still schedule GC in a low-traffic window and after retention has run.
- GC is irreversible. Always dry-run, confirm the reclaim estimate is sane, then execute.
For HA, the stateless Harbor services (core, jobservice, portal, registry, adapters) scale horizontally behind a load balancer with replicas > 1. The hard requirements are the stateful dependencies: an external, HA PostgreSQL (managed RDS/Cloud SQL/Flexible Server, not the in-chart single Postgres), an external Redis (cluster or Sentinel), and shared object storage for blobs so any registry replica serves any blob. The embedded chart database and Redis are fine for a lab and disqualifying for production.
# Production Helm values: external state, multiple stateless replicas.
core:
replicas: 3
jobservice:
replicas: 2
registry:
replicas: 3
database:
type: external
external:
host: harbor-pg.internal
port: "5432"
username: harbor
coreDatabase: registry
sslmode: require
redis:
type: external
external:
addr: harbor-redis.internal:6379
sentinelMasterSet: "" # set when using Redis Sentinel
A backup/restore runbook has exactly three concerns, in priority order:
- PostgreSQL is the crown jewel. Take continuous WAL-archived backups or frequent
pg_dumpsnapshots. Everything else can be rebuilt; lose this and you lose projects, RBAC, policies, robots, and scan history. - Object storage holds the blobs. Enable bucket versioning and cross-region replication at the storage layer; restoring blobs without the matching database leaves dangling manifests.
- Secrets (the Harbor encryption keys /
coresecret, registry HTTP secret). These encrypt sensitive data at rest in the database. Restoring a database backup against a Harbor with different secrets will fail to decrypt stored credentials. Back up the secret material with the database and keep their versions aligned.
Restore order is the inverse of dependency: provision object storage and secrets first, restore PostgreSQL, then bring up the stateless services pointed at all three. Test the restore on a non-prod Harbor quarterly; a backup you have never restored is a hypothesis, not a recovery plan.
Verify
Walk these end-to-end after standing up or changing the platform.
# 1. Health: all components report healthy.
curl -sS "https://harbor.example.com/api/v2.0/health" | jq '.status, .components[].name'
# 2. Robot login + push works, human/admin push is not used by CI.
echo "${ROBOT_SECRET}" | docker login harbor.example.com -u 'robot$payments+ci-pusher' --password-stdin
docker tag alpine:3.20 harbor.example.com/payments/test:probe && docker push harbor.example.com/payments/test:probe
# 3. Scan ran and results are queryable.
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
"https://harbor.example.com/api/v2.0/projects/payments/repositories/test/artifacts/probe/additions/vulnerabilities" \
| jq '."application/vnd.security.vulnerability.report; version=1.1".severity'
# 4. Gate works: push a known-vulnerable image and confirm the pull is blocked.
docker pull harbor.example.com/payments/test:probe # should be denied if prevent_vul triggers
# 5. Proxy cache serves an upstream image through Harbor.
docker pull harbor.example.com/dockerhub-proxy/library/busybox:1.36
# 6. Replication policy executed at least once with success.
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
"https://harbor.example.com/api/v2.0/replication/executions?policy_id=1" | jq '.[0].status'
Expected results: health is healthy for every component; the robot push succeeds and is the only credential CI uses; the vulnerability report returns severities; a High+ image is denied on pull with the gate on; the proxied pull succeeds through Harbor; and the latest replication execution reports Succeeded.
Enterprise scenario
A payments platform team ran a single Harbor in their primary region. Their EKS clusters in a second region pulled base and app images cross-region, and during a regional network degradation, every Pod restart in the second region failed with ErrImagePull because the only registry was unreachable. Worse, their CI fronted Docker Hub directly, so when the incident coincided with Docker Hub tightening anonymous pull limits, even unaffected pipelines started failing with toomanyrequests.
The constraint: the second region’s DR network only permitted inbound-initiated connections to the primary (a hard firewall rule they could not change quickly), so a naive push-based replication from primary to the DR Harbor was blocked at the network layer.
They solved it with two changes. First, a proxy cache project in each regional Harbor fronting Docker Hub with stored credentials, so all external base images came through an audited, rate-limit-resilient local path. Second, pull-based replication initiated from the DR-region Harbor (which could reach the primary), so release tags landed in the regional registry without violating the firewall direction. They scoped replication to release tags only to keep bandwidth bounded:
{
"name": "pull-releases-into-region-b",
"src_registry": {"id": 3},
"dest_registry": null,
"dest_namespace_replace_count": 1,
"trigger": {"type": "scheduled", "trigger_settings": {"cron": "0 */15 * * * *"}},
"filters": [
{"type": "name", "value": "payments/**"},
{"type": "tag", "value": "v*"}
],
"deletion": false,
"override": false,
"enabled": true
}
After the change, a primary-region registry outage no longer stopped second-region deployments, because nodes pulled from their local Harbor, and the 15-minute pull schedule kept release tags current with bounded, predictable cross-region traffic. The proxy cache absorbed the Docker Hub rate-limit problem entirely. The lesson the team took away: replication direction is a network-topology decision, not a preference, and the registry is where you absorb upstream-dependency risk before it reaches your clusters.
Going deeper
Everything above gets you a hardened, multi-team Harbor. This section is what separates “it runs” from “I can defend the design in a security review and debug it when the gate fires at 2 a.m.”
How the pull-time gate is actually enforced
prevent_vul and content-trust are not admission webhooks bolted on the side — they run inside Harbor’s request path. When a client (a developer, a CI job, or a kubelet) requests a manifest, core evaluates the project’s policy against that artifact’s recorded scan result and signature accessories before registry is allowed to serve the manifest. A blocked pull comes back as HTTP 412 Precondition Failed with a message naming the reason (“current image with ‘high’ vulnerabilities cannot be pulled” or “the image is not signed”). Three consequences are easy to miss:
- Unscanned fails closed. With
prevent_vulon, an artifact Harbor has not yet scanned is blocked, not waved through. That is the safe default, but it means a just-pushed image can be un-pullable for the seconds-to-minutes until its scan finishes — size that into deploy timing. - The allowlist is evaluated per decision. An allowlisted CVE is subtracted from the finding set at gate time, so a waiver takes effect immediately and its expiry re-arms the block automatically.
- It gates the manifest, so it covers
docker pullandkubeletalike — anything speaking the registry protocol. It does not reach an image a node already has cached or pulls from a different registry, which is exactly why the cluster-side admission layer below is not optional.
Accessories, OCI referrers, and the signing model
Modern Harbor (2.5+) stores a Cosign signature, an SBOM, or an attestation as an accessory: a separate OCI artifact whose subject points at the image digest, discoverable through the OCI v1.1 Referrers API. In the UI they appear under the artifact’s Accessories tab, and they are what enable_content_trust_cosign checks. Two current-surface caveats matter:
- Notary v1 (Docker Content Trust) is gone. Harbor removed the legacy Notary/DCT signer in v2.11, so
enable_content_trust(the old Notary switch) no longer exists — content trust today means Cosign (enable_content_trust_cosign). If you are reading an old runbook that talks aboutnotary-server/notary-signerpods, it predates your cluster. Notation (the CNCF Notary Project successor) is the other signature format in the ecosystem; Cosign is the path Harbor enforces natively. - Sign the digest, not the tag. Cosign signs
name@sha256:…. Tags move; digests do not. In CI, resolve the tag to its digest first, then sign that digest — otherwise you have signed “whatever:latestmeant this second,” which is not a durable claim. The Sigstore lesson walks the keyless flow end to end.
Harbor 2.11+ can also generate an SBOM (via Trivy) as an accessory on demand or on push — distinct from a vulnerability scan. The scan answers “what is wrong with this image today”; the SBOM answers “what is in this image” so tomorrow’s CVE can be matched without a rebuild.
The scanner is pluggable — Trivy is just the default
Harbor talks to scanners through the Pluggable Scanner API (a documented adapter contract), so Trivy is the shipped default, not a hard dependency. You can register additional scanners (Aqua’s commercial engine, Anchore/Grype-based adapters, and others that implement the spec) and set one as the project or system default; the pull-time gate reads whichever scanner produced the artifact’s report. The older Clair scanner was removed years ago (2.2), so a fresh install is Trivy-first. Knowing the adapter is an HTTP contract is what lets you swap scanners without touching the gate logic.
P2P preheat for large fleets
When 500 nodes roll a new release at once, they can hammer a single registry into a thundering-herd stall. Harbor’s Distributions / preheat feature integrates a peer-to-peer engine (Dragonfly or Uber’s Kraken): you create a preheat policy that pushes a chosen image into the P2P network before the rollout, and nodes then pull from nearby peers instead of all hitting Harbor. For a big-bang deploy this is the difference between a warm rollout and a registry brown-out — the same instinct as the origin-shield/cache-warming pattern in CDN design, applied to image distribution.
Webhooks, metrics, and closing the alerting loop
The recurring warning above — “a new CVE can stop a passing deploy, so wire scans into alerting” — is concrete in Harbor. Each project supports webhook policies that fire on events (PUSH_ARTIFACT, SCANNING_COMPLETED, QUOTA_EXCEED, REPLICATION, TAG_RETENTION, and more) to an HTTP or Slack target, so a failed scan or a near-full quota becomes a page, not a surprise at deploy time. Separately, Harbor exposes Prometheus metrics (enable the exporter in the Helm chart) for scan durations, job queue depth, replication status, and quota usage — the signals you actually alert on. A registry whose gate can block deploys but whose scan failures are invisible is a trap you set for yourself.
OIDC onboarding internals
When Harbor runs in OIDC auth mode, a first login onboards the user (they pick or confirm a Harbor username bound to their OIDC subject), and thereafter Docker/Helm CLI logins use a per-user CLI secret, not the IdP password — because the CLI cannot do an interactive OIDC dance. Group membership comes from a configurable group claim in the token and maps to Harbor groups, which you then grant project roles; an OIDC admin group can auto-grant system admin. The payoff of wiring this correctly: access is managed entirely in your IdP, and de-provisioning a user in the IdP removes their Harbor access without a second system to remember.
Running Harbor on Kubernetes, and where it sits versus the alternatives
The supported path on Kubernetes is the official goharbor/harbor-helm chart: expose core/portal behind an Ingress with TLS, point database, redis, and imageChartStorage at external managed services for production (as in step 8), and scale the stateless services. A community harbor-operator exists but is less actively maintained than the chart — reach for it only if you have a specific operator-pattern reason; the chart is the default. Where Harbor fits against the other artifact planes:
| Registry | Formats | Hosting model | Scanning | Signing | Best when |
|---|---|---|---|---|---|
| Harbor (CNCF) | Container/OCI images + Helm OCI charts | Self-hosted, free (OSS) | Trivy built in (pluggable) | Cosign content-trust native | Kubernetes-first, you want the deploy gate + replication in the registry itself |
| Sonatype Nexus / JFrog Artifactory | Very broad (Maven, npm, PyPI, Docker/OCI, apt, yum…) | Self-hosted (Artifactory also SaaS); paid tiers for HA | Add-on (IQ / Xray) | Add-on | You need one multi-language plane, not just containers — see Deploy Nexus Repository |
| Cloud-native (ECR, Artifact Registry, ACR) | Per-cloud (OCI + a few) | Fully managed | Native scanning | Cosign/OCI signatures supported | You are all-in on one cloud and want zero servers to run |
The honest summary: pick Harbor when the workload is containers on Kubernetes and you want scanning, the pull-time gate, signature enforcement, and replication in the registry as one control plane. Reach for Nexus/Artifactory when you need a broad multi-language artifact plane; reach for a cloud-native registry when you never want to operate the server and live inside one cloud. All three can be a Cosign signing target and sit behind cluster-side admission — the registry choice does not excuse you from defense in depth.
Practice challenges
Escalating from beginner to advanced. Try each against a lab Harbor before opening the solution; every solution ends with a one-line why. Assume HARBOR_ADMIN_PASS is set and the base URL is https://harbor.example.com.
1. (Beginner) Create a pull-only robot for a runtime service account. A production service needs to pull from the payments project but must never push. Create a project-scoped robot with pull-only rights and a 30-day expiry.
<details> <summary>Solution</summary>
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X POST "https://harbor.example.com/api/v2.0/robots" \
-H "Content-Type: application/json" \
-d '{
"name": "runtime-puller",
"duration": 30,
"level": "project",
"permissions": [{
"kind": "project",
"namespace": "payments",
"access": [{"resource": "repository", "action": "pull"}]
}]
}'
Why: least privilege — a puller that cannot push cannot be used to poison the registry if its secret leaks, and the 30-day duration forces rotation. Capture the returned secret immediately; Harbor shows it once.
</details>
2. (Beginner) Turn the deploy gate to critical and read it back. Enable “prevent vulnerable images from running” on payments at the critical threshold, then confirm the metadata took.
<details> <summary>Solution</summary>
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X PUT "https://harbor.example.com/api/v2.0/projects/payments" \
-H "Content-Type: application/json" \
-d '{"metadata": {"prevent_vul": "true", "severity": "critical"}}'
# Read it back
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
"https://harbor.example.com/api/v2.0/projects/payments" \
| jq '.metadata | {prevent_vul, severity}'
Why: severity is the floor — critical blocks only Critical findings, high blocks High and Critical. Start strict-but-narrow (critical) and tighten to high once teams have cleared their backlog, so you do not wall off every deploy on day one.
</details>
3. (Intermediate) Add a proxy-cache project fronting a second upstream. You already proxy Docker Hub; now front GitHub Container Registry (ghcr.io) so ghcr.io/... base images also come through an audited, cached path. Register the endpoint, then create the proxy project.
<details> <summary>Solution</summary>
# 1. Register ghcr.io as a generic OCI registry endpoint.
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X POST "https://harbor.example.com/api/v2.0/registries" \
-H "Content-Type: application/json" \
-d '{
"name": "ghcr",
"type": "docker-registry",
"url": "https://ghcr.io",
"credential": {"access_key": "GHCR_USER_PLACEHOLDER", "access_secret": "GHCR_PAT_PLACEHOLDER"}
}'
# 2. Create the proxy-cache project bound to it (use the returned registry id).
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X POST "https://harbor.example.com/api/v2.0/projects" \
-H "Content-Type: application/json" \
-d '{"project_name": "ghcr-proxy", "registry_id": 8, "metadata": {"public": "false"}}'
Why: the proxy-cache pattern is upstream-agnostic — Harbor supports Docker Hub, another Harbor, ECR/ACR/GCR, Quay, and a generic docker-registry, so every external pull can be funneled through one scanned, rate-limit-resilient door. Store a credential (a GHCR PAT here) to lift the upstream’s anonymous limits.
</details>
4. (Intermediate) Pull release tags from a remote Harbor on a schedule. The remote Harbor (registered as endpoint id: 3) is the source of truth; your local Harbor should pull only v* release tags of payments/** every 15 minutes (a DR-friendly, firewall-safe direction).
<details> <summary>Solution</summary>
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X POST "https://harbor.example.com/api/v2.0/replication/policies" \
-H "Content-Type: application/json" \
-d '{
"name": "pull-payments-releases",
"src_registry": {"id": 3},
"dest_registry": null,
"dest_namespace_replace_count": 1,
"trigger": {"type": "scheduled", "trigger_settings": {"cron": "0 */15 * * * *"}},
"filters": [
{"type": "name", "value": "payments/**"},
{"type": "tag", "value": "v*"}
],
"deletion": false,
"override": false,
"enabled": true
}'
Why: dest_registry: null makes this pull-based — the local Harbor initiates the connection, which is the only direction that works when the firewall lets the DR site reach the primary but not the reverse. The v* filter keeps cross-region bandwidth bounded to real releases. (Harbor cron is 6-field, seconds first.)
</details>
5. (Advanced) Protect releases and prune the dev tail. Make v* tags in payments immutable, add a retention rule keeping only the latest 5 sha-* tags per repository, and dry-run the retention before it can delete anything.
<details> <summary>Solution</summary>
# a) Immutability on release tags (nothing can overwrite or delete v*).
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X POST "https://harbor.example.com/api/v2.0/projects/payments/immutabletagrules" \
-H "Content-Type: application/json" \
-d '{
"disabled": false,
"scope_selectors": {"repository": [{"kind": "doublestar", "decoration": "repoMatches", "pattern": "**"}]},
"tag_selectors": [{"kind": "doublestar", "decoration": "matches", "pattern": "v*"}]
}'
# b) Retention: keep the latest 5 sha-* per repo (returns a retention id).
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X POST "https://harbor.example.com/api/v2.0/retentions" \
-H "Content-Type: application/json" \
-d '{
"algorithm": "or",
"scope": {"level": "project", "ref": <PROJECT_ID>},
"rules": [{
"template": "latestPushedK",
"params": {"latestPushedK": 5},
"scope_selectors": {"repository": [{"kind": "doublestar", "decoration": "repoMatches", "pattern": "**"}]},
"tag_selectors": [{"kind": "doublestar", "decoration": "matches", "pattern": "sha-*"}]
}],
"trigger": {"kind": "Schedule", "settings": {"cron": "0 0 1 * * 0"}}
}'
# c) DRY-RUN the retention before trusting it (use the id from step b).
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X POST "https://harbor.example.com/api/v2.0/retentions/<RETENTION_ID>/executions" \
-H "Content-Type: application/json" \
-d '{"dry_run": true}'
Why: immutability and retention are complementary, not the same job — immutability protects v* from ever changing, retention deletes the sha-* long tail, and immutability wins any conflict. The dry-run report tells you exactly what would be deleted so a mis-scoped selector never eats a release.
</details>
6. (Advanced) Enforce Cosign signatures and prove an unsigned image is refused. Turn on signature enforcement for payments, sign a pushed image keyless in CI, and confirm that an unsigned artifact is denied on pull.
<details> <summary>Solution</summary>
# 1. Enforce Cosign signatures at the project boundary.
curl -sS -u "admin:${HARBOR_ADMIN_PASS}" \
-X PUT "https://harbor.example.com/api/v2.0/projects/payments" \
-H "Content-Type: application/json" \
-d '{"metadata": {"enable_content_trust_cosign": "true"}}'
# 2. In CI: resolve the tag to a digest, then sign THAT digest keyless.
DIGEST=$(docker buildx imagetools inspect harbor.example.com/payments/api:v1.5.0 \
--format '{{.Manifest.Digest}}')
COSIGN_EXPERIMENTAL=1 cosign sign --yes "harbor.example.com/payments/api@${DIGEST}"
# 3. Prove enforcement: an image with no signature accessory is refused.
docker pull harbor.example.com/payments/legacy:unsigned # -> denied, HTTP 412 "image is not signed"
Why: enforcement checks for a valid Cosign accessory (linked via OCI referrers) before serving the manifest, so an unsigned pull comes back 412 Precondition Failed. Signing the digest — not the moving tag — is what makes the claim durable, and pairing it with cluster-side admission (Kyverno / Sigstore policy-controller) closes the gap for pulls Harbor never sees.
</details>
Common beginner mistakes
These are conceptual traps — the wrong mental model, not a mistyped flag.
- “I deleted the tag, so the disk space came back.” Deleting a tag only removes a manifest reference; the layers stay on disk until garbage collection runs with
delete_untagged: true. Watch actual object-storage usage, not the UI tag count — “I deleted 40 GB and nothing changed” is GC not having run yet. - “One scan at push time is enough.” A scan reflects the CVE database at that moment. A vulnerability disclosed next week against a base layer you shipped last month is invisible until a scheduled scan-all re-evaluates existing images. Scanning is a standing process, not a one-shot gate.
- “The registry gate protects my cluster.” It protects pulls Harbor can see. A node that already cached the image, or pulls from a different registry, sails past
prevent_vul. The gate is the first layer; cluster-side admission (Kyverno / Sigstore policy-controller) verifying the same signature is the second. Both must fail closed. - “A robot account is basically a user.” No — it is a scoped machine credential (
robot$project+name) bound to specific resource/action pairs, with its own expiry, and its secret is shown exactly once. CI uses a robot; it never uses a human login oradmin. - “Signature enforcement means the image is safe.” A signature proves who built it and how (provenance); it says nothing about vulnerabilities. Scanning proves the other half. You need both — a signed image full of Criticals is signed and dangerous.
- “Quota is the sum of my tag sizes.” Harbor charges quota against unique blobs after layer deduplication, so ten images sharing a base count that base once — and deleting a tag rarely frees quota until GC removes the now-unreferenced layers.
- “With the gate on, an image that hasn’t been scanned yet slips through.” The opposite —
prevent_vulfails closed: an unscanned artifact is blocked, not admitted. A just-pushed image can be briefly un-pullable until its scan lands, so budget for that in tight deploy windows. - “Replication direction is just a preference.” It is dictated by your firewall. Push-based needs the source to reach the destination; pull-based needs the destination to reach the source. Pick the direction your network actually allows, then filter to release tags so you are not shipping every SHA build across regions.
Glossary
- Registry — the service that stores and serves container images over the OCI distribution protocol. Harbor is a registry plus a control plane around it.
- Project — Harbor’s unit of isolation. Scopes RBAC, quota, retention, immutability, scanning policy, and the deploy gate together. Model one per team or app.
- Repository — a named image stream inside a project (e.g.
payments/api), holding many tags and digests. - Artifact — a single stored object addressed by digest: an image, an image index, or an accessory (signature/SBOM). Tags are movable labels on artifacts.
- Digest — the immutable
sha256:…content address of an artifact. Tags move; digests do not — sign and pin by digest. - Robot account — a scoped, optionally-expiring machine credential (
robot$project+name) bound to resource/action pairs. CI’s identity; never a human oradmin. - RBAC roles — the five fixed project roles: Limited Guest, Guest, Developer, Maintainer, Project Admin. Mapped to IdP groups over OIDC.
- OIDC — the SSO protocol Harbor federates to; group claims map users to Harbor groups and project roles, and CLI logins use a per-user CLI secret.
- Proxy cache project — a project that transparently fronts an upstream registry (Docker Hub, GHCR, ECR…), caching pulls lazily and scanning them like any other image.
- Registry endpoint — a stored connection to a remote registry, referenced by proxy-cache and replication policies via its
registry_id. - Replication — push- or pull-based mirroring of artifacts to/from a remote registry endpoint for geo-distribution, DR, or promotion. Direction follows your firewall.
- Trivy — the default vulnerability scanner Harbor ships, run through the pluggable scanner-adapter API.
- Pluggable Scanner API — the HTTP adapter contract that lets you register scanners other than Trivy and set a default without changing gate logic.
- CVE — a Common Vulnerabilities and Exposures identifier for a known security flaw (e.g.
CVE-2024-12345), the unit a scan reports and an allowlist waives. - CVE allowlist — a scoped, expiring waiver for specific CVE IDs (system- or project-level) that subtracts those findings from the gate decision.
prevent_vul— the project switch that refuses pulls of any artifact scanning at or above the chosenseverity; unscanned artifacts fail closed.- Severity threshold — the floor (
low…critical) at and above whichprevent_vulblocks;highblocks High and Critical. - Content trust (Cosign) — the
enable_content_trust_cosignproject switch that serves only artifacts carrying a valid Cosign signature accessory. - Cosign — the Sigstore tool that signs image digests, keyless (via OIDC) or with a key; Harbor’s native signature format after Notary’s removal.
- Accessory — a separate OCI artifact (signature, SBOM, attestation) whose
subjectpoints at an image digest, discovered via the OCI Referrers API. - SBOM — a Software Bill of Materials listing everything inside an image; Harbor can generate one (via Trivy) as an accessory, distinct from a vulnerability scan.
- Notary / DCT — the legacy Docker Content Trust signer, removed in Harbor 2.11. Content trust today means Cosign.
- Tag retention — a scheduled policy that decides which tags to keep (by “latest K pushed”, filters); everything else becomes eligible for deletion. Dry-run it first.
- Immutability — a rule that prevents matching tags from being overwritten or deleted at all. Wins over retention; protect
v*releases with it. - Quota — a per-project storage limit enforced at push against unique blob usage after deduplication.
- Garbage collection (GC) — the job that removes blobs no longer referenced by any manifest. Until GC runs (
delete_untagged: true), deletes do not free storage. jobservice— Harbor’s async worker pool that runs scans, replication, retention, and GC. When it stalls, those jobs queue forever.- P2P preheat (Dragonfly/Kraken) — Harbor’s “Distributions” feature that pushes an image into a peer-to-peer network before a big rollout so nodes pull from peers, not all from Harbor.
- Webhook policy — a project-level notification on events (
SCANNING_COMPLETED,QUOTA_EXCEED,REPLICATION…) to an HTTP or Slack target, so scan failures page you. - HTTP 412 Precondition Failed — the status a blocked pull returns (too-vulnerable or unsigned); a
kubeletsurfaces it asErrImagePull.