What you are putting together
A registry is where your images live between the build that makes them and the servers that run them. The open-source project behind Docker's own registry image is CNCF Distribution. It has no web pages, just an API that docker push and docker pull speak. Your setup has four parts:
- The registry container, which stores layers on disk and checks logins.
- A password file in htpasswd format, one line per account.
- nginx, which holds your HTTPS certificate and passes requests through to the registry.
- ufw, the firewall, letting in SSH and HTTPS (plus port 80 if Let's Encrypt checks your name over HTTP) and nothing else.
Distribution's own guide calls the bare quick-start command suitable only for testing: a real registry needs TLS, and ideally access control. Everything below follows that rule.
How much server does it take?
Mostly storage. The Distribution docs give no processor or memory requirement, and the registry's work is reading and writing files. Each layer is saved once, however many images share it. Space grows with every new layer your builds push, and old ones stay until you clean up, which the last steps of this page show you how to do.
If you want Harbor, the bigger registry with a web console, its install guide asks for at least 2 CPUs, 4 GB of memory and 40 GB of disk, and recommends 4 CPUs, 8 GB and 160 GB.
The server that fits
Your registry is busiest when everyone is shipping at once: CI runners pushing, servers pulling the new release. On a Primcast dedicated server nobody meters the port, so there is no allowance to run over and no bandwidth line on the invoice however many pulls you serve. The whole machine is yours, and no other customer shares its disks.
- For a team that depends on it, pick a blade or rack server. These can be ordered with a hardware RAID card, which lets two mirrored disks survive one of them failing, and they come with a console with the screen and the power button for the morning a bad firewall change cuts off SSH. Our enterprise servers explain that class.
- For a side project or one small team, a cartridge server is enough, but it has no RAID card and usually a single disk, and of the cartridge models just the m510 and m710x come with a console.
- For terabytes of images, have one built to your spec, with room for as many as 24 disks, mirrored or striped with parity.
A racked server is handed over within 30 minutes once your payment is approved; a custom build takes 4 to 24 hours when we hold the parts. There is no private network between our servers, so other machines reach your registry over the public internet. That is one more reason the HTTPS step is not optional.
Up and running in an evening
These steps assume Ubuntu with Docker Engine already installed, and a DNS name such as images.example.com pointing at the server. Start with accounts and the firewall, so nothing is listening before the door is shut.
The -B flag matters: Distribution reads bcrypt entries only and ignores any other kind, so a login made without it simply never works. The official example passes the password on the command line with -b; skip that, because anything typed there shows up in the process list and your shell history. The block below creates the first account, closes the firewall, then shows the registry's settings file and the Compose file that starts it:
sudo apt install apache2-utils nginx
sudo mkdir -p /opt/registry/auth /opt/registry/data
# htpasswd asks for the password; it never appears in the command
sudo htpasswd -B -C 12 -c /opt/registry/auth/htpasswd builder
sudo chmod 600 /opt/registry/auth/htpasswd
# confirm ufw manages IPv6 too, then allow only SSH and HTTPS
grep IPV6= /etc/default/ufw # should say IPV6=yes
sudo ufw allow OpenSSH
sudo ufw allow 443/tcp
sudo ufw allow 80/tcp # needed only for Let's Encrypt HTTP-01 issuance and renewal
sudo ufw --force enable
# /opt/registry/config.yml
version: 0.1
log:
level: info
storage:
filesystem:
rootdirectory: /var/lib/registry
delete:
enabled: true
http:
addr: :5000
host: https://images.example.com
auth:
htpasswd:
realm: images
path: /auth/htpasswd
# /opt/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
Run cd /opt/registry && sudo docker compose up -d. Then curl -i http://127.0.0.1:5000/v2/ should answer 401: the registry is up and asking for a password. Finally, nginx, with a certificate for your name from a public CA (Let's Encrypt, for instance). Let's Encrypt's HTTP-01 check needs port 80 reachable when the certificate is issued and at each renewal, which is why the block above opens it; prefer the DNS-01 challenge if you would rather keep 80 shut.
# /etc/nginx/sites-available/images
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name images.example.com;
ssl_certificate /etc/ssl/images/fullchain.pem;
ssl_certificate_key /etc/ssl/images/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;
}
}
Enable the site, check it with sudo nginx -t, reload nginx, and run docker login images.example.com from your laptop. client_max_body_size 0 lifts nginx's upload limit, which big layers would otherwise hit, and the recipe says the Host line is there for the Docker client's sake. If your config sets http.host, the registry uses it for the links it hands back; leave it out and those links come from whatever address the request arrived on, which is why the config names your https address.
Why port 5000 stays on 127.0.0.1
Because a firewall rule will not protect it. Docker's documentation explains that traffic to a published container port is redirected before it reaches the chains ufw filters, so, on IPv4 at the very least, ufw never gets a say. Write 5000:5000 without an address and, by Docker's default, the port is published on all host addresses, 0.0.0.0 and [::] alike.
Publishing on 127.0.0.1 means only programs on the server itself, nginx included, can connect, provided Docker Engine is version 28 or newer. Older releases let machines on the same layer-2 network reach such a port (moby#45610), so update Docker first. The registry still demands a login there too, as the Distribution nginx recipe recommends: otherwise any user logged in to the server could push images. IPv6 comes with your server at no charge, and IPV6=yes makes your ufw rules cover it as well.
Do not reach for insecure-registries to skip the certificate. Docker's daemon reference warns that it lets through unencrypted or unchecked connections and belongs in testing only, and Distribution's own page adds that plain HTTP cannot be used together with basic authentication.
Connecting your builds and your cluster
Make one account per tool, so you can switch one off without breaking the rest. Add it with sudo htpasswd -B -C 12 /opt/registry/auth/htpasswd jenkins (no -c, which would wipe the file), then restart the registry, because it reads the file only when it starts. In pipelines, pipe the token from your CI secret into docker login --password-stdin rather than passing -p.
- Your Jenkins server, a GitHub Actions runner or the runners behind Gitea and GitLab push with their own account.
- On a k3s cluster, every node that pulls needs a
configsentry with the login in/etc/rancher/k3s/registries.yaml, and K3s restarted on that node.
Docker keeps logins in ~/.docker/config.json, base64-encoded rather than encrypted, unless a credential store is configured. Look after build machines with that in mind.
Clearing out old images without breaking anything
Removing an image via the API, which means deleting its manifest by digest, does not free disk by itself; it only removes the reference, and removing just a tag leaves even the manifest behind. The space comes back when you run garbage collection, which keeps every layer some image still uses and deletes the rest. The docs warn that a push arriving mid-run can lose layers and leave a broken image, so writes have to stop first. The routine below stops the container, previews, collects, and starts it again:
cd /opt/registry
sudo docker compose stop registry
# preview: lists what would be deleted, removes nothing
sudo docker compose run --rm registry garbage-collect --dry-run /etc/distribution/config.yml
# the real pass
sudo docker compose run --rm registry garbage-collect /etc/distribution/config.yml
sudo docker compose start registry
Put --delete-untagged on the real pass if you also want images that no tag points to any more dropped. If people must keep pulling during the clean-up, put the registry in read-only mode instead of stopping it: the readonly switch under storage.maintenance, then a restart, and the same again to turn it off.
Half-finished uploads are swept up automatically: by default, upload purging removes leftovers older than a week. Copy /opt/registry regularly to a machine other than the registry server, because a copy on the same disks does not survive losing them.
When Harbor, or more than one server, suits you better
Pick Harbor if people need to browse images in a browser or need different rights per project: Guests may only download images, while Developers can also upload them. Its installer can add Trivy vulnerability scanning with --with-trivy. Before you run install.sh, edit harbor.yml: give hostname your registry's name, choose a harbor_admin_password (left alone, the first login is admin / Harbor12345), and set up HTTPS there too. By default Harbor listens on ports 80 and 443 (you can change both in harbor.yml), so the simplest plan is a server of its own.
If the registry must keep working when a machine fails, one box with local disk is the wrong design. Distribution describes its filesystem storage as fine for development and possibly small production use. Several copies behind a load balancer must share one storage backend and one http.secret, which usually means the s3 driver pointed at S3-compatible object storage such as MinIO.
Questions people ask
- Is the registry free to run?
- The software is. Distribution and Harbor are both open source under the Apache 2.0 licence, and the
registry:3image is pulled like any other. What you pay for is the server it runs on, and the disk your images fill. - Can I browse my images in a web page?
- Not with Distribution alone: it only speaks the registry API that Docker uses. Harbor is built around Distribution and adds a web console, projects and per-project roles, at the cost of more memory and more containers to look after.
- Why does my disk stay full after I delete images?
- Deleting an image (its manifest, by digest) removes the reference, not its layers. The layers go when garbage collection runs, and only if no other image still uses them. Pause writes by stopping the registry or switching it to read-only, collect, and start it again.
- Do my clients need any special setup to trust it?
- Not with a certificate from a public authority on the proxy: Docker connects over HTTPS like it would to any registry. With your own private CA, copy its certificate to
/etc/docker/certs.d/images.example.com/ca.crton each client. Never fall back to an insecure-registries entry outside a test setup. - Can I keep the images in object storage instead of the server's disk?
- Yes, through the s3 storage driver, which talks to Amazon S3 or anything that speaks its API, MinIO included; for those, set
regionendpointand turn onforcepathstyle. Pulls are then redirected to signed links on the storage itself, so the registry server does less of the work.