Why a private Docker registry with Harbor
Docker Hub and GHCR are convenient public services, but they create three real problems as your organization grows or must comply with NIS2 (Art. 21 § 2 f – supply chain security) or GDPR (infrastructure data hosted outside the EU). Harbor addresses each point directly.
Key benefits of Harbor
- Integrated Trivy scanning — every push triggers a CVE analysis; admission policies block deployment of images exceeding your configured severity threshold.
- Cosign / Notary signing — chain of trust from CI build to Kubernetes cluster; only images signed by your key pass through.
- Multi-project RBAC — isolation by team, environment (dev/staging/prod) or client, with Guest, Developer, Maintainer and ProjectAdmin roles.
- Cross-registry replication — sync to a second VPS, AWS ECR or Docker Hub for CDN distribution.
- Immutable audit logs — every pull, push, deletion and login is traced with source IP; NIS2 article 21 § 2 d compliance.
- No rate limit — your CI runners pull as many images as needed without delay or quota.
- Native OIDC — connect Harbor to ZITADEL or any OpenID Connect provider; zero local accounts to manage.
Quantified prerequisites
Harbor runs comfortably on a VPS from the following configuration:
- CPU: 4 vCPU (minimum 2 vCPU, but Trivy consumes 1–2 cores during scanning)
- RAM: 8 GB (4 GB minimum; plan 2 GB for PostgreSQL, 1 GB for Redis, 2 GB for Harbor core, 1 GB margin)
- Disk: 100 GB SSD (Docker layers accumulate quickly; allow 40 GB for the registry, 20 GB for Trivy logs, 20 GB for the OS)
- OS: Debian 12 or Ubuntu 22.04 LTS
- Docker: ≥ 24.0, Docker Compose: ≥ 2.20
- DNS: a subdomain pointing to your VPS (registry.example.com), already resolving
- TLS: valid certificate — Let's Encrypt via Certbot or Traefik ACME
Installing Harbor v2.15 with Docker Compose
Download the offline installer
SSH into your VPS. Fetch the Harbor v2.15.4 offline package (most stable as of 2026-10-13) from GitHub:
export HARBOR_VERSION=v2.15.4 wget https://github.com/goharbor/harbor/releases/download/${HARBOR_VERSION}/harbor-offline-installer-${HARBOR_VERSION}.tgz wget https://github.com/goharbor/harbor/releases/download/${HARBOR_VERSION}/harbor-offline-installer-${HARBOR_VERSION}.tgz.asc # Verify GPG signature gpg --keyserver keyserver.ubuntu.com --recv-keys 7722D168DAEC457894C2988818B80B4B2D318C8A gpg --verify harbor-offline-installer-${HARBOR_VERSION}.tgz.asc tar xvf harbor-offline-installer-${HARBOR_VERSION}.tgz cd harborPrepare TLS certificates
Harbor requires TLS. Using Certbot:
apt install -y certbot certbot certonly --standalone -d registry.example.com # Certificates are in /etc/letsencrypt/live/registry.example.com/ mkdir -p /data/cert cp /etc/letsencrypt/live/registry.example.com/fullchain.pem /data/cert/registry.example.com.crt cp /etc/letsencrypt/live/registry.example.com/privkey.pem /data/cert/registry.example.com.keyIf you use Traefik as a reverse proxy (see the Traefik v3 article), delegate TLS termination to Traefik and configure Harbor in HTTP internally on port 8080.
Configure harbor.yml
Copy the template and adjust the essential values:
cp harbor.yml.tmpl harbor.ymlEdit
harbor.yml:hostname: registry.example.com https: port: 443 certificate: /data/cert/registry.example.com.crt private_key: /data/cert/registry.example.com.key harbor_admin_password: ChangeThisPassword! database: password: ChangeThisToo! data_volume: /data trivy: ignore_unfixed: false skip_update: false offline_scan: false security_check: vuln insecure: falseRun the installer
Harbor offers optional components. Enable Trivy (scanner) and Notary (signatures):
./install.sh --with-trivy --with-notaryThe installer generates a
docker-compose.ymland starts all containers. Verify they are allhealthy:docker compose ps # Expected STATUS for each service: healthyFirst login and password change
Open
https://registry.example.comin your browser. Log in withadmin/ the password fromharbor.yml. Go to Administration → Users → admin and immediately change the password.From your workstation or CI runner:
docker login registry.example.com # Enter your admin credentials or a service accountCreate a project and push your first image
In the Harbor interface, create a project
my-project(private). Then from your build machine:docker pull nginx:alpine docker tag nginx:alpine registry.example.com/my-project/nginx:1.27-alpine docker push registry.example.com/my-project/nginx:1.27-alpine # In Harbor → my-project → Repositories: the image appears with its Trivy statusEnable automatic TLS renewal
Create a Certbot post-renewal hook to reload Harbor:
cat > /etc/letsencrypt/renewal-hooks/post/harbor-reload.sh << 'EOF' #!/bin/bash cp /etc/letsencrypt/live/registry.example.com/fullchain.pem /data/cert/registry.example.com.crt cp /etc/letsencrypt/live/registry.example.com/privkey.pem /data/cert/registry.example.com.key cd /opt/harbor && docker compose restart proxy EOF chmod +x /etc/letsencrypt/renewal-hooks/post/harbor-reload.sh
Configuration: projects, users and OIDC (ZITADEL)
Projects and RBAC
Harbor organizes images by projects. Each project can be public or private, with an independent retention policy. Create one project per team or environment: frontend, backend, infra. Assign members with granular roles:
- Guest: pull only
- Developer: push + pull
- Maintainer: manage tags, webhooks and retention policies
- ProjectAdmin: manage members
OIDC integration with ZITADEL
If you have already deployed ZITADEL (see the dedicated article), connect Harbor in a few clicks. In ZITADEL, create a harbor application of type *Web*, note the Client ID and Client Secret. In Harbor → Administration → Configuration → Authentication:
- Auth Mode: OIDC
- OIDC Provider Name: ZITADEL
- OIDC Endpoint: https://sso.example.com
- OIDC Client ID / Secret: from ZITADEL
- OIDC Scope: openid profile email groups
- Check Verify Certificate
On first OIDC login, Harbor automatically creates the user account. Then disable local account creation (Administration → Configuration → Allow Self-Registration → Off).
Scanning images with Trivy
Harbor embeds Trivy directly into the push pipeline. As soon as an image is pushed, Harbor triggers an asynchronous scan that queries Trivy's CVE database (updated daily). The result appears in the interface: number of vulnerabilities by severity (CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN).
Enable automatic scan on push: in Harbor → Administration → Configuration → Vulnerability → check *Automatically scan images on push*.
View a report: open a repository, click on a tag → *Vulnerabilities* tab. Each CVE lists the affected package, the fixed version and a link to the NVD.
Trivy version in Harbor v2.15: Harbor v2.15.4 embeds trivy-adapter aligned with Trivy v0.55.x, supporting OS databases (Alpine, Debian, RHEL, Ubuntu), application package managers (npm, pip, cargo, go.mod) and lock files.
Compliance policies per project: in Harbor → *Your project* → Policy → *Deployment Security* → choose the severity level that blocks pulls. For example, block any pull of an image with at least one unpatched CRITICAL CVE.
Security tip: Kubernetes admission policies
Combine Harbor with the Cosign admission controller (via Kyverno or Connaisseur) so no unsigned image or one with a CRITICAL CVE can enter your cluster. In your CI pipeline:
# Sign the image with Cosign after the build
cosign sign --key cosign.key registry.example.com/my-project/nginx:1.27-alpine
# Verify in CI before deployment
cosign verify --key cosign.pub registry.example.com/my-project/nginx:1.27-alpineConfigure a Kyverno policy requiring Harbor signing: any pod whose image is not signed by your key is rejected at admission. This closes the most exploited gap in supply chain attacks following CVE-2026-21589.
Troubleshooting: 5 real errors and their causes
1. Error response from daemon: Get https://registry.example.com/v2/: x509: certificate signed by unknown authority
Cause: the Let's Encrypt certificate has not yet propagated or you are using a self-signed certificate.
Fix: on each Docker client node, copy fullchain.pem to /etc/docker/certs.d/registry.example.com/ca.crt then systemctl restart docker.
2. harbor-core restarting in a loop with Error 1045 (28000): Access denied for user 'root'@'localhost'
Cause: the PostgreSQL volume contains data from a previous installation with a different password.
Fix: docker compose down -v (⚠️ deletes data), modify the password in harbor.yml, rerun ./prepare && docker compose up -d.
3. Trivy scan stuck on Fetching vulnerability DB for 10+ minutes
Cause: the VPS cannot reach ghcr.io (restrictive outbound firewall) to download the Trivy database.
Fix: allow outbound HTTPS to ghcr.io and pkg.dev, or enable offline_scan: true after pre-loading the database with trivy image --download-db-only.
4. unauthorized: unauthorized to access repository in CI despite a successful docker login
Cause: the service account does not have the Developer role on the target project.
Fix: in Harbor → *Your project* → Members → add the robot or user account with the Developer role or higher.
5. Harbor interface unreachable after Let's Encrypt certificate renewal
Cause: the proxy container (nginx) continues to serve the old certificate loaded in memory.
Fix: docker compose restart proxy from the Harbor installation folder. Ideally, automate this via the post-renewal hook described in step 7.
Harbor vs Docker Hub vs GHCR vs Gitea Registry
Scroll the table
| Criterion | Harbor self-hosted | Docker Hub | GHCR | Gitea Registry |
|---|---|---|---|---|
| Hosting | Your VPS (EU data) | Docker Inc. servers (USA) | GitHub/Microsoft servers (USA/EU opt.) | Your VPS |
| Vulnerability scanning | Integrated Trivy, auto on push | Docker Scout (paid) | None native | None native |
| Image signing | Cosign + Notary v2 | DCT (deprecated) | External Cosign | None native |
| Granular RBAC | 5 roles per project | Limited org roles | GitHub roles (coarse) | Basic Gitea roles |
| Rate limit (pulls) | None | 100/6h (free), 5000/day (Pro) | Linked to GitHub limits | None |
| Native OIDC/SSO | Yes (LDAP, OIDC, SAML) | No (paid enterprise SSO) | GitHub SSO only | Partial OIDC |
| Complete audit logs | Yes (IP, user, action, tag) | Limited | Partial via GitHub audit | Limited |
| NIS2/GDPR compliance | Full (on-premise data) | Contractual only | Microsoft DPA available | Full (on-premise) |
| Cost | VPS (~€15–30/month) | Free / Pro ~$5/month | Included in GitHub Free/Pro | VPS shared with Gitea |