Tutorial

Self-host Harbor Registry on a VPS: complete guide

Deployment8 min read7 steps

Since CVE-2026-21589 shook the Atlassian supply chain, DevOps teams are looking to regain control over every artifact that flows through their pipeline. Docker Hub enforces a 100 pulls/6h rate limit on free accounts, GHCR ties your images to GitHub's retention policy, and neither signs nor scans images by default. Harbor, the CNCF open source registry, solves all three problems in a single deployment: integrated Trivy vulnerability scanning, Cosign signing, granular RBAC and complete audit logs. This guide takes you from Docker Compose installation to production admission policies, on a 4 vCPU / 8 GB RAM VPS.

Contents· Why a private Docker registry with Harbor1/9
  1. 01Why a private Docker registry with Harbor
  2. 02Key benefits of Harbor
  3. 03Quantified prerequisites
  4. 04Installing Harbor v2.15 with Docker Compose
  5. 05Configuration: projects, users and OIDC (ZITADEL)
  6. 06Scanning images with Trivy
  7. 07Security tip: Kubernetes admission policies
  8. 08Troubleshooting: 5 real errors and their causes
  9. 09Harbor vs Docker Hub vs GHCR vs Gitea Registry

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

  1. 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 harbor
  2. Prepare 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.key

    If 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.

  3. Configure harbor.yml

    Copy the template and adjust the essential values:

    cp harbor.yml.tmpl harbor.yml

    Edit 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: false
  4. Run the installer

    Harbor offers optional components. Enable Trivy (scanner) and Notary (signatures):

    ./install.sh --with-trivy --with-notary

    The installer generates a docker-compose.yml and starts all containers. Verify they are all healthy:

    docker compose ps
    # Expected STATUS for each service: healthy
  5. First login and password change

    Open https://registry.example.com in your browser. Log in with admin / the password from harbor.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 account
  6. Create 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 status
  7. Enable 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-alpine

Configure 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

CriterionHarbor self-hostedDocker HubGHCRGitea Registry
HostingYour VPS (EU data)Docker Inc. servers (USA)GitHub/Microsoft servers (USA/EU opt.)Your VPS
Vulnerability scanningIntegrated Trivy, auto on pushDocker Scout (paid)None nativeNone native
Image signingCosign + Notary v2DCT (deprecated)External CosignNone native
Granular RBAC5 roles per projectLimited org rolesGitHub roles (coarse)Basic Gitea roles
Rate limit (pulls)None100/6h (free), 5000/day (Pro)Linked to GitHub limitsNone
Native OIDC/SSOYes (LDAP, OIDC, SAML)No (paid enterprise SSO)GitHub SSO onlyPartial OIDC
Complete audit logsYes (IP, user, action, tag)LimitedPartial via GitHub auditLimited
NIS2/GDPR complianceFull (on-premise data)Contractual onlyMicrosoft DPA availableFull (on-premise)
CostVPS (~€15–30/month)Free / Pro ~$5/monthIncluded in GitHub Free/ProVPS shared with Gitea

Deploy your Harbor registry on a ServOrbit VPS

A 4 vCPU / 8 GB RAM VPS is enough to run Harbor in production for a team of 10 developers. ServOrbit delivers the VM in under 90 seconds, with root SSH access and automatic snapshots included.

Need help?

Browse our help center and FAQ, or reach our team — callback, WhatsApp or email. Support in French, English and Arabic.

Message us on WhatsAppopens in a new tab