Skip to main content
RunBook Academy

Docker & ContainersXIII Β· RegistriesPrivate registries

Private registries β€” Distribution, Harbor, GitLab, Quay

Intermediate⏱ ~26 mindockercurl

What you'll learn

  • Choose between Distribution, Harbor, GitLab and Quay on operational grounds rather than feature lists
  • Deploy a Distribution registry with a storage backend you can reason about
  • Probe a registry in a way that distinguishes a live API from a working pull
  • Predict the blast radius of a registry outage on a running estate

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12

Not yet marked complete on this device.

A private registry is the foundation of a production Docker supply chain, and it is also a stateful service sitting directly on the deploy path. Those two facts pull in different directions: the first argues for features, the second argues for something you can restore from a tarball at 03:00.

The four common options

  • CNCF Distribution β€” the reference open-source registry, and the code most of the others are built on. Minimal: no UI, no RBAC beyond what you put in front of it, no scanning, no replication. Roughly one process and a storage backend.
  • Harbor β€” the CNCF graduated registry. Distribution plus a Postgres database, a UI, RBAC, scanning, replication, retention policies and signing policy enforcement. Substantially more moving parts.
  • GitLab Container Registry β€” bundled with GitLab. Namespaces track projects, permissions track GitLab permissions, CI integration is free.
  • Quay β€” Red Hat’s registry. Clair scanning, geo-replication, mirroring from public registries, robot accounts. Available hosted (Quay.io) or self-hosted.

The honest selection criterion is not the feature matrix. It is: how many components must be restored, in what order, for a pull to work again?

Components holding stateRestore order
DistributionStorage backend onlyOne step
GitLab registryRegistry storage + GitLab databaseDatabase, then storage
HarborRegistry storage + Postgres + Redis + configDatabase and storage together, then services
QuayRegistry storage + database + configDatabase and storage together

Distribution’s minimalism is a feature on a bad day. Harbor’s policy enforcement is a feature on every other day. Pick according to which day you are more worried about, and know the answer before you need it.

A Distribution registry you can reason about

Configuration changeminimal registry
docker run -d \
--name registry \
--restart always \
-p 5000:5000 \
-v /srv/registry:/var/lib/registry \
registry:3

That single bind mount is the entire state of the service. Everything a Distribution registry knows β€” every layer, every manifest, every tag β€” lives under it. The container itself is disposable.

Configuration changea config worth having
version: 0.1
log:
level: info
storage:
filesystem:
  rootdirectory: /var/lib/registry
delete:
  enabled: true
maintenance:
  readonly:
    enabled: false
http:
addr: :5000
headers:
  X-Content-Type-Options: [nosniff]

Two keys there deserve attention now because they matter later:

  • storage.delete.enabled defaults to off. With it off, the registry accepts pushes forever and refuses every delete, so retention is impossible and the disk fills. Every operator discovers this the first time they try to remove an image.
  • storage.maintenance.readonly.enabled is the switch that makes garbage collection safe. It is off here; it must be on while GC runs. That is a whole lesson of its own, and it starts here in the config.

Every key has an environment-variable equivalent formed by upper-casing the path with underscores, which is how you configure the container without maintaining a file:

Configuration changethe same setting as an environment variable
docker run -d --name registry \
-e REGISTRY_STORAGE_DELETE_ENABLED=true \
-p 5000:5000 \
-v /srv/registry:/var/lib/registry \
registry:3

Storage backends

The filesystem driver is the right default for a single registry with a real backup. It stops being right the moment you want two registry replicas, because two Distribution processes over one shared filesystem is a correctness question about that filesystem, not about Distribution.

For anything horizontally scaled, use an object-store driver β€” S3, Azure Blob, GCS. The registry becomes stateless, replicas become trivial, and the durability problem moves to a service that already solved it. The cost is latency on every blob operation and a bill that scales with pull traffic.

Probing a registry properly

β€œThe registry is up” usually means β€œthe TCP port answered”, which tells you almost nothing. The API has a defined entry point: a GET /v2/ must return 200 OK when the registry is serving and authentication is not required, or 401 Unauthorized when it is. Anything else is a fault.

Read-only / SafeAPI liveness
REGISTRY=https://registry.example.com

curl -fsS -o /dev/null -w '%{http_code}\n' "$REGISTRY/v2/" || true
401

Illustrative output

That is liveness. It is not readiness, because a registry can answer /v2/ perfectly while its storage backend is unreachable. The check that actually corresponds to β€œa deploy will work” is a manifest fetch for something you know exists:

Read-only / Safereadiness β€” can a real pull resolve?
REGISTRY=registry.example.com
REPO=myorg/myapp
DIGEST=sha256:REPLACE_ME
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json'

curl -fsSI -H "Accept: $ACCEPT" \
"https://$REGISTRY/v2/$REPO/manifests/$DIGEST" \
| grep -i 'docker-content-digest'
docker-content-digest: sha256:REPLACE_ME

Illustrative output

If the returned digest does not match the one you requested, stop and investigate β€” you are talking to something that is not serving the content you asked for.

TLS, and the flag you should not reach for

A registry must be served over TLS. When the certificate is wrong, the daemon fails the pull with an x509 error, and there is a tempting configuration key that makes the error go away:

Read-only / Safewhat the daemon believes
docker info --format 'insecure: {{.RegistryConfig.IndexConfigs}}
mirrors: {{.RegistryConfig.Mirrors}}'
insecure: map[docker.io:0x40001a2000]
mirrors: []

Illustrative output

Harbor, GitLab and Quay in one paragraph each

Harbor is the standard self-hosted choice when you want policy enforced rather than documented: per-project RBAC, tag retention rules (configured per project in the UI or API β€” not in harbor.yml), Trivy-backed scanning with a β€œblock pull if severity above X” gate, and replication to another Harbor or to a public registry. The operational cost is a Postgres database that is now on your restore path, and an upgrade procedure with more steps than β€œpull the new image”.

Configuration changeHarbor offline installer
HARBOR_VERSION=v2.15.0
cd /opt
curl -fsSLO "https://github.com/goharbor/harbor/releases/download/${HARBOR_VERSION}/harbor-offline-installer-${HARBOR_VERSION}.tgz"
tar xzf "harbor-offline-installer-${HARBOR_VERSION}.tgz"
# edit harbor/harbor.yml: hostname, TLS paths, database password
sudo ./harbor/install.sh

GitLab Container Registry is the lowest-marginal-cost option if you already run GitLab: a project’s registry namespace, permissions and CI credentials all follow from the project itself. Configuration lives in gitlab.rb:

registry_external_url 'https://registry.example.com'
gitlab_rails['registry_enabled'] = true

The trade is coupling. A GitLab outage or a GitLab upgrade window is now a registry outage or a registry upgrade window, and your deploys were probably not scoped into that maintenance notice.

Quay brings Clair scanning, geo-replication, repository mirroring from upstream registries, and robot accounts designed for automation. Signing in the modern Quay/OCI world is Cosign and OCI referrers; Notary v1 is being retired and its successor work moved to the Notation project, so treat any documentation referring to β€œNotary v2” as referring to Notation.

Replication

Replication solves two different problems and the design differs by which one you have:

  • Latency. Consumers far from the registry pay a network tax on every pull. A read-only replica or pull-through cache close to them fixes it, and a cache is usually enough.
  • Availability. If the registry disappearing must not stop deploys, you need a second registry that is authoritative, not a cache β€” and therefore a decision about how clients fail over to it.

Harbor offers scheduled, optionally bidirectional replication. Quay offers asynchronous geo-replication. Distribution offers neither; you put a pull-through cache in front of it or you copy images yourself with docker buildx imagetools create, which transfers the whole multi-platform index without pulling layers to the local host.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. A Distribution registry accepts pushes but every attempt to delete a manifest returns 405 Method Not Allowed. What is the cause?

  2. Q2. Your monitoring reports the registry as healthy because `GET /v2/` returns 401. A deploy still fails. What did the probe fail to test?

  3. Q3. A registry becomes unreachable. Which of the following break? Select all that apply.

  4. Q4. Adding a registry to `insecure-registries` is an acceptable way to accept a certificate signed by your internal CA.

  5. Q5. A backup of a Distribution registry that captures only the `blobs/` tree restores every byte of every image with no way to name any of them.

Passing score: 75%. Answers are checked in this browser.