Overview
2 AM. Phone buzzed. Disk alert — 95%.
Logged in and found the Harbor server’s /data/registry directory at 200GB, piled with 3,000+ tags. There was app:v0.0.3-beta from last year’s testing, app:feature-branch-xxx left over from failed builds, and ten copies of the same base image — each 800MB+.
This isn’t an isolated case. I’ve seen the same script play out across multiple teams: once CI/CD is up and running, images get pushed but never cleaned. Three months later, the disk explodes. Worse, some images carry known CVEs running in production, and access control is a joke — anyone can push, anyone can pull, no isolation between projects.
This article solves three problems: how to manage image permissions, how to block vulnerable images, and how to clean up storage. I’ll walk through Harbor (CNCF graduated project, 29k+ GitHub stars) from installation to production configuration, with copy-paste-ready configs at every step. Whether you’re setting up your first artifact registry or have one that’s a mess, this will save you some headaches.
Why You Need an Artifact Repository
Let’s clarify one concept: an artifact repository is not Docker Hub.
Docker Hub is a public image hosting platform, similar to npm’s registry. Pulling official images from it is fine. But using it to store your company’s business images? Several hard problems:
- No fine-grained permissions. Docker Hub organizations only have admin and regular members. You can’t set it up so “the QA team can push to dev projects but not touch prod projects”
- No vulnerability scanning. You push it, it’s there. CVEs and all
- No retention policies. Images pile up indefinitely unless you manually delete them
- Unreliable network. Docker Hub rate limits and timeouts are a daily reality, especially in China
Harbor solves exactly these problems. Simply put, Harbor = Docker Registry + Access Control + Vulnerability Scanning + Replication + Audit Logging. It’s like a warehouse with security guards, sorting, and a recycling system — not an open-air dump.
Think of it this way: Docker Hub is a public parking lot where anyone can enter. Harbor is a corporate garage with zones (projects), badge access (RBAC), security checks at the door (vulnerability scanning), and regular towing of abandoned vehicles (GC + retention policies).
Harbor Architecture at a Glance
No jargon dumping. Let’s look at the diagram:
┌─────────────┐
│ Client │ docker push / pull
└──────┬──────┘
│
┌──────▼──────┐
│ Nginx │ Reverse proxy + TLS
│ (Portal) │
└──────┬──────┘
│
┌────────────────┼────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Core API │ │ Registry │ │ JobService │
│ (API+UI) │ │ (Distribution)│ │ (Scan/Replicate/GC)│
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
┌──────▼──────┐ ┌─────▼───────┐ ┌─────▼───────┐
│ PostgreSQL │ │ Storage │ │ Trivy │
│ (Metadata) │ │ (Filesystem) │ │ (Scanner) │
└─────────────┘ └────────────┘ └─────────────┘
Here are the core components, each explained in one sentence:
| Component | Purpose | Analogy |
|---|---|---|
| Core | Handles API requests, manages projects and users | Front desk |
| Registry | Stores image files, based on Docker Distribution | Shelves |
| JobService | Runs async tasks: scanning, replication, GC | Mover |
| PostgreSQL | Stores metadata: projects, users, image tag info | Ledger |
| Trivy | Scans images for CVE vulnerabilities | Security inspector |
| Redis | Caches sessions and job state | Sticky notes |
You don’t need to memorize each component’s internals. Just know: pushing images goes through Nginx → Core → Registry, while scanning and GC run asynchronously via JobService. Understanding this chain gives you a direction when troubleshooting.
Installing Harbor from Scratch
For production, Harbor’s official Helm Chart on K8s is recommended. But if you’re just validating, the docker-compose approach is faster. Both methods below.
Method 1: docker-compose (Dev/Test)
# Download Harbor offline installer
wget https://github.com/goharbor/harbor/releases/download/v2.12.0/harbor-offline-installer-v2.12.0.tgz
tar xzf harbor-offline-installer-v2.12.0.tgz
cd harbor
# Copy config template
cp harbor.yml.tmpl harbor.yml
Key configuration (harbor.yml):
# HTTPS config — production must use TLS
hostname: harbor.yourcompany.com
http:
port: 80
https:
port: 443
certificate: /data/cert/harbor.crt
private_key: /data/cert/harbor.key
# Initial admin password — change immediately after first login
harbor_admin_password: Harbor12345 # Don't use this!
# Data storage directory
data_volume: /data/harbor
# Database config
database:
password: change_this_to_random_string
max_idle_conns: 50
max_open_conns: 100
# Trivy scanner
trivy:
ignore_unfixed: false # Don't ignore unpatched vulnerabilities
skip_update: false # Update vulnerability DB on startup
offline_scan: false
# Garbage collection
garbage_collection:
enabled: true
schedule: "0 3 * * *" # Daily at 3 AM
# Audit log
audit_log:
enabled: true
Install and start:
# Generate self-signed cert (for testing only — use real certs in production)
mkdir -p /data/cert
openssl req -x509 -newkey rsa:4096 -nodes \
-keyout /data/cert/harbor.key \
-out /data/cert/harbor.crt \
-days 365 \
-subj "/CN=harbor.yourcompany.com"
# Install
./install.sh --with-trivy --with-chartmuseum
# Verify after startup
docker-compose ps # All components should be healthy
Method 2: Helm Chart (Production)
# Add Harbor Helm repo
helm repo add harbor https://helm.goharbor.io
helm repo update
# Create values.yaml to override defaults
cat > harbor-values.yaml << 'EOF'
expose:
type: ingress
tls:
enabled: true
certSource: secret
secret:
secretName: harbor-tls
externalURL: https://harbor.yourcompany.com
# Persistent storage — must configure for production
persistence:
persistentVolumeClaim:
registry:
size: 500Gi
storageClass: managed-nfs-storage
database:
size: 20Gi
storageClass: managed-nfs-storage
trivy:
size: 10Gi
storageClass: managed-nfs-storage
# Trivy scanner config
trivy:
enabled: true
skipUpdate: false
# Garbage collection
garbageCollection:
enabled: true
schedule: "0 3 * * *"
# Resource limits
resources:
core:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
# High availability (requires external PostgreSQL and Redis)
# Production should have at least 2 Core replicas
core:
replicas: 2
EOF
# Install
helm install harbor harbor/harbor \
-f harbor-values.yaml \
-n harbor \
--create-namespace
After installation, open https://harbor.yourcompany.com in your browser, log in as admin, and change the password immediately.
Projects and Permission Layering
Harbor’s permission model is straightforward: User → Project → Role. Projects are the unit of isolation, similar to GitLab Groups or GitHub Organizations.
Why Project Isolation Matters
I learned this the hard way on a ride-hailing project. Initially, for simplicity, all images were pushed to a single default project called library. The result:
- A QA colleague accidentally overwrote
payment-service:v2with a test build - Nobody could tell which images were for testing and which were for production
- During a compliance audit (China’s equivalent of FedRAMP), we got dinged for “insufficient access controls”
After splitting into dev, staging, and prod projects with proper permission layering, things finally clicked. Remember: the first line of defense for your image registry isn’t vulnerability scanning — it’s project isolation.
Four Roles and Their Permissions
Harbor’s project roles are just four. Don’t overthink it:
| Role | What They Can Do | Analogy |
|---|---|---|
| Project Admin | Manage project settings, add members, delete images | Warehouse manager |
| Developer | Push, pull, delete own tags | Mover |
| Guest | Pull only | Pickup person |
| Maintainer | Scan images, modify metadata | QA inspector |
Recommended production setup:
prod project:
- SRE team → Project Admin
- CI/CD service account → Developer (push only, no delete)
- Deployment system → Guest (pull only)
dev project:
- Dev team → Developer
- QA team → Maintainer
Managing Permissions via API
Clicking through the UI for dozens of users is tedious. Harbor has a full REST API:
# Create project
curl -X POST "https://harbor.yourcompany.com/api/v2.0/projects" \
-u "admin:your_password" \
-H "Content-Type: application/json" \
-d '{
"project_name": "prod",
"metadata": {
"public": "false",
"auto_scan": "true",
"reuse_sys_cve_allowlist": "false"
}
}'
# Add member to project
curl -X POST "https://harbor.yourcompany.com/api/v2.0/projects/1/members" \
-u "admin:your_password" \
-H "Content-Type: application/json" \
-d '{
"role_id": 2, # 2=Developer, 3=Guest
"entity_type": "user",
"entity_id": 5
}'
For CI/CD pipelines, use Robot Accounts instead of real users:
# Create robot account (push only)
curl -X POST "https://harbor.yourcompany.com/api/v2.0/projects/1/robots" \
-u "admin:your_password" \
-H "Content-Type: application/json" \
-d '{
"name": "ci-pusher",
"level": "project",
"duration": -1,
"permissions": [{
"access": [
{"action": "pull", "resource": "repository"},
{"action": "push", "resource": "repository"}
],
"kind": "project",
"namespace": "prod"
}]
}'
Robot accounts offer: minimal permissions, controllable expiry, no human association. Your pipeline should use these, not someone’s LDAP account — when that person leaves, the pipeline breaks.
Vulnerability Scanning: From Detection to Blocking
Installing Harbor without enabling scanning is like buying a security door and never plugging it in.
How Trivy Integration Works
Since v2.x, Harbor ships with Trivy as the default scanner. Trivy scans each image layer for software packages: apt-installed, pip-installed, npm-installed — all checked against the CVE database.
Scanning triggers in two ways:
- Auto-scan on push: After an image is pushed, JobService kicks off Trivy
- Scheduled full scan: Scans all existing images daily, because the vulnerability DB updates — yesterday’s safe image might not be safe today
Configuring Auto-Scan and Blocking Policies
In Harbor UI: Project Settings → enable “Automatically scan images on push”.
More importantly, configure “Prevent vulnerable images from being deployed”. Harbor calls this “Deployment Security” policy:
# Set project-level security policy via API
# prevent_vul = "true" blocks pulling images with high-severity vulnerabilities
curl -X PUT "https://harbor.yourcompany.com/api/v2.0/projects/1" \
-u "admin:your_password" \
-H "Content-Type: application/json" \
-d '{
"metadata": {
"auto_scan": "true",
"prevent_vul": "true",
"severity": "high"
}
}'
This means: images with High or Critical severity vulnerabilities cannot be pulled. The severity threshold options are none, low, medium, high, critical. For production projects, I recommend high.
Gotcha:
prevent_vulonly works for pull requests proxied through Harbor Core. If your K8s nodes configure Docker daemon’sinsecure-registriesto bypass Harbor Core, the blocking won’t work. Solution: all nodes must pull through the Harbor domain, not direct IP.
Vulnerability Allowlist
Some vulnerabilities you can’t fix immediately — like a glibc version in a base image that would break things if upgraded. Harbor supports CVE allowlists:
# Global CVE allowlist
curl -X PUT "https://harbor.yourcompany.com/api/v2.0/system/CVEWhitelist" \
-u "admin:your_password" \
-H "Content-Type: application/json" \
-d '{
"items": [
{"cve_id": "CVE-2024-12345"},
{"cve_id": "CVE-2024-67890"}
],
"expires_at": 1767225600
}'
Allowlists must have an expiration date. An allowlist without expiry is a permanent pass — same as no scanning. I recommend 30 days. When it expires, either you fixed the vulnerability or you renew — forcing yourself to face it.
During a compliance audit, the auditor specifically checked allowlist records and found a CVE allowlist that had been open for 8 months without review. That was flagged as non-compliant. Lesson: an allowlist buys you time, it doesn’t let you off the hook. For more on false positive management, see Related: CI/CD Security Gate Four-Layer Defense and False Positive Management.
Storage Governance: Retention Policies and Garbage Collection
This is the direct solution to the 200GB disk explosion from the opening.
Retention Policies
Retention policies solve one problem: which tags to keep, which to delete. They automatically clean up unneeded image tags without manual intervention.
Harbor supports these retention rules:
| Rule Type | Meaning | Use Case |
|---|---|---|
| Keep latest N tags | Retain the most recent N versions | dev project, only need recent versions for rollback |
| Keep tags from last N days | Retain recently pushed images | feature branch images, delete when expired |
| Keep tags matching regex | Retain v* format releases | prod project, only keep release versions |
| Keep latest N tags + regex match | Combined rule | prod project: keep last 5 v1.x versions |
Recommended configuration:
dev project:
Retention → keep latest 10 tags, delete rest
Schedule → daily
staging project:
Retention → keep tags from last 7 days
Schedule → daily
prod project:
Retention → keep tags matching v*, latest 20
Schedule → weekly
Configure via API:
# Set retention policy for dev project: keep latest 10 tags
curl -X POST "https://harbor.yourcompany.com/api/v2.0/retentions" \
-u "admin:your_password" \
-H "Content-Type: application/json" \
-d '{
"scope": {
"level": "project",
"ref": 1
},
"trigger": {
"kind": "Schedule",
"settings": {
"cron": "0 3 * * *"
}
},
"rules": [{
"action": "retain",
"template": "latestPulledNCount",
"params": {
"latestPulledNCount": 10
},
"tag_selectors": [{
"kind": "doublestar",
"decoration": "matches",
"pattern": "**"
}]
}]
}'
Note: retention policies delete tag references, not the underlying blob files. Actual disk space reclamation requires garbage collection.
Garbage Collection (GC)
Retention deletes tags, but image layers (blobs) remain on disk. It’s like deleting a shortcut but the installer is still there. Garbage collection (GC) cleans up these orphaned blobs.
Harbor’s GC is based on Docker Distribution’s GC mechanism:
# Manually trigger GC (recommend stopping push operations first)
# Harbor UI → Administration → Garbage Collection → Run Now
# Or via API
curl -X POST "https://harbor.yourcompany.com/api/v2.0/system/gc/schedule" \
-u "admin:your_password" \
-H "Content-Type: application/json" \
-d '{
"schedule": {
"type": "Daily",
"cron": "0 3 * * *"
}
}'
Key behavior during GC: temporarily blocks push operations. GC switches the Registry to read-only mode, scans all blobs, marks orphans for deletion, then restores read-write. For production, schedule this during low-traffic hours.
My recommended cleanup procedure (for existing repositories with accumulated images):
# 1. Configure retention policy to clean unneeded tags
# 2. Wait for retention to execute, confirm deleted tags aren't needed
# 3. Manually trigger GC
# 4. Check GC logs for space released
# View GC execution results
curl -s "https://harbor.yourcompany.com/api/v2.0/system/gc?page=1&page_size=5" \
-u "admin:your_password" | python3 -m json.tool | grep -E "status|space_released"
Measured result: on a 200GB repository with 3,000+ tags, configuring “keep latest 10 tags” policy + running GC once reduced disk usage from 200GB to 45GB. 177GB of blobs were orphaned garbage — no references but occupying disk space.
Storage Quotas
To prevent future disk explosions, set project quotas:
# Set 50GB storage quota for dev project
curl -X PUT "https://harbor.yourcompany.com/api/v2.0/projects/1" \
-u "admin:your_password" \
-H "Content-Type: application/json" \
-d '{
"storage_limit": 53687091200 # 50GB in bytes
}'
Quotas don’t replace retention policies — they’re a safety net. Even if retention is misconfigured, quotas prevent new pushes at 50GB, so the disk won’t explode.
Production Considerations
High Availability Deployment
docker-compose is single-node, not suitable for production. When deploying with Helm Chart on K8s:
- At least 2 Core replicas, fronted by Ingress
- Use external PostgreSQL, not Harbor’s bundled container version (single point of failure)
- Use external Redis, avoid losing sessions on restart
- Use NFS/Ceph/object storage, not emptyDir
The prerequisite for HA is external database and Redis. Harbor’s stateless components (Core, JobService, Portal) can run multiple replicas, but stateful components (PostgreSQL, Redis) if bundled are single points. This design choice determines whether your Harbor can survive node failures.
Cross-Datacenter Replication
For multi-datacenter needs, Harbor’s replication feature handles image synchronization:
Primary Harbor (DC-A) ──push──> Secondary Harbor (DC-B)
│
└── K8s cluster pulls from DC-B directly
Three things to watch when configuring replication:
- Filter rules must be precise. Don’t sync everything — filter by project + tag prefix, only sync
v*production images - Choose manual or scheduled trigger. Event-based real-time sync can lag and pile up when image volume is high
- Bandwidth limits. Don’t saturate cross-DC bandwidth — leave room for business traffic. Harbor supports
--bandwidthlimits
In a cross-datacenter disaster recovery design, we used Harbor replication to achieve RPO<5min image sync. K8s clusters pulling from local Harbor reduced network latency from 30ms (cross-DC) to 1ms (local), directly improving deployment speed. This is the same principle as Related: Docker Image Optimization — reduce transfer size, improve deployment efficiency.
Monitoring Metrics
Harbor exposes a Prometheus metrics endpoint:
# Enable metrics in harbor.yml
metric:
enabled: true
port: 9090
path: /metrics
Key alert rules:
# Prometheus alert rules
groups:
- name: harbor
rules:
# Storage usage > 80%
- alert: HarborStorageHigh
expr: |
harbor_storage_usage_bytes / harbor_storage_capacity_bytes > 0.8
for: 10m
labels:
severity: warning
annotations:
summary: "Harbor storage usage exceeds 80%"
# Scan failure rate > 10%
- alert: HarborScanFailureHigh
expr: |
rate(harbor_scan_total{result="error"}[5m])
/ rate(harbor_scan_total[5m]) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "Harbor scan failure rate exceeds 10%"
# GC failed
- alert: HarborGCFailed
expr: harbor_gc_status{status="failed"} == 1
for: 5m
labels:
severity: critical
annotations:
summary: "Harbor garbage collection failed"
For a complete monitoring setup, refer to Related: Quick Setup: Prometheus Monitoring Stack.
Backup Strategy
Harbor’s core data falls into two categories: PostgreSQL metadata and storage layer blobs. Backup strategy:
| Data Type | Backup Method | Recovery Method | Frequency |
|---|---|---|---|
| PostgreSQL | pg_dump scheduled backup | Restore dump file | Daily |
| Blob files | Storage snapshot (NFS/Ceph) | Rollback snapshot | Daily |
| harbor.yml | Git managed | git checkout | On change |
# PostgreSQL backup script
#!/bin/bash
DATE=$(date +%Y%m%d)
pg_dump -h harbor-db.internal -U postgres harbor | gzip > /backup/harbor-db-${DATE}.sql.gz
# Keep 30 days
find /backup -name "harbor-db-*.sql.gz" -mtime +30 -delete
Don’t back up only the database. Metadata loss means you can rebuild project structure, but blob loss means images are gone. Back up both.
A Complete CI/CD Integration Example
Putting it all together, here’s how to use Harbor in a pipeline:
# .gitlab-ci.yml example
stages:
- build
- push
- scan
build_and_push:
stage: build
image: docker:24
variables:
HARBOR_URL: "harbor.yourcompany.com"
HARBOR_USER: "ci-pusher" # Robot account
HARBOR_PASS: "$HARBOR_CI_TOKEN" # Injected from CI variable
before_script:
# Login to Harbor
- echo "$HARBOR_PASS" | docker login -u "$HARBOR_USER" --password-stdin "$HARBOR_URL"
script:
# Build with multi-stage to reduce size
- docker build -t $HARBOR_URL/prod/app:$CI_COMMIT_TAG .
# Local Trivy scan before push
- trivy image --severity HIGH,CRITICAL --exit-code 1 $HARBOR_URL/prod/app:$CI_COMMIT_TAG
# Only push if scan passes
- docker push $HARBOR_URL/prod/app:$CI_COMMIT_TAG
# Harbor auto-triggers second scan
after_script:
- docker logout $HARBOR_URL
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ # Only push on tags
There are two scanning layers: local Trivy scan in CI + Harbor’s post-push scan. They serve different purposes — CI scan is blocking (fail the build), Harbor scan is auditing (record full vulnerability landscape for later queries).
I recommend CI local scanning for blocking and Harbor for full-scan records. The two complement each other: CI catches new images, Harbor tracks historical ones.
Summary
An artifact repository is not an image dumping ground. Its core value spans three dimensions:
- Permission management: Project isolation + RBAC roles + robot accounts — clear who can push, who can pull
- Security scanning: Trivy auto-scan + vulnerability blocking policy + CVE allowlist management — stop vulnerable images before production
- Storage governance: Retention policies auto-clean unused tags + GC reclaims disk space + quotas as safety net — prevent disk explosions
From my experience, storage governance is the most easily overlooked. Many teams install Harbor, configure scanning, set up projects — but never configure retention or GC. Three months later, disk alerts fire at 2 AM, and someone’s manually deleting images. That’s the opening scenario.
Configuration recommendations in one sentence:
- dev project: keep latest 10 tags, daily GC
- staging project: keep tags from last 7 days, daily GC
- prod project: keep v* latest 20 tags, weekly GC
- All projects: set 50-100GB storage quota as safety net
- Production projects: enable Trivy auto-scan + High severity blocking
Harbor itself isn’t hard to install. What’s hard is building the habit of continuous governance. Image management is the last mile of CI/CD — if this mile goes wrong, all the build speed before it is wasted.
References & Acknowledgments
The following resources were referenced during the writing of this article. Thanks to the original authors:
- Harbor GitHub Repository — Harbor Official Team, CNCF graduated project providing enterprise-grade registry for storing, signing, and scanning container images
- Harbor Official Documentation - Garbage Collection — Harbor Official Documentation, garbage collection mechanism and storage management
- Trivy Official Documentation — Aqua Security, configuration and usage guide for the Trivy vulnerability scanner
- Harbor Helm Chart — Harbor Team, Helm Chart deployment configuration and parameter reference
- CNCF Harbor Project Page — CNCF Foundation, Harbor project overview and community updates