How to Install Harbor on Ubuntu 24.04 VPS: Self-Hosted Container Image Registry with Scanning
Container images are the new application artifact. Whether you ship microservices to Kubernetes, build GitOps pipelines, or package internal tools for your team, you need somewhere reliable to store and distribute those images. Docker Hub's public tier throttles anonymous pulls to 100 per six hours, team plans start at USD 11 per user per month, and AWS ECR charges USD 0.10 per GB-month of storage plus egress fees that add up fast once your nodes start pulling images hundreds of times a day. This guide walks you through installing Harbor, the CNCF-graduated open-source registry, on a single Ubuntu 24.04 VPS with Trivy vulnerability scanning, replication, webhook notifications, and optional S3/MinIO object storage.
By the end you will have a hardened, HTTPS-protected registry at harbor.yourdomain.com that your developers and CI/CD runners can docker push and docker pull against exactly like Docker Hub or ECR -- except you own the data, there are no egress fees, and every image is automatically scanned for CVEs.
Skip the manual setup? Deploy a Harbor-ready VPS from our CloudCore Professional plan with Docker Engine pre-installed for EUR 19.99/month.
Table of Contents
What is Harbor?
Harbor is an open-source cloud-native registry that stores, signs, and scans container images and Helm charts. Originally created at VMware and donated to the Cloud Native Computing Foundation, it became a CNCF Graduated project in 2020 alongside Kubernetes, Prometheus, and Envoy -- the highest maturity tier in the CNCF landscape.
At its core Harbor is a collection of Docker-Compose-orchestrated services: the upstream Docker distribution registry for blob storage, a PostgreSQL database for metadata, Redis for job state, an Nginx proxy for TLS termination, a core API server, a job service for async tasks like replication and garbage collection, and optional components like Trivy for vulnerability scanning and Notary for image signing.
What makes Harbor different from a plain docker registry:2 container is the feature surface around the registry:
- Multi-tenancy with projects -- each project is an isolated namespace with its own access policies, quotas, and replication rules.
- Role-based access control (RBAC) -- project admins, developers, maintainers, and limited guests map to standard team workflows.
- Robot accounts -- long-lived tokens for CI/CD and Kubernetes image pulls that are scoped per-project and can be rotated independently of human users.
- Vulnerability scanning -- Trivy scans every pushed image against the Aqua Security vulnerability database and surfaces CVEs in the UI and API.
- Replication -- mirror repositories to or from Docker Hub, GitHub Container Registry (GHCR), quay.io, AWS ECR, Azure ACR, Google GCR, and other Harbor instances.
- Webhooks -- fire HTTP POSTs to Slack, Discord, Jenkins, ArgoCD, or your own API when images are pushed, scanned, replicated, or deleted.
- Tag retention and immutability -- policies that keep the last N tags of each repository and prevent accidental overwrites.
- OCI artifact support -- stores not just Docker images but Helm 3 charts, OPA bundles, WebAssembly modules, and any OCI artifact.
- Image signing via Cosign/Notary -- cryptographically sign images and enforce signed-only pull policies.
Why Self-Host a Registry vs Docker Hub or ECR?
Moving your container registry onto your own VPS is one of the highest-leverage infra decisions a small-to-medium team can make. The economics are compelling, and the operational benefits compound over time.
Cost Comparison: Harbor vs Docker Hub vs AWS ECR
| Scenario | Docker Hub Team | AWS ECR | Self-Hosted Harbor |
|---|---|---|---|
| Monthly base cost | USD 11/user/mo (5 users = USD 55) | USD 0 (pay as you go) | EUR 19.99/mo flat |
| Storage cost | Included | USD 0.10 / GB-month | Included in VPS disk |
| Pull bandwidth (500 GB/mo) | Included | ~USD 45/mo egress | Included (unmetered) |
| Private image scanning | Paid add-on | USD 0.09 per scan (basic free) | Included (Trivy) |
| Pull rate limits | 200 pulls / 6hr (team) | 10,000 req/sec per account | None |
| Replication to other registries | Not supported | Cross-Region Replication (paid) | Included |
| Webhook integrations | Limited | EventBridge (extra setup) | Native, unlimited |
| Data sovereignty | US only | Per-region | Full control |
| Image signing | Paid (DCT deprecated) | ECR Signer (extra tooling) | Included (Notary/Cosign) |
- No rate limits on pulls -- GitOps agents like ArgoCD or Flux can re-pull images on every reconcile loop without tripping quotas.
- Data sovereignty -- images containing proprietary binaries, licensed software, or regulated workloads stay on infrastructure you control.
- Air-gapped and offline workflows -- deploy Harbor into a private network with no internet access and still run a full CI/CD pipeline.
- Custom retention and compliance -- keep images for the 7-year retention your auditor requires, or purge them in 30 days, with per-project rules.
- Unified UI for humans and pipelines -- no more juggling three different registry web consoles for different environments.
Prerequisites
Before you start, confirm you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access.
- A domain name (for example
harbor.yourdomain.com) with an A record pointing to the VPS public IP. - Ports 80 and 443 open in your firewall / cloud provider security group.
- At least 4 vCPU, 8 GB RAM, and 60 GB of disk -- Trivy adds ~2 GB of RAM overhead and the vulnerability database itself is ~1 GB.
- Docker Engine 20.10+ and Docker Compose v2 (we install both below).
- OpenSSL (pre-installed on Ubuntu).
Recommended Plan: CloudCore Professional>
Harbor runs comfortably on the CloudCore Professional plan:>
- 6 vCPU cores
- 12 GB RAM
- 100 GB NVMe SSD
- Unmetered bandwidth
- EUR 19.99/month>
This gives you headroom for Harbor + Trivy + Redis + PostgreSQL + your largest concurrent pushes. For teams storing more than 200 GB of images, pair it with block storage or an S3 backend (see Step 13).
If you have not already set up Docker on the host, start with our Docker install guide -- Step 2 below mirrors its key commands.
Connect to your server:
ssh root@your-server-ipStep 1: Prepare the Ubuntu 24.04 Server
Update package lists and upgrade existing packages:
sudo apt update && sudo apt upgrade -yInstall the baseline utilities we will need (curl, git, openssl, vim, and the UFW firewall):
sudo apt install -y curl git openssl vim ufwOpen the ports Harbor uses and enable the firewall:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
sudo ufw statusExpected output:
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW AnywhereSet a hostname that matches the DNS record:
sudo hostnamectl set-hostname harbor.yourdomain.comStep 2: Install Docker Engine and Docker Compose
Harbor ships as a set of Docker Compose services, so we need a working Docker Engine + Compose v2 plugin.
Add Docker's official repository and install:
sudo apt install -y ca-certificates gnupg lsb-release sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpgecho \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Confirm the versions (Harbor 2.10+ requires Docker 20.10+ and Compose v2):
docker --version
docker compose versionExpected output:
Docker version 26.1.3, build b72abbb
Docker Compose version v2.27.0Enable Docker at boot and add your user to the docker group (log out and back in for it to take effect):
sudo systemctl enable --now docker
sudo usermod -aG docker "$USER"For a more detailed walkthrough including post-install hardening, see our Docker Ubuntu install guide.
Step 3: Obtain TLS Certificates
Harbor strongly recommends HTTPS -- the Docker CLI refuses to push to plain HTTP registries by default, and disabling that safeguard cluster-wide is a non-starter for real teams.
Option A: Let's Encrypt via Certbot (Recommended)
If harbor.yourdomain.com resolves publicly to your VPS, grab a free certificate:
sudo apt install -y certbot
sudo certbot certonly --standalone -d harbor.yourdomain.com \
--non-interactive --agree-tos -m [email protected]Certbot writes certs to /etc/letsencrypt/live/harbor.yourdomain.com/. Copy them to a location Harbor will reference:
sudo mkdir -p /data/cert
sudo cp /etc/letsencrypt/live/harbor.yourdomain.com/fullchain.pem /data/cert/harbor.crt
sudo cp /etc/letsencrypt/live/harbor.yourdomain.com/privkey.pem /data/cert/harbor.key
sudo chmod 644 /data/cert/harbor.crt
sudo chmod 600 /data/cert/harbor.keySet up auto-renewal with a deploy hook that restarts Harbor after renewal:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/harbor.sh > /dev/null <<'EOF'
#!/bin/bash
cp /etc/letsencrypt/live/harbor.yourdomain.com/fullchain.pem /data/cert/harbor.crt
cp /etc/letsencrypt/live/harbor.yourdomain.com/privkey.pem /data/cert/harbor.key
cd /opt/harbor && docker compose restart proxy
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/harbor.shOption B: Self-Signed Certificate (Internal / Dev)
For an internal registry without public DNS, generate a self-signed cert:
sudo mkdir -p /data/cert && cd /data/certsudo openssl genrsa -out ca.key 4096 sudo openssl req -x509 -new -nodes -sha512 -days 3650 \ -subj "/C=US/ST=CA/L=SF/O=example/OU=IT/CN=harbor-ca" \ -key ca.key -out ca.crt
sudo openssl genrsa -out harbor.key 4096 sudo openssl req -sha512 -new \ -subj "/C=US/ST=CA/L=SF/O=example/OU=IT/CN=harbor.yourdomain.com" \ -key harbor.key -out harbor.csr
sudo tee v3.ext > /dev/null <<EOF authorityKeyIdentifier=keyid,issuer basicConstraints=CA:FALSE keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment extendedKeyUsage = serverAuth subjectAltName = @alt_names [alt_names] DNS.1=harbor.yourdomain.com EOF
sudo openssl x509 -req -sha512 -days 3650 \ -extfile v3.ext \ -CA ca.crt -CAkey ca.key -CAcreateserial \ -in harbor.csr -out harbor.crt
Clients pulling from a self-signed Harbor must trust ca.crt -- copy it into /etc/docker/certs.d/harbor.yourdomain.com/ca.crt on every Docker host that needs access.
Step 4: Download the Harbor Offline Installer
Harbor offers two installer flavors: online (pulls images at install time) and offline (bundles all images in the tarball). We use the offline installer because it is reproducible and works on air-gapped hosts.
Check the latest release and download it:
cd /opt
sudo curl -L -O https://github.com/goharbor/harbor/releases/download/v2.11.1/harbor-offline-installer-v2.11.1.tgz
sudo tar xzvf harbor-offline-installer-v2.11.1.tgz
cd harbor
ls -laExpected contents:
common.sh
harbor.v2.11.1.tar.gz
harbor.yml.tmpl
install.sh
LICENSE
prepareThe harbor.v2.11.1.tar.gz is the bundle of container images; install.sh loads them into Docker and then runs docker compose up.
Step 5: Configure harbor.yml
Copy the template to harbor.yml, then edit it:
sudo cp harbor.yml.tmpl harbor.yml
sudo vim harbor.ymlKey settings to change (leave others at default unless noted):
# The FQDN clients will use to reach Harbor
hostname: harbor.yourdomain.comPlain HTTP listener (kept on 80 for Let's Encrypt HTTP-01 redirects)
http:
port: 80HTTPS listener with the certs from Step 3
https:
port: 443
certificate: /data/cert/harbor.crt
private_key: /data/cert/harbor.keyAdmin password for first UI login (change this!)
harbor_admin_password: ChangeMeToAStrongPasswordPostgreSQL password
database:
password: root123
max_idle_conns: 100
max_open_conns: 900Where Harbor stores images, DB, and logs on the host
data_volume: /dataLog rotation
log:
level: info
local:
rotate_count: 50
rotate_size: 200M
location: /var/log/harborScheduled jobs (garbage collection, scans, replication)
jobservice:
max_job_workers: 10Notifications
notification:
webhook_job_max_retry: 3
webhook_job_http_client_timeout: 3Save and exit. A couple of notes:
data_volume: /datais where all persistent state lives. Back up this directory and the PostgreSQL volume together when you snapshot.harbor_admin_passwordis only used during the firstinstall.shrun. After install, change it in the UI or via the API -- editingharbor.ymllater has no effect on existing admin accounts.- If you are using S3 or MinIO for blob storage, see Step 13 before running
install.sh-- the storage backend cannot easily be swapped after install without a data migration.
Step 6: Run install.sh with Trivy and Notary
The installer accepts feature flags. For a modern stack you want Trivy (vulnerability scanning) and optionally Notary (legacy image signing; Cosign is the modern alternative but Harbor still ships Notary):
sudo ./install.sh --with-trivy --with-notaryNote: Harbor 2.11+ has deprecated Notary in favor of Cosign verification. If you are on Harbor 2.12+, drop --with-notary and use Cosign instead (Harbor has native Cosign signature viewing in the UI). For this guide we show the full historical flag set:
sudo ./install.sh --with-trivyExpected output (abbreviated):
[Step 0]: checking if docker is installed ... Note: docker version: 26.1.3 [Step 1]: checking docker-compose is installed ... Note: Docker Compose version v2.27.0 [Step 2]: loading Harbor images ... Loaded image: goharbor/redis-photon:v2.11.1 Loaded image: goharbor/harbor-portal:v2.11.1 Loaded image: goharbor/harbor-core:v2.11.1 Loaded image: goharbor/harbor-jobservice:v2.11.1 Loaded image: goharbor/registry-photon:v2.11.1 Loaded image: goharbor/nginx-photon:v2.11.1 Loaded image: goharbor/trivy-adapter-photon:v2.11.1 ... [Step 3]: preparing environment ... [Step 4]: preparing harbor configs ... ... [Step 5]: starting Harbor ... [+] Running 11/11 Container harbor-log Started Container registry Started Container redis Started Container harbor-portal Started Container registryctl Started Container harbor-db Started Container harbor-core Started Container trivy-adapter Started Container harbor-jobservice Started Container nginx Started
--- Harbor has been installed and started successfully.----
Confirm the stack is healthy:
sudo docker compose psEvery container should show Up (healthy). Browse to https://harbor.yourdomain.com -- you will see the Harbor login screen.
Step 7: First Login and Push Test
Log in with username admin and the password you set in harbor.yml.
From any Docker client machine, log in to your new registry:
docker login harbor.yourdomain.com -u adminEnter the admin password when prompted. Then push a test image:
docker pull nginx:1.27-alpine
docker tag nginx:1.27-alpine harbor.yourdomain.com/library/nginx:1.27-alpine
docker push harbor.yourdomain.com/library/nginx:1.27-alpineExpected output:
The push refers to repository [harbor.yourdomain.com/library/nginx]
a8b77d40d045: Pushed
4dcab49015d4: Pushed
...
1.27-alpine: digest: sha256:abcd1234... size: 1362Refresh the Harbor UI: under Projects > library > Repositories you should see nginx with one artifact.
Step 8: Create Projects and Robot Accounts
Harbor organizes images into projects. The default library project is public; real work should go into private projects with scoped RBAC.
Create a Private Project
In the UI: Projects > + New Project.
- Project Name:
production - Access Level: Private (clear the Public checkbox)
- Storage quota: set a per-project limit (for example 50 GB) to prevent runaway usage
curl -u admin:ChangeMeToAStrongPassword -X POST \
https://harbor.yourdomain.com/api/v2.0/projects \
-H "Content-Type: application/json" \
-d '{
"project_name": "production",
"metadata": {"public": "false"},
"storage_limit": 53687091200
}'Create a Robot Account for CI/CD
Robot accounts are long-lived tokens scoped to one project -- perfect for Kubernetes imagePullSecret or GitHub Actions.
Navigate to Projects > production > Robot Accounts > + New Robot Account.
- Name:
gha-ci - Expires in: 365 days
- Permissions: Push + Pull on Repository, Pull on Artifact
robot$production+gha-ci.Use the robot token in GitHub Actions:
- name: Log in to Harbor
uses: docker/login-action@v3
with:
registry: harbor.yourdomain.com
username: ${{ secrets.HARBOR_ROBOT_USER }}
password: ${{ secrets.HARBOR_ROBOT_TOKEN }}Or in Kubernetes -- this pairs well with a K3s cluster:
kubectl create secret docker-registry harbor-pull \ --namespace production \ --docker-server=harbor.yourdomain.com \ --docker-username='robot$production+gha-ci' \ --docker-password='<paste-token-here>'
kubectl patch serviceaccount default -n production \ -p '{"imagePullSecrets": [{"name": "harbor-pull"}]}'
Step 9: Configure Replication Rules
Replication mirrors images between registries on a schedule or on every push. Common use cases:
- Pull-through cache for Docker Hub so your nodes never hit public rate limits.
- Push to staging -> promote to production by replicating across two Harbor instances.
- Backup by mirroring to a geographically separate Harbor or S3 bucket.
Add an Upstream Registry (Docker Hub)
Administration > Registries > + New Endpoint:
- Provider: Docker Hub
- Name:
docker-hub-upstream - Access ID: your Docker Hub username
- Access Secret: a Docker Hub personal access token
- Test Connection, then save.
Create a Pull-Through Replication Rule
Administration > Replications > + New Replication Rule:
- Name:
mirror-nginx-from-dockerhub - Replication Mode: Pull-based
- Source Registry:
docker-hub-upstream - Source Resource Filter: name matches
library/nginx, tag matches1.27-* - Destination:
library/nginx-mirror - Trigger Mode: Scheduled (cron
0 /6for every 6 hours) or Event-Based
nginx:1.27-* tags from Docker Hub into your local registry every six hours. Kubernetes nodes that pull harbor.yourdomain.com/library/nginx-mirror:1.27.2 get the image from your VPS without ever hitting hub.docker.com.Push Replication to a Second Harbor
If you run two Harbor instances (for example dev and prod), register the destination Harbor as an endpoint, then create a Push-based rule with Trigger Mode: Event-Based. Every time an image lands in production/* on dev, Harbor forwards it to prod immediately.
Step 10: Enable Trivy Vulnerability Scanning
If you ran install.sh with --with-trivy, the scanner is already running as the trivy-adapter container. The first run downloads the ~1 GB Aqua Trivy vulnerability database from GitHub, which takes 2-3 minutes.
Trigger a Manual Scan
Projects > production > Repositories > nginx > (click tag) > Scan.
Within a few seconds Harbor shows a CVE breakdown: Critical / High / Medium / Low / Negligible counts with links to NVD entries for each vulnerability.
Schedule Automatic Scans
Administration > Interrogation Services > Vulnerability:
- Scan All:
Dailyat02:00 - Auto-scan on push: enable at the project level (Projects > production > Configuration > Automatically scan images on push)
Block Pulls of Vulnerable Images
In Projects > production > Configuration:
- Check Prevent vulnerable images from running
- Severity:
High(orCritical)
docker pull harbor.yourdomain.com/production/myapp:tag where the image has an unfixed High or Critical CVE will receive an authorization error. This turns Harbor into a supply-chain enforcement point -- developers can still push vulnerable images during builds, but they cannot promote or deploy them.Update the Trivy Database
The Trivy container refreshes the DB every 12 hours automatically. To force an immediate refresh:
sudo docker compose exec trivy-adapter /home/scanner/entrypoint.shOr restart the container:
sudo docker compose restart trivy-adapterStep 11: Webhook Notifications
Webhooks let Harbor notify other systems when artifacts are pushed, pulled, deleted, scanned, or replicated.
Projects > production > Webhooks > + New Webhook:
- Name:
slack-pushes - Event Type: select
Artifact pushed,Scanning completed,Scanning failed - Notification Type: HTTP
- Endpoint URL:
https://hooks.slack.com/services/T000.../B000.../xxxx(or your own API) - Auth Header: optional bearer token
- Verify Remote Certificate: on
{
"type": "PUSH_ARTIFACT",
"occur_at": 1713283200,
"operator": "robot$production+gha-ci",
"event_data": {
"resources": [{
"digest": "sha256:abcd1234...",
"tag": "v1.2.3",
"resource_url": "harbor.yourdomain.com/production/api:v1.2.3"
}],
"repository": {
"name": "api",
"namespace": "production",
"repo_full_name": "production/api"
}
}
}For Slack specifically, put a small translator in front of the webhook (or use a serverless function) to convert Harbor's payload into Slack's blocks format. You can also point webhooks at ArgoCD's image updater or Jenkins to trigger a redeploy on push.
Step 12: Garbage Collection
Deleting a tag in the Harbor UI marks the underlying blob as unreferenced, but does not immediately reclaim disk space. A garbage collection (GC) job must run to actually delete orphaned blobs from the registry storage backend.
Run GC Manually
Administration > Garbage Collection > + GC Now.
Harbor puts the registry into read-only mode during GC (typically 30 seconds to 5 minutes depending on blob count). Clients attempting to push during this window receive a 503.
Schedule Weekly GC
On the same page:
- Schedule: Weekly, Sunday at
03:00UTC - Delete Untagged Artifacts: on
- Dry Run: off
Pair GC with Tag Retention Rules
Projects > production > Policy > Tag Retention > + Add Rule:
- Matching Repository:
** - Rule:
retain the most recently pushed 20 artifacts - Except: tags matching
prod-orv..(keep release tags forever)
pr-123-abc1234 tags.Step 13: Use S3 or MinIO as the Storage Backend
By default Harbor writes image blobs to /data/registry on the host. For production you probably want object storage for elastic capacity, built-in durability, and offsite backup. Harbor's underlying distribution registry supports S3, GCS, Azure Blob, Swift, and S3-compatible providers like MinIO, Backblaze B2, Wasabi, and Cloudflare R2.
The cleanest path is to configure the backend before you first run install.sh. Edit harbor.yml and uncomment the storage_service block:
storage_service:
ca_bundle:
s3:
region: us-east-1
regionendpoint: https://minio.yourdomain.com
bucket: harbor-registry
accesskey: AKIA...
secretkey: <secret>
secure: true
v4auth: true
chunksize: 5242880
rootdirectory: /
redirect:
disable: falseIf you are using self-hosted MinIO, the regionendpoint is your MinIO API URL and region can be any placeholder (MinIO ignores it). For AWS S3, omit regionendpoint and set region to the bucket region.
Then run install.sh as usual. The registry container will write all new blobs to S3/MinIO instead of the local disk.
Switching Storage Post-Install
If you already installed Harbor with local storage and want to migrate, the process is:
docker compose down the stack.rclone sync /data/registry s3-remote:harbor-registry to copy blobs to object storage.common/config/registry/config.yml (generated file) with the S3 stanza.docker compose up -d.Because this touches generated config it can break on the next install.sh upgrade. For that reason we strongly recommend configuring S3 before the first install.
Cost Model
Storing 500 GB of images on AWS S3 Standard runs ~USD 11.50/month + egress. On self-hosted MinIO using your own Contabo / Hetzner disks it is effectively free past the flat VPS cost. A common production pattern: Harbor on CloudCore Professional + MinIO on a second VPS with a large block volume, connected via private networking.
Step 14: Upgrade Harbor
Harbor ships a new minor release roughly every 3-4 months with security fixes. The upgrade process is straightforward because all state lives in /data and the PostgreSQL volume.
Backup First
cd /opt/harbor
sudo docker compose downSnapshot the data directory
sudo tar czf /root/harbor-data-$(date +%F).tar.gz /dataDump the PostgreSQL database
sudo docker compose up -d postgresql
sudo docker exec -t harbor-db pg_dumpall -U postgres > /root/harbor-db-$(date +%F).sql
sudo docker compose downInstall the New Release
cd /opt sudo curl -L -O https://github.com/goharbor/harbor/releases/download/v2.12.0/harbor-offline-installer-v2.12.0.tgz sudo tar xzvf harbor-offline-installer-v2.12.0.tgz -C /opt/harbor-new --strip-components=1Copy your existing config into the new directory
sudo cp /opt/harbor/harbor.yml /opt/harbor-new/harbor.ymlSwap directories
sudo mv /opt/harbor /opt/harbor-old sudo mv /opt/harbor-new /opt/harbor
cd /opt/harbor sudo ./install.sh --with-trivy
Harbor's built-in migrator detects the schema version on disk and runs any required PostgreSQL migrations automatically. Watch the output for DB schema upgrade completed before the stack comes up.
Rollback
If the upgrade fails:
cd /opt
sudo docker compose -f /opt/harbor/docker-compose.yml down
sudo rm -rf /data && sudo tar xzf /root/harbor-data-YYYY-MM-DD.tar.gz -C /
sudo mv /opt/harbor /opt/harbor-failed
sudo mv /opt/harbor-old /opt/harbor
cd /opt/harbor && sudo docker compose up -dTroubleshooting
| Problem | Cause | Solution |
|---|---|---|
x509: certificate signed by unknown authority when pushing | Client does not trust the self-signed CA | Copy ca.crt to /etc/docker/certs.d/harbor.yourdomain.com/ca.crt on the Docker host and restart Docker. |
unauthorized: authentication required after login | Clock skew between client and Harbor | Confirm both sides run NTP / chronyd. Harbor JWT tokens are time-sensitive. |
http: server gave HTTP response to HTTPS client | Proxy port mismatch | Check that https.port: 443 in harbor.yml and that your nginx proxy is not also listening on 443. |
Trivy scan stuck at Queued | DB download failed | docker compose logs trivy-adapter to see the error. Most often a proxy or GitHub rate limit. Restart the container once network is clean. |
Push fails with blob unknown to registry | Harbor lost blob but kept manifest (disk full, interrupted GC) | Run GC with Delete Untagged Artifacts enabled, then re-push the image. |
UI shows Internal Server Error after upgrade | Database migration still running | docker compose logs harbor-core -f until you see server listening on :8080. |
Out of disk space in /data/registry | Old blobs not garbage collected | Set up the weekly GC from Step 12 and consider moving to MinIO. |
docker login fails with Error response from daemon: Get https://harbor...: dial tcp: i/o timeout | Firewall blocking 443 or DNS misconfig | curl -v https://harbor.yourdomain.com/v2/ from the client to isolate. Check UFW and cloud security groups. |
Reading Harbor Logs
Logs for every component are in the harbor-log container's host mount /var/log/harbor/:
sudo tail -f /var/log/harbor/core.log
sudo tail -f /var/log/harbor/jobservice.log
sudo tail -f /var/log/harbor/registry.log
sudo tail -f /var/log/harbor/trivy-adapter.logOr stream via Docker Compose:
cd /opt/harbor
sudo docker compose logs -f harbor-coreFAQ
What is Harbor used for?
Harbor is a self-hosted registry for container images, Helm charts, and other OCI artifacts. Teams use it to store private Docker images they do not want on Docker Hub, serve as a pull-through cache that dodges Docker Hub rate limits, run vulnerability scans with Trivy, enforce image signing, and replicate images between environments (dev/staging/prod) or cloud regions. In a typical Kubernetes + GitOps stack, Harbor sits between the CI system (which pushes images) and the cluster (which pulls them), enforcing policy at both ends.
Is Harbor better than Docker Hub?
It depends on your workload. Docker Hub is excellent for public open-source projects and individual developers -- the free tier is generous enough. Harbor wins when you have a team with private images, CI pipelines that push dozens of images per day, Kubernetes clusters pulling thousands of times per day, or compliance requirements that forbid your artifacts from leaving your infrastructure. Harbor also includes features that Docker Hub charges extra for (scanning, replication, webhooks, RBAC) and has no rate limits or per-user seat pricing.
How much does it cost to run Harbor?
The software itself is free (Apache 2.0 licensed). The running costs are the VPS plus any object storage. A single CloudCore Professional VPS at EUR 19.99/month comfortably handles 5-15 developers pushing images and a small Kubernetes cluster pulling them. Storing 100-200 GB of images locally on the NVMe is fine; past 200 GB consider adding MinIO or S3. For reference, the equivalent Docker Hub Team + AWS ECR + third-party scanner setup typically costs USD 100-300/month for the same workload.
Can Harbor work with Kubernetes?
Yes -- Harbor is Kubernetes-native. Create a robot account per namespace, generate a docker-registry pull secret with that robot token, and attach the secret to your namespace's default ServiceAccount. Every pod in that namespace then pulls from Harbor transparently. Harbor itself also has an official Helm chart if you want to run the registry inside a cluster rather than on a dedicated VPS, though the dedicated VPS pattern is simpler to operate. If you are standing up a cluster fresh, see the K3s install guide.
Does Harbor support Helm charts?
Yes. Harbor 2.x supports Helm 3 charts as OCI artifacts (the modern Helm chart format). Push with helm push mychart-0.1.0.tgz oci://harbor.yourdomain.com/production. The UI displays charts alongside container images in the same project. The older ChartMuseum-based Helm 2 support was removed in Harbor 2.8 -- use OCI Helm instead.
How do I back up Harbor?
Two things to back up: the data directory (/data) and the PostgreSQL database. For the data directory, any block-level or file-level backup tool works -- restic, borgbackup, or rsync to a remote target. For the database, docker exec harbor-db pg_dumpall -U postgres > harbor.sql on a schedule. Restore order: stop Harbor, restore /data, restore the database, start Harbor. Test this regularly; a backup you have never restored from is a hope, not a backup.
What is the difference between Harbor and a plain Docker registry?
The upstream registry:2 image is the bare blob store that Harbor wraps. It has no UI, no user accounts, no projects, no scanning, and no replication -- just a REST API for pushing and pulling manifests and blobs. Harbor adds the full product layer: web UI, PostgreSQL-backed user and permission model, Trivy integration, Helm chart support, replication engine, webhook dispatcher, garbage collection scheduler, and the job service that runs async tasks. If you have one developer and push ten images a year, registry:2 is fine. Once you have a team, you want Harbor.
Next Steps
Your Harbor instance is production-ready. To build on it:
- Harden network access -- put Harbor behind Cloudflare Tunnel or a VPN for internal-only registries. See Cloudflared install guide.
- Wire up GitOps -- point ArgoCD or FluxCD's image updater at your Harbor and let it auto-deploy new tags that pass scanning.
- Sign images with Cosign --
cosign sign harbor.yourdomain.com/production/api:v1.0.0then enforcecosign verifyin an admission controller like Kyverno. - Add distributed storage -- stand up a MinIO cluster on a separate VPS and flip Harbor to S3 backend for elastic capacity. Walk through the MinIO install guide.
- Run Harbor in Kubernetes -- deploy the official Harbor Helm chart on a K3s cluster when you outgrow single-host. See K3s install guide.
- Export metrics to Prometheus -- Harbor exposes
/metricsendpoints on every component. Scrape them with Prometheus and build a Grafana dashboard for storage growth, scan queue length, and push rate. - Read the upstream docs -- the official Harbor documentation covers advanced topics like LDAP integration, OIDC authentication, and multi-tenancy patterns.
Run Harbor on a CloudCore Professional VPS>
Harbor is happiest with fast NVMe storage, plenty of RAM for Trivy, and unmetered bandwidth for image pulls. Our CloudCore Professional plan checks every box:>
- 6 vCPU cores
- 12 GB RAM
- 100 GB NVMe SSD (room for ~200 GB of deduplicated images after compression)
- Unmetered bandwidth (no egress bills when your Kubernetes nodes pull images all day)
- Ubuntu 24.04 LTS pre-installed
- EUR 19.99/month flat -- no per-image, per-user, or per-pull fees>
Get a CloudCore Professional VPS and have Harbor running in under an hour.