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:
| Component | Version |
|---|---|
| Server OS | Debian 13 (Trixie) |
| Registry | Harbor 2.13.0 (offline) |
| Runtime | Docker Engine 27+ |
| GitLab Runner | 18.9 |
| Workstation | macOS (CA host) |
What We’ll Cover
- Stand up a Debian VM and install Docker Engine — apt prep + Docker’s official repo
- Install Harbor from the offline installer on HTTP — first run, no TLS
- Create a project and robot account in the UI — the credentials CI will use
- Hit the
docker loginTLS wall — the moment HTTP-only stops being good enough - Build an internal CA on your Mac — OpenSSL, real PKI fundamentals
- Sign
harbor.lan.crtwith proper SANs — and serve a full chain - Trust the CA everywhere it matters — Keychain, NSS, curl, Docker daemon, containerd
- Wire Harbor into GitLab Runner —
DOCKER_AUTH_CONFIG+ the containerd gotcha - 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:
| Resource | Suggested |
|---|---|
| CPU | 2 vCPU |
| RAM | 4 GB |
| Disk | 40 GB |
Enable Trivy scanning? Bump RAM to 8 GB.
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
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
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: you@example.com
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:
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
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:
| Field | Expected value |
|---|---|
| Issuer | CN=A1-Lab Root CA, O=SuBuilds, OU=DevOps, … |
| Subject | CN=harbor.lan, O=SuBuilds, OU=DevOps, … |
| X509v3 SAN | DNS:harbor.lan, IP Address:10.10.1.23 |
| Basic Constraints | CA:FALSE (this is a server cert, not a CA) |
| Key Usage | Digital Signature, Key Encipherment |
| Extended Key Usage | TLS 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.
| Client | Trust store | How to trust the CA |
|---|---|---|
| Chrome, Safari | macOS Keychain (System) | Import a1-root-ca.crt, expand Trust, set Always Trust |
| Firefox, Zen | NSS (browser-private) | Settings → Privacy → View Certificates → Authorities → Import |
| curl on macOS | LibreSSL 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.crt | Drop 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 certificate → Always 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.
- Settings → Privacy & Security → Certificates → View Certificates
- Authorities tab → Import
- Select
a1-root-ca.crt - Check ✅ Trust this CA to identify websites
- Restart the browser
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 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.
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
| Layer | Outcome |
|---|---|
| Registry | Harbor 2.13 on Debian, serving HTTPS via your internal CA |
| Trust | a1-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
- Setting Up GitLab Container Registry — the GitLab-native alternative. Simpler to operate (one product, not two), but no Trivy scanning, replication, or project-scoped robot accounts.
- GitLab Runner Performance Optimization — Docker cache volumes, concurrent jobs, and the runner flags most teams leave on defaults.
- Dockhand: One UI to Manage Docker Across Every Host — what I use to watch the Harbor server, runner host, and every other Docker box without SSHing.