Every docker pull from Docker Hub counts against your rate limit. Every proprietary image pushed to a public registry is a potential security incident waiting to happen. And every CI/CD pipeline that depends on an external registry is one outage away from failing builds.

Running your own Docker registry gives you full control over image storage, access, and distribution. This guide covers everything from a basic setup to a production-hardened deployment with TLS, authentication, cloud storage backends, and automated garbage collection.

Quick Start: Registry in 60 Seconds

The Docker Distribution project (Registry v2) is the official open-source registry:

# Start a basic registry
docker run -d \
  --name registry \
  -p 5000:5000 \
  --restart always \
  registry:2

# Tag and push an image
docker tag nginx:alpine localhost:5000/nginx:alpine
docker push localhost:5000/nginx:alpine

# Pull it back
docker pull localhost:5000/nginx:alpine

# List repositories via the API
curl http://localhost:5000/v2/_catalog
# {"repositories":["nginx"]}

This works for local development but is completely unsuitable for production. It has no TLS, no authentication, and stores images in an ephemeral container filesystem. Let us fix all of that.

Production Setup with Docker Compose

Step 1: Generate TLS Certificates

Docker refuses to communicate with registries over plain HTTP (except localhost). You need TLS:

# Using Let's Encrypt with certbot (recommended for public-facing registries)
certbot certonly --standalone -d registry.example.com

# Or generate self-signed certificates for internal use
mkdir -p certs
openssl req -newkey rsa:4096 -nodes -sha256 \
  -keyout certs/registry.key \
  -x509 -days 365 \
  -out certs/registry.crt \
  -subj "/CN=registry.example.com" \
  -addext "subjectAltName=DNS:registry.example.com,IP:192.168.1.10"
Warning: If you use self-signed certificates, you must install the CA certificate on every Docker daemon that will push or pull from the registry. Copy registry.crt to /etc/docker/certs.d/registry.example.com:5000/ca.crt on each client machine.

Step 2: Configure Authentication

The simplest authentication method is htpasswd:

# Create an htpasswd file
mkdir -p auth
docker run --rm --entrypoint htpasswd \
  httpd:2 -Bbn admin supersecretpassword > auth/htpasswd

# Add more users
docker run --rm --entrypoint htpasswd \
  httpd:2 -Bbn developer devpassword >> auth/htpasswd

Step 3: Registry Configuration File

# config.yml
version: 0.1
log:
  level: info
  formatter: json
storage:
  filesystem:
    rootdirectory: /var/lib/registry
  delete:
    enabled: true
  cache:
    blobdescriptor: inmemory
  maintenance:
    uploadpurging:
      enabled: true
      age: 168h
      interval: 24h
      dryrun: false
http:
  addr: :5000
  tls:
    certificate: /certs/registry.crt
    key: /certs/registry.key
  headers:
    X-Content-Type-Options: [nosniff]
auth:
  htpasswd:
    realm: "Registry Realm"
    path: /auth/htpasswd
health:
  storagedriver:
    enabled: true
    interval: 10s
    threshold: 3

Step 4: Docker Compose Deployment

# docker-compose.yml
version: "3.8"

services:
  registry:
    image: registry:2
    restart: always
    ports:
      - "5000:5000"
    volumes:
      - registry-data:/var/lib/registry
      - ./certs:/certs:ro
      - ./auth:/auth:ro
      - ./config.yml:/etc/docker/registry/config.yml:ro
    environment:
      REGISTRY_LOG_LEVEL: info

volumes:
  registry-data:
# Deploy
docker compose up -d

# Test authentication
docker login registry.example.com:5000
# Username: admin
# Password: supersecretpassword

# Push an image
docker tag myapp:latest registry.example.com:5000/myapp:latest
docker push registry.example.com:5000/myapp:latest

Storage Backends

The filesystem backend is fine for small registries, but for production you should consider cloud storage:

Amazon S3

# config.yml - S3 storage section
storage:
  s3:
    accesskey: AKIAIOSFODNN7EXAMPLE
    secretkey: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
    region: us-east-1
    bucket: my-docker-registry
    rootdirectory: /registry
    encrypt: true
    secure: true
    v4auth: true
    chunksize: 5242880
    multipartcopychunksize: 33554432
    multipartcopymaxconcurrency: 100
    multipartcopythresholdsize: 33554432
  delete:
    enabled: true
  cache:
    blobdescriptor: inmemory

Azure Blob Storage

storage:
  azure:
    accountname: mystorageaccount
    accountkey: base64encodedaccountkey
    container: docker-registry
    realm: core.windows.net

Google Cloud Storage

storage:
  gcs:
    bucket: my-docker-registry
    keyfile: /gcs/keyfile.json
    rootdirectory: /registry
    chunksize: 5242880
Tip: When using S3 or other object storage, enable the cache.blobdescriptor: redis option with a Redis instance for significantly faster metadata lookups. The in-memory cache does not persist across registry restarts.

Advanced Authentication

Token-Based Authentication

For larger deployments, token-based authentication provides more flexibility than htpasswd:

# config.yml - token auth
auth:
  token:
    realm: https://auth.example.com/token
    service: registry.example.com
    issuer: "Auth Service"
    rootcertbundle: /certs/auth.crt
    autoredirect: false

This delegates authentication to an external service that issues JWT tokens. The registry verifies the token signature against the provided certificate bundle. Open-source implementations include cesanta/docker_auth and Docker's own token auth spec.

LDAP/Active Directory Integration

The native registry does not support LDAP directly. For LDAP integration, use a reverse proxy like Nginx with LDAP auth modules, or switch to Harbor which has built-in LDAP support:

# Nginx configuration with LDAP auth in front of the registry
server {
    listen 443 ssl;
    server_name registry.example.com;

    ssl_certificate /etc/nginx/certs/registry.crt;
    ssl_certificate_key /etc/nginx/certs/registry.key;

    # Limit upload size for large images
    client_max_body_size 0;

    # Required for chunked transfer encoding
    chunked_transfer_encoding on;

    location /v2/ {
        auth_basic "Registry Realm";
        auth_basic_user_file /etc/nginx/conf.d/htpasswd;

        proxy_pass http://registry: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;
    }
}

Garbage Collection

When you delete a tag or push a new version of an image, the old layers remain on disk. Garbage collection reclaims this space:

# Dry run first (see what would be deleted)
docker exec registry bin/registry garbage-collect \
  /etc/docker/registry/config.yml --dry-run

# Run garbage collection
docker exec registry bin/registry garbage-collect \
  /etc/docker/registry/config.yml

# For large registries, stop the registry first to avoid race conditions
docker compose stop registry
docker compose run --rm registry bin/registry garbage-collect \
  /etc/docker/registry/config.yml
docker compose start registry
Warning: Running garbage collection while the registry is serving requests can cause issues. For production registries, either stop the registry briefly, put it in read-only mode, or use a registry that supports online GC (like Harbor).

Automate garbage collection with a cron job:

# Run GC weekly at 3 AM on Sundays
0 3 * * 0 docker exec registry bin/registry garbage-collect \
  /etc/docker/registry/config.yml >> /var/log/registry-gc.log 2>&1

Registry Web UI

The bare registry has no web interface. Add one with these popular frontends:

Docker Registry UI (Joxit)

# Add to docker-compose.yml
  registry-ui:
    image: joxit/docker-registry-ui:latest
    restart: always
    ports:
      - "8080:80"
    environment:
      REGISTRY_TITLE: "My Docker Registry"
      REGISTRY_URL: https://registry:5000
      DELETE_IMAGES: "true"
      SHOW_CONTENT_DIGEST: "true"
      NGINX_PROXY_PASS_URL: http://registry:5000
      SINGLE_REGISTRY: "true"
    depends_on:
      - registry

Registry Configuration for UI Access

The registry must return proper CORS headers for the UI to work:

# Add to config.yml under http.headers
http:
  headers:
    X-Content-Type-Options: [nosniff]
    Access-Control-Allow-Origin: ["https://registry-ui.example.com"]
    Access-Control-Allow-Methods: ["HEAD", "GET", "OPTIONS", "DELETE"]
    Access-Control-Allow-Headers: ["Authorization", "Accept", "Cache-Control"]
    Access-Control-Expose-Headers: ["Docker-Content-Digest"]

Harbor: The Enterprise Alternative

If you need features beyond what the basic registry provides—vulnerability scanning, RBAC, replication, LDAP/OIDC authentication, audit logs, image signing—consider Harbor:

# Download Harbor installer
wget https://github.com/goharbor/harbor/releases/download/v2.11.0/harbor-online-installer-v2.11.0.tgz
tar xzf harbor-online-installer-v2.11.0.tgz
cd harbor

# Edit harbor.yml
# Set hostname, HTTPS certificates, admin password, database config

# Install
./install.sh --with-trivy --with-chartmuseum
Feature Registry v2 Harbor
Vulnerability scanning No Yes (Trivy integration)
RBAC No Yes (project-level)
Image replication No Yes (push and pull modes)
LDAP/OIDC auth No Yes
Web UI No (third-party) Yes (built-in)
Image signing Notary (separate) Cosign/Notation integrated
Audit logs No Yes
Resource usage ~50MB RAM ~2GB RAM minimum

Registry Mirroring and Caching

Configure your registry as a pull-through cache to reduce Docker Hub rate limit hits and speed up image pulls:

# config.yml for a mirror/cache registry
proxy:
  remoteurl: https://registry-1.docker.io
  username: your-dockerhub-user
  password: your-dockerhub-password
# Configure Docker daemon to use the mirror
# /etc/docker/daemon.json
{
  "registry-mirrors": ["https://registry.example.com:5000"]
}
# Restart Docker daemon
sudo systemctl restart docker

# Now docker pull nginx:alpine will check your mirror first
docker pull nginx:alpine

Monitoring Your Registry

The registry exposes Prometheus metrics and a debug endpoint:

# config.yml
http:
  debug:
    addr: :5001
    prometheus:
      enabled: true
      path: /metrics
# Query metrics
curl http://localhost:5001/metrics | grep registry

# Key metrics to monitor:
# registry_storage_action_seconds - Storage operation latency
# registry_http_in_flight_requests - Current active requests
# registry_storage_blob_upload_bytes - Upload throughput

Platforms like usulnet can integrate with your private registry, providing a unified view of both your running containers and the images available in your registry. This makes it straightforward to track which image versions are deployed where.

Security Hardening Checklist

  1. Always use TLS—never expose a registry over plain HTTP
  2. Enable authentication with strong passwords or token-based auth
  3. Set read-only mode for public-facing registries that should not accept pushes:
    storage:
      maintenance:
        readonly:
          enabled: true
  4. Restrict network access with firewall rules
  5. Enable content trust with Docker Content Trust or Cosign
  6. Scan images for vulnerabilities before pushing (Trivy, Grype)
  7. Rotate credentials regularly
  8. Back up the registry storage and test restores

Conclusion

A private Docker registry is a foundational piece of infrastructure for any serious container deployment. Start with the basic Registry v2 for small teams and straightforward use cases. Graduate to Harbor when you need enterprise features like RBAC, vulnerability scanning, and cross-datacenter replication. Whichever you choose, the investment in running your own registry pays dividends in security, reliability, and independence from external services.