Docker & Containers — Level by Level›07 · Run your own private registry

Lesson 07 of 13 · Level 3 — Registries

Run your own private registry

Run the open-source registry with a TLS certificate and password login using Docker Compose, push and pull through it, query it with its HTTP API, delete images properly, and turn it into a pull-through cache for Docker Hub.

Practitioner
Key wordsprivate registryregistry imagedistributionTLShtpasswdbcryptsubjectAltName/v2/_cataloggarbage-collectpull-through cacheHarbor

The plan

The open-source registry (the CNCF Distribution project, published on Docker Hub as registry) is a single small container. In this lesson you'll run it on a lab host registry.lab.local (192.168.56.10) with:

  • TLS, so Docker talks to it without any bypass,
  • password login (htpasswd),
  • data on a volume,
  • delete enabled so you can clean up.

Which registry image tag?

registry:2 has been the standard tag for years. Distribution v3 is published as registry:3, and the environment variables used below are the same for both. One difference that matters: the configuration file path inside the container moved from /etc/docker/registry/config.yml (v2) to /etc/distribution/config.yml (v3). Check the image's documentation for the version you pin.

For a team or company registry with a web UI, projects, RBAC, vulnerability scanning and replication, use Harbor instead (it uses Distribution underneath). The commands for pushing and pulling are identical.

Docker Hub is the big public library. Your own registry is the bookshelf in your office: only your team's books, only people with a key card get in (login), and the door has a proper lock that visitors can check is genuine (the TLS certificate).

1. Certificate

For the lab, a self-signed certificate is enough. The SAN list must contain every name and IP clients will use:

$ mkdir -p registry/{certs,auth} && cd registry
$ openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes \
    -keyout certs/domain.key -out certs/domain.crt \
    -subj "/CN=registry.lab.local" \
    -addext "subjectAltName=DNS:registry.lab.local,IP:192.168.56.10"
$ openssl x509 -in certs/domain.crt -noout -ext subjectAltName
X509v3 Subject Alternative Name:
    DNS:registry.lab.local, IP Address:192.168.56.10

A certificate with only a Common Name fails in Docker with x509: certificate relies on legacy Common Name field, use SANs instead. (-addext needs OpenSSL 1.1.1 or newer.) In a company, ask your internal CA for the certificate instead; the next-but-one lesson covers trusting it.

2. Users

The registry accepts only bcrypt htpasswd entries:

$ docker run --rm --entrypoint htpasswd httpd:2 -Bbn ci-bot 'S3cret!' > auth/htpasswd
$ docker run --rm --entrypoint htpasswd httpd:2 -Bbn alice 'An0ther!' >> auth/htpasswd

(These passwords are lab examples. Typing a real password on the command line leaves it in your shell history; use -B -n user without -b to be prompted instead.)

3. Run it with Compose

# registry/compose.yaml
services:
  registry:
    image: registry:2
    restart: unless-stopped
    ports:
      - "5000:5000"
    environment:
      REGISTRY_HTTP_TLS_CERTIFICATE: /certs/domain.crt
      REGISTRY_HTTP_TLS_KEY: /certs/domain.key
      REGISTRY_AUTH: htpasswd
      REGISTRY_AUTH_HTPASSWD_REALM: "Lab Registry"
      REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
      REGISTRY_STORAGE_DELETE_ENABLED: "true"
    volumes:
      - ./certs:/certs:ro
      - ./auth:/auth:ro
      - registry-data:/var/lib/registry

volumes:
  registry-data:
$ docker compose up -d
$ curl -s -o /dev/null -w '%{http_code}\n' --cacert certs/domain.crt https://registry.lab.local:5000/v2/
401
$ curl -s -u ci-bot:'S3cret!' --cacert certs/domain.crt https://registry.lab.local:5000/v2/
{}

401 without a password and {} with one: TLS and login both work.

4. Trust it, log in, push, pull

Every Docker host that talks to the registry needs to trust the certificate. The clean way, with no daemon restart:

$ sudo mkdir -p /etc/docker/certs.d/registry.lab.local:5000
$ sudo cp certs/domain.crt /etc/docker/certs.d/registry.lab.local:5000/ca.crt
$ docker login registry.lab.local:5000
$ docker tag alpine:3.20 registry.lab.local:5000/tools/alpine:3.20
$ docker push registry.lab.local:5000/tools/alpine:3.20

The folder name must match exactly what's in the image name, including the port. Level 4 explains every trust option, and the insecure bypass, in detail.

5. Look inside with the API

The registry speaks the OCI distribution HTTP API:

$ curl -s -u ci-bot:'S3cret!' https://registry.lab.local:5000/v2/_catalog
{"repositories":["tools/alpine"]}
$ curl -s -u ci-bot:'S3cret!' https://registry.lab.local:5000/v2/tools/alpine/tags/list
{"name":"tools/alpine","tags":["3.20"]}

(With the certificate trusted system-wide, curl no longer needs --cacert.)

6. Delete images and free space

Deleting is two steps: remove the manifest by digest, then run garbage collection to free unreferenced blobs.

$ DIGEST=$(curl -s -u ci-bot:'S3cret!' -I \
    -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \
    https://registry.lab.local:5000/v2/tools/alpine/manifests/3.20 \
    | awk -F': ' 'tolower($1)=="docker-content-digest"{print $2}' | tr -d '\r')
$ curl -s -u ci-bot:'S3cret!' -X DELETE https://registry.lab.local:5000/v2/tools/alpine/manifests/$DIGEST
$ docker compose exec registry registry garbage-collect --delete-untagged /etc/docker/registry/config.yml

The Accept header matters: without it the registry may return a different manifest type and you delete, or fail to find, the wrong digest. Run garbage collection when nothing is pushing (stop pushes, or put the registry in read-only mode), because a push that lands during GC can lose blobs. On registry:3, use /etc/distribution/config.yml as the path.

7. A pull-through cache for Docker Hub

The same image becomes a caching proxy with one setting. Run it as a separate registry (a proxy registry can't also accept pushes):

services:
  hub-cache:
    image: registry:2
    restart: unless-stopped
    ports:
      - "5001:5000"
    environment:
      REGISTRY_PROXY_REMOTEURL: https://registry-1.docker.io
      REGISTRY_PROXY_USERNAME: ${HUB_USER}     # optional: pulls count against this account
      REGISTRY_PROXY_PASSWORD: ${HUB_TOKEN}
      REGISTRY_HTTP_TLS_CERTIFICATE: /certs/domain.crt
      REGISTRY_HTTP_TLS_KEY: /certs/domain.key
    volumes:
      - ./certs:/certs:ro
      - hub-cache-data:/var/lib/registry
volumes:
  hub-cache-data:

Then point the Docker daemons at it with "registry-mirrors": ["https://registry.lab.local:5001"] in daemon.json (next lesson). docker pull nginx:1.27 now goes through the cache; only the first pull reaches Docker Hub. registry-mirrors applies to Docker Hub pulls only, not to other registries.

Try it: your own registry end to end

  1. Follow steps 1–4 on a lab VM, adding registry.lab.local to /etc/hosts on each client.
  2. Push alpine:3.20 and one image you built in lesson 2.
  3. From a second VM (trust the certificate there too), log in and pull it.
  4. List the catalog and tags with curl.
  5. Delete one tag by digest, run garbage collection and compare docker compose exec registry du -sh /var/lib/registry before and after.

Going deeper: what production adds

  • A certificate from your internal CA (or a public one, if the name is public), renewed automatically.
  • Token auth or an identity provider instead of an htpasswd file, which Harbor gives you with LDAP/OIDC.
  • Object storage (S3 or compatible) instead of a local volume, so the registry host is disposable.
  • Retention rules (keep the last N tags per repository) and scheduled garbage collection.
  • Vulnerability scanning and signing (Trivy in Harbor, cosign signatures), covered in the "CI/CD & Software Supply Chain" track.
  • For air-gapped edge sites: a registry at the site, pre-loaded with a signed bundle of images (skopeo sync or oras), so nodes never need the internet.

Recap

  • registry + TLS certificate with SANs + bcrypt htpasswd + a volume = a private registry in one Compose file.
  • Clients trust it via /etc/docker/certs.d/<host:port>/ca.crt, then docker login and push/pull as usual.
  • The /v2/ API lists repositories and tags; deleting is manifest by digest, then garbage-collect.
  • REGISTRY_PROXY_REMOTEURL turns a second instance into a pull-through cache for Docker Hub.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.