What the registry needs from the machine
Disk, first and last. The Distribution docs give no CPU or memory figures; the process serves blobs and checks credentials, and the filesystem storage driver keeps every layer under one root directory, by default /var/lib/registry. Layers are stored by content address, so a base layer shared by many tags is stored once. What grows the disk is every rebuilt layer your CI pushes, and nothing is removed until you delete manifests and run garbage collection.
| Option | What it runs | Published requirement |
|---|---|---|
Distribution (registry:3) | One Go binary in one container; no UI | None stated; plan around disk |
| Distribution with the s3 storage driver | Registry here, blobs in an S3-compatible bucket | Pulls redirect to pre-signed URLs, so the docs say the host can be light |
| Harbor 2.15 | Registry, portal, database (PostgreSQL), Redis, optional Trivy | Minimum 2 CPU, 4 GB RAM, 40 GB disk; recommended 4 CPU, 8 GB, 160 GB |
Size the disk for the images you keep, not the images you push. A retention habit (delete old tags, collect weekly) matters more than the starting capacity.
Why a whole machine, and which one
A registry is pull traffic. Every CI job and every cluster node that starts a pod fetches layers, usually the same base layers again and again. On a dedicated server the port is unmetered: no transfer allowance and no overage fee, so a busy build day costs the same as a quiet one. The disks are yours alone, so a garbage-collection sweep or a large push does not queue behind another tenant's I/O.
- Rack servers carry a real RAID controller (HPE Smart Array, Smart HBA or a Dell BOSS) and an out-of-band console (iLO, iDRAC or a Supermicro BMC). A RAID 1 SSD pair is the sensible floor for a registry the team depends on.
- The Xeon D-1587 cartridge ships with a 1.92 TB NVMe SSD. Pair it with off-machine backups.
- Moonshot cartridges suit a small registry for one team, but no cartridge has a hardware RAID controller, most carry one disk, and only the m510 and m710x have a console.
- A custom build takes up to 24 drive bays in RAID 5, 6 or 10 when the image set runs to terabytes; it is handed over in 4 to 24 hours, against about 30 minutes for a racked machine once payment is approved.
Layout: loopback registry, TLS at the proxy
The registry container listens on 5000 and Docker publishes it on 127.0.0.1 only. nginx on the host owns 443, holds the certificate and forwards /v2/ to the loopback port. Authentication stays in the registry itself: the Distribution nginx recipe warns that a registry bound to localhost without auth lets anyone with a shell on the box push images. The config file replaces the image's default, which logs at debug level.
# /srv/registry/config.yml
version: 0.1
log:
level: info
storage:
filesystem:
rootdirectory: /var/lib/registry
delete:
enabled: true
http:
addr: :5000
host: https://registry.example.com
auth:
htpasswd:
realm: registry
path: /auth/htpasswd
# /srv/registry/compose.yml
services:
registry:
image: registry:3
restart: always
ports:
- "127.0.0.1:5000:5000"
environment:
OTEL_TRACES_EXPORTER: none
volumes:
- ./config.yml:/etc/distribution/config.yml:ro
- ./auth:/auth:ro
- ./data:/var/lib/registry
The registry builds the URLs it hands back from http.host when that is set, and from the incoming request when it is not, so setting it keeps them on your public https name. The official image's Dockerfile already sets OTEL_TRACES_EXPORTER=none; the line here only pins that behaviour, so trace export to a collector at localhost:4318 stays off whatever the image ships with later.
Accounts, firewall and first start
Distribution accepts only bcrypt entries; any other hash type is ignored, and the deploy guide notes that leaving out -B makes every login fail. Its example passes the password as an argument (-Bbn user pass), which puts it in ps output and shell history. Let htpasswd prompt instead, and restrict the file to root with mode 600. The official image sets no USER, so the registry runs as root inside the container and mode 600 does not lock it out.
sudo apt install apache2-utils nginx
sudo mkdir -p /srv/registry/auth /srv/registry/data
# prompts twice for the password; nothing on the command line
sudo htpasswd -B -C 12 -c /srv/registry/auth/htpasswd ci-push
sudo chmod 600 /srv/registry/auth/htpasswd
# firewall before anything listens: SSH and HTTPS, IPv4 and IPv6
grep IPV6= /etc/default/ufw # must print IPV6=yes
sudo ufw allow OpenSSH
sudo ufw allow 443/tcp
sudo ufw allow 80/tcp # only for Let's Encrypt HTTP-01 issuance and renewal
sudo ufw --force enable
cd /srv/registry && sudo docker compose up -d
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5000/v2/ # 401
- Check the mount. If the registry cannot find the htpasswd file, the configuration reference says it creates one with a default user and prints a generated password to its log.
- Adding a user means running htpasswd again without
-c(which would truncate the file), thendocker compose restart registry: the file is loaded once, at startup. - Why ufw is not the fence for port 5000. Docker's packet-filtering docs say published container traffic is diverted in the nat table before it reaches the INPUT chain ufw uses, so, on IPv4 at least, a ufw deny would not stop it. The loopback bind is what keeps 5000 private: on Docker Engine 28 and later only programs on the host can reach it, while older releases let hosts on the same layer-2 segment reach a port published on 127.0.0.1 (moby#45610). Update Docker before relying on it. A bare
-p 5000:5000, as in the quick-start example, publishes on every host address; Docker's port-publishing docs name both 0.0.0.0 and [::]. - IPv6. IPv6 is added at checkout at no charge. With
IPV6=yes, the ufw rules above apply to both address families.
The TLS proxy
Use a certificate from a public CA for registry.example.com; the deploy guide names Let's Encrypt as one source of a browser-trusted certificate, and clients need no extra CA file for it. Its HTTP-01 challenge needs port 80 open for issuance and every renewal, hence the commented rule above; if you keep 80 closed, use the DNS-01 challenge instead. Two lines from the official nginx recipe are not optional: client_max_body_size 0, or large layer uploads fail with HTTP 413, and proxy_set_header Host $http_host, which the recipe marks as required for the Docker client.
# /etc/nginx/sites-available/registry
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name registry.example.com;
ssl_certificate /etc/ssl/registry/fullchain.pem;
ssl_certificate_key /etc/ssl/registry/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 0;
chunked_transfer_encoding on;
location /v2/ {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 900;
}
}
- Link it into
sites-enabled, then runsudo nginx -tandsudo systemctl reload nginx. - From a workstation:
docker login registry.example.com, then tag and push a test image. - Do not add the name to
insecure-registries. The dockerd reference says that setting allows unencrypted or unverified traffic and should be enabled only for testing; with a private CA, put its certificate at/etc/docker/certs.d/registry.example.com/ca.crton each client instead.
Wiring CI and clusters to it
Give each consumer its own htpasswd account so one can be revoked without touching the others. In pipelines, log in with docker login --password-stdin fed from the CI system's secret store, never -p. Without a credential store, Docker keeps the login base64-encoded in ~/.docker/config.json; treat runner hosts accordingly.
- Runners for Gitea Actions, a GitHub Actions self-hosted runner or a Jenkins agent push with the account you gave them.
- Pipelines on a self-hosted GitLab runner log in and push the same way.
- k3s nodes read credentials from
/etc/rancher/k3s/registries.yamlunderconfigs, which has to exist on every node that pulls, followed by a K3s restart on that node.
Deleting images and reclaiming disk
Deletion is off by default; the delete: enabled: true line above turns on deleting an image through the API (its manifest, by digest). Deleting a tag alone leaves the manifest in place, and deleting the manifest removes references only. Space comes back when garbage collection runs its mark and sweep: it marks every blob some manifest still references and deletes the rest. The docs are blunt about the risk: an upload during collection can lose layers and corrupt the image, so the registry must be stopped or in read-only mode.
- Stop writes:
sudo docker compose stop registry(or restart it withmaintenance: readonly: enabled: trueunderstorageto keep pulls working). - Preview:
sudo docker compose run --rm registry garbage-collect --dry-run /etc/distribution/config.yml. - Collect: the same command without
--dry-run. Add--delete-untaggedto also drop images no tag points to. - Start it again, or drop the read-only setting and restart.
Separately, upload purging is on by default and clears abandoned upload directories older than 168 hours, checked every 24 hours. Back up config.yml, the htpasswd file and data/ to another machine, since a backup kept on the registry's own disks dies with them; copying the data during the read-only window is our suggestion, as it is the one time nothing is writing.
Harbor, and when one box is the wrong answer
Harbor wraps Distribution with a web portal, projects whose members get roles (Guest pulls only, Developer pushes too) and, with ./install.sh --with-trivy, vulnerability scanning. Its installer deploys with Docker Compose, Harbor takes ports 80 and 443 by default (both can be changed in harbor.yml), and production use calls for HTTPS configured in harbor.yml (its docs call HTTP acceptable only for air-gapped test setups). Set hostname and harbor_admin_password before the first run: the default login is admin / Harbor12345. Run it on its own machine instead of the nginx layout above.
Choose something else when the registry must survive the loss of one machine. The filesystem driver keeps everything on one host, and the docs describe it as fit for development and possibly small-scale production. Several registries behind a load balancer need the same storage backend and the same http.secret, which in practice means the s3 storage driver against shared object storage.
Questions
Can the registry run over plain HTTP on a private network?
Only for testing. Basic auth sends the password in a header, so the configuration reference says to use htpasswd only with TLS, and the insecure-registry page says plain HTTP cannot be combined with basic authentication at all. Every client would also need an insecure-registries entry in its daemon config. A public certificate on the proxy avoids all of that.
Does the registry:3 image include a web interface?
No. Distribution is an HTTP API that Docker, containerd and other clients speak. If people need to browse repositories and tags, or manage access per project, Harbor provides a portal on top of the same registry engine.
I deleted tags but the disk did not shrink. Why?
Deleting a tag leaves its manifest behind, and deleting an image through the API (its manifest, by digest) only removes references. The blobs stay on disk until garbage collection runs, and a layer still referenced by any other manifest is kept. Stop the registry or make it read-only, run garbage-collect (with --delete-untagged to drop images no tag points to), then restart it.
Can I give one account push rights and another pull-only?
Not with htpasswd: it checks who is connecting, not which repository they may touch. The Distribution docs point to token authentication when you need access control lists, and the nginx recipe shows a proxy restricting pushes to a group. Harbor's project roles are the ready-made answer: a Guest can pull but not push.
Should the images live on local disk or in S3-compatible storage?
Local disk is the simple choice for one registry on one server. The s3 storage driver works with Amazon S3 and compatible services such as MinIO (set regionendpoint and forcepathstyle), and it is what lets several registry replicas share one image set.