subuilds.dev

Self-Host Harbor Image Registry on Debian: Internal PKI and GitLab CI

· 16 min read
self-hosted-infra

Every CI build that pulls from Docker Hub bleeds time and burns rate-limit quota. The fix isn’t bigger runners — it’s a registry on your LAN. Harbor self-hosts that: mirror what you pull, scan with Trivy, gate access with robot accounts, serve at line speed.

This post covers the full path: install, the TLS wall docker login will hit, building your own CA to solve it, and wiring Harbor into GitLab Runner so .gitlab-ci.yml pulls from harbor.lan.

Environment:

ComponentVersion
Server OSDebian 13 (Trixie)
RegistryHarbor 2.13.0 (offline)
RuntimeDocker Engine 27+
GitLab Runner18.9
WorkstationmacOS (CA host)

What We’ll Cover

  1. Stand up a Debian VM and install Docker Engine — apt prep + Docker’s official repo
  2. Install Harbor from the offline installer on HTTP — first run, no TLS
  3. Create a project and robot account in the UI — the credentials CI will use
  4. Hit the docker login TLS wall — the moment HTTP-only stops being good enough
  5. Build an internal CA on your Mac — OpenSSL, real PKI fundamentals
  6. Sign harbor.lan.crt with proper SANs — and serve a full chain
  7. Trust the CA everywhere it matters — Keychain, NSS, curl, Docker daemon, containerd
  8. Wire Harbor into GitLab RunnerDOCKER_AUTH_CONFIG + the containerd gotcha
  9. Replace the public base image in .gitlab-ci.yml — and watch pipelines pull from your LAN

VM Specs

Harbor runs nine containers (core, portal, registry, db, redis, nginx, trivy, jobservice, chartmuseum). 1 GB of RAM falls over fast. Workable baseline:

ResourceSuggested
CPU2 vCPU
RAM4 GB
Disk40 GB

Enable Trivy scanning? Bump RAM to 8 GB.

Harden the host before you put a registry on it

Run through the Linux Server Security Baseline first. A registry holds the artifacts every other system runs — it deserves a hardened host.

1. Prepare Debian

sudo apt update && sudo apt upgrade -y
sudo apt install -y curl wget vim git ca-certificates gnupg lsb-release

2. Install Docker Engine

Use Docker’s official repo. The Debian docker.io package lags behind and skips docker compose.

Add the GPG key and repo:

sudo install -m 0755 -d /etc/apt/keyrings

curl -fsSL https://download.docker.com/linux/debian/gpg \
  | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian $(lsb_release -cs) stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Install Docker:

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

Verify and enable:

docker version
docker compose version
sudo systemctl enable --now docker

3. Download the Harbor Offline Installer

The offline installer bundles every image Harbor needs. No slow Docker Hub pulls during install, and you can do the first run with the box’s network locked down.

Grab the latest release from github.com/goharbor/harbor/releases:

cd /opt
sudo wget https://github.com/goharbor/harbor/releases/download/v2.13.0/harbor-offline-installer-v2.13.0.tgz
sudo tar xzf harbor-offline-installer-v2.13.0.tgz
cd /opt/harbor

You should now have /opt/harbor with installer scripts and the bundled images tarball.

4. Configure harbor.yml for HTTP First

Copy the template:

sudo cp harbor.yml.tmpl harbor.yml
sudo vim harbor.yml

Make four changes:

hostname: harbor.lan

# Comment out the entire https block for the first run
# https:
#   port: 443
#   certificate: /your/certificate/path
#   private_key: /your/private/key/path

harbor_admin_password: StrongPassword123

data_volume: /data/harbor

Create the data directory:

sudo mkdir -p /data/harbor
harbor.lan needs to resolve from every client

DNS must resolve harbor.lan from your workstation, runners, and any Kubernetes nodes. OPNsense’s Unbound resolver is a clean fit — see OPNsense Core Features: DNS Resolution. /etc/hosts works for one box but won’t scale.

5. Install Harbor

sudo ./install.sh

install.sh loads images, renders the compose stack from harbor.yml, and starts the services. 2–5 minutes on most disks.

When it finishes, verify:

docker ps

You should see nine harbor-* containers plus nginx, registry, registryctl, redis, trivy-adapter.

6. First Login and Create a Project

Open http://harbor.lan and log in as admin with the password from harbor.yml.

Create a project — I use base-images for shared CI base images:

Generate a robot account scoped to that project: Project → Robot Accounts → New Robot Account. Grant pull and push. Save the token — Harbor only shows it once.

7. The Docker Login TLS Wall

Harbor stops being a UI demo here. On your workstation:

docker login harbor.lan
Username: robot$bot-01
Password:
Error response from daemon: Get "https://harbor.lan/v2/":
  dialing harbor.lan:443 container via direct connection
  because Docker Desktop has no HTTPS proxy:
  connecting to harbor.lan:443:
  dial tcp 10.10.1.23:443: connect: connection refused
HTTP-only Harbor hits a wall the moment Docker shows up

The browser accepts http://harbor.lan because you typed http://. The Docker CLI doesn’t — it tries HTTPS first, and nothing’s on 443. Two options: add harbor.lan to Docker’s insecure-registries (works, teaches bad habits, breaks on every fresh client), or give Harbor real TLS. Pick TLS.

For internal infra: build your own CA. Public Let’s Encrypt won’t issue for .lan. A self-signed leaf with no chain makes every client complain. Build the CA once, trust it everywhere once, and every cert you sign with it works.

8. Build an Internal CA on Your Mac

Lay out a working directory:

mkdir -p ~/Projects/labA1-pki/{root,harbor}
cd ~/Projects/labA1-pki/root

Generate the CA key and self-signed root cert. 10 years is fine for a homelab; drop to 1–2 years for production and rotate.

openssl genrsa -out a1-root-ca.key 4096

openssl req -x509 -new -nodes \
  -sha512 \
  -days 3650 \
  -key a1-root-ca.key \
  -out a1-root-ca.crt

OpenSSL prompts for a Distinguished Name. Only the Common Name matters — pick something you’ll recognize in a browser’s cert inspector:

Country Name (2 letter code): VN
State or Province Name: HCM
Locality Name: HCM
Organization Name: SuBuilds
Organizational Unit Name: DevOps
Common Name: A1-Lab Root CA
Email Address: [email protected]

You now have two files: a1-root-ca.key (guard this — whoever holds it can mint trusted certs on your network) and a1-root-ca.crt (the public cert, distribute freely).

9. Generate Harbor’s Key and CSR on the Harbor Server

The private key never leaves the Harbor server. It produces a CSR (Certificate Signing Request) and sends only the CSR to the CA host.

sudo mkdir -p /opt/harbor/certs
cd /opt/harbor/certs

sudo openssl genrsa -out harbor.lan.key 4096

sudo openssl req -sha512 -new \
  -key harbor.lan.key \
  -out harbor.lan.csr

In the DN prompts, set Common Name to harbor.lan — the hostname clients will request.

The flow:

Diagram

Copy the CSR over to the Mac:

scp harbor:/opt/harbor/certs/harbor.lan.csr ~/Projects/labA1-pki/harbor/

10. Sign the CSR with SANs

Modern TLS validates the Subject Alternative Name, not the Common Name. No SAN, no trust — that’s true in Chrome (since 58), Go (since 1.15), and OpenSSL 3+. You need a v3 extensions file.

authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=@alt_names

[alt_names]
DNS.1=harbor.lan
IP.1=10.10.1.23
Why SAN, not Common Name

A SAN entry says: “this cert is valid for exactly these names/IPs.” Common Name alone isn’t trusted anymore. List both the DNS name and the IP — containerd sometimes checks by IP when DNS gets weird.

Sign:

openssl x509 -req \
  -sha512 \
  -days 3650 \
  -extfile v3.ext \
  -CA ../root/a1-root-ca.crt \
  -CAkey ../root/a1-root-ca.key \
  -CAcreateserial \
  -in harbor.lan.csr \
  -out harbor.lan.crt

Inspect the result:

openssl x509 -in harbor.lan.crt -text -noout

Verify these fields:

FieldExpected value
IssuerCN=A1-Lab Root CA, O=SuBuilds, OU=DevOps, …
SubjectCN=harbor.lan, O=SuBuilds, OU=DevOps, …
X509v3 SANDNS:harbor.lan, IP Address:10.10.1.23
Basic ConstraintsCA:FALSE (this is a server cert, not a CA)
Key UsageDigital Signature, Key Encipherment
Extended Key UsageTLS Web Server Authentication

11. Enable HTTPS in Harbor

Copy the signed cert into Harbor’s cert directory:

scp harbor.lan.crt harbor:/tmp/
ssh harbor "sudo mv /tmp/harbor.lan.crt /opt/harbor/certs/"

Uncomment the HTTPS block in harbor.yml:

https:
  port: 443
  certificate: /opt/harbor/certs/harbor.lan.crt
  private_key: /opt/harbor/certs/harbor.lan.key

Re-render the compose stack and restart:

cd /opt/harbor
sudo ./prepare
sudo docker compose down
sudo docker compose up -d

Open https://harbor.lan. It will still say “not secure.” Harbor is serving a cert signed by a CA the browser has never heard of — expected. Next: teach every client to trust the CA.

12. Trust the CA Everywhere It Matters

This is where I burned the most time. Every client has its own trust store. “I added it to Keychain” only covers about a third of them.

ClientTrust storeHow to trust the CA
Chrome, SafarimacOS Keychain (System)Import a1-root-ca.crt, expand Trust, set Always Trust
Firefox, ZenNSS (browser-private)Settings → Privacy → View Certificates → Authorities → Import
curl on macOSLibreSSL CA bundle (not Keychain!)curl --cacert a1-root-ca.crt …, or install Homebrew curl which reads Keychain
Docker daemon/etc/docker/certs.d/<host>/ca.crtDrop ca.crt in that folder
containerd (23+)OS trust store/usr/local/share/ca-certificates/ + update-ca-certificates + restart Docker

macOS Keychain (Chrome and Safari)

Open Keychain Access. Select the System keychain. File → Import Items → pick a1-root-ca.crt. Double-click the imported cert, expand Trust, set When using this certificateAlways Trust.

Chrome and Safari pick this up immediately. Reload https://harbor.lan — green.

Firefox / Zen — NSS, not Keychain

Firefox (and forks like Zen) bundle their own CA store, NSS. Keychain trust doesn’t reach it.

  1. Settings → Privacy & Security → Certificates → View Certificates
  2. Authorities tab → Import
  3. Select a1-root-ca.crt
  4. Check ✅ Trust this CA to identify websites
  5. Restart the browser
Keychain doesn't cover curl or Firefox

Keychain is one trust store among several. Chrome and Safari use it. curl uses Apple’s LibreSSL bundle. Firefox uses NSS. Docker reads /etc/docker/certs.d/. Each needs the CA installed separately. Assume “fixed one means fixed all” and you’ll waste an hour on the wrong layer.

curl

System curl on macOS uses Apple’s LibreSSL bundle, which ignores user-added Keychain certs. Even with Keychain trust working:

curl https://harbor.lan
curl: (60) SSL certificate problem:
unable to get local issuer certificate

Three fixes:

curl --cacert ~/Projects/labA1-pki/root/a1-root-ca.crt https://harbor.lan
brew install curl
/opt/homebrew/opt/curl/bin/curl https://harbor.lan

Option C is to confirm the chain at the protocol level:

openssl s_client \
  -connect harbor.lan:443 \
  -CAfile ~/Projects/labA1-pki/root/a1-root-ca.crt \
  </dev/null 2>&1 | grep -E "Verify return code"

Verify return code: 0 (ok) means the chain is correct. Anything else is a trust-store issue, not a cert issue. Use this check whenever a client claims “untrusted.”

Aside: the “fullchain” detour

Debugging the same wall, I hit a different symptom. openssl s_client -showcerts showed:

--- Certificate chain
 0 s:CN=harbor.lan
   i:CN=A1-Lab Root CA
---

Only depth-0 (the server cert) is served. The CA cert is not in the chain. Most clients expect server cert plus issuer chain so they don’t have to look up the CA themselves. Fix: concatenate server cert with root, serve the bundle.

cat harbor.lan.crt ../root/a1-root-ca.crt > harbor-fullchain.crt
cat harbor-fullchain.crt
# -----BEGIN CERTIFICATE-----   (harbor.lan)
# ...
# -----END CERTIFICATE-----
# -----BEGIN CERTIFICATE-----   (A1-Lab Root CA)
# ...
# -----END CERTIFICATE-----

Copy harbor-fullchain.crt to the Harbor server and point harbor.yml at it instead of the leaf cert:

https:
  certificate: /opt/harbor/certs/harbor-fullchain.crt
  private_key: /opt/harbor/certs/harbor.lan.key

./prepare && docker compose down && docker compose up -d and re-test.

13. Verify Docker Login

With Docker Desktop trusting the CA (it picks up macOS Keychain on restart):

docker login harbor.lan
Username: robot$bot-01
Password:
Login Succeeded

Milestone hit. Harbor now behaves like any other registry — docker pull harbor.lan/base-images/<image> works, and you have a place to push.

14. Wire Harbor into GitLab Runner

Goal: every .gitlab-ci.yml job pulls its base image from Harbor, not Docker Hub. This is the production win — no more Hub rate limits, pulls at LAN speed.

Three traps on the way. I hit all of them.

Trap 1: containerd uses a different trust path than the Docker daemon

On the GitLab Runner host, install the CA the “Docker way”:

sudo mkdir -p /etc/docker/certs.d/harbor.lan
sudo cp a1-root-ca.crt /etc/docker/certs.d/harbor.lan/ca.crt

Now login works:

docker login harbor.lan
# Login Succeeded

But the pull fails:

docker pull harbor.lan/base-images/node:24.12.0-trixie-slim
Error response from daemon: failed to resolve reference
"harbor.lan/base-images/node:24.12.0-trixie-slim":
failed to authorize: failed to fetch oauth token:
Post "https://harbor.lan/service/token":
tls: failed to verify certificate:
x509: certificate signed by unknown authority
docker login lies — docker pull is what tests trust

Docker 23+ delegates pulls to containerd, which reads the system trust store, not /etc/docker/certs.d/. docker login succeeds (daemon handles it). docker pull fails (containerd doesn’t know your CA). Stop after docker login and you’ll debug the wrong layer for hours.

Install the CA into the OS trust store:

sudo cp /etc/docker/certs.d/harbor.lan/ca.crt \
  /usr/local/share/ca-certificates/a1-root-ca.crt
sudo update-ca-certificates
sudo systemctl restart docker

Re-test the pull — it should succeed.

Trap 2: The job container doesn’t inherit the host’s Docker auth

GitLab Runner in docker executor mode starts a fresh container per job. The host’s ~/.docker/config.json doesn’t reach inside. Without explicit auth, the base-image pull fails: unauthorized: unauthorized to access repository.

Generate a base64 auth blob from the robot credentials:

echo -n 'robot$bot-01:<password>' | base64

Drop it into config.toml as a DOCKER_AUTH_CONFIG env var:

[[runners]]
  name = "docker-runner-01"
  url = "http://gitlab.lan"
  executor = "docker"
  environment = ["DOCKER_AUTH_CONFIG={\"auths\":{\"harbor.lan\":{\"auth\":\"<base64-from-above>\"}}}"]
  [runners.docker]
    image = "harbor.lan/base-images/alpine:latest"
    pull_policy = ["if-not-present"]

Restart the runner. In my setup the runner itself runs as a Docker container (gitlab/gitlab-runner:v18.9.0) inside a VM, so it’s not a systemd service on the host. Bouncing the Docker daemon restarts the container and reloads config.toml:

sudo systemctl restart docker

If your runner is installed as a systemd service instead, use sudo systemctl restart gitlab-runner.

DOCKER_AUTH_CONFIG vs. a project CI/CD variable

You can also set DOCKER_AUTH_CONFIG as a masked group/project CI variable. config.toml covers every project on the runner with one config. Trade-off: on token rotation, update config.toml once per runner instead of every project.

Trap 3: Point .gitlab-ci.yml at Harbor

A one-line swap:

# Before
image: node:24.12.0-trixie-slim

# After
image: harbor.lan/base-images/node:24.12.0-trixie-slim

Before you commit, push the image into Harbor once:

docker pull node:24.12.0-trixie-slim
docker tag node:24.12.0-trixie-slim harbor.lan/base-images/node:24.12.0-trixie-slim
docker push harbor.lan/base-images/node:24.12.0-trixie-slim

Commit, push, watch the pipeline. Job logs will show the runner pulling from harbor.lan instead of registry-1.docker.io.

For more ways to trim pipeline time past the registry bottleneck, see GitLab Runner Performance Optimization.

What You Have Now

LayerOutcome
RegistryHarbor 2.13 on Debian, serving HTTPS via your internal CA
Trusta1-root-ca.crt installed on workstations (Keychain + NSS + Docker) and every runner host (Docker + containerd)
CI.gitlab-ci.yml pulls base images from harbor.lan/base-images/* — no more Docker Hub rate limits

You also have the building blocks for what Harbor really shines at: Trivy scanning, vulnerability policies that gate pulls, replication to a DR registry, OIDC for human users, and webhooks for image events.

Next Steps