Self-Hosted Docker Registry Hardening: Authentication, Network Segmentation, and Image Signing

Running a self-hosted Docker registry gives you full control over your container image supply chain. It also gives you full responsibility for securing it. A misconfigured registry is a direct path to deploying compromised images across your entire infrastructure.

This guide covers deploying a production-hardened private registry behind a reverse proxy with TLS termination, token-based authentication, network segmentation, image signing, vulnerability scanning, and the performance tuning details that documentation rarely covers.

Architecture: Reverse Proxy in Front of Everything

Never expose the Docker registry directly. Place it behind a reverse proxy that handles TLS termination, authentication, and request buffering. The architecture looks like this:

Client → Reverse Proxy (TLS, port 443) → Registry (port 5000, localhost only)

The registry binds to 127.0.0.1:5000 and accepts only plaintext HTTP from the local reverse proxy. The proxy handles TLS, client authentication, and access control.

Apache Configuration for Registry Proxying

<VirtualHost :443>
    ServerName registry.example.com

    SSLEngine On
    SSLCertificateFile    /etc/ssl/certs/registry.example.com.pem
    SSLCertificateKeyFile /etc/ssl/private/registry.example.com.key
    SSLProtocol           -all +TLSv1.2 +TLSv1.3
    SSLCipherSuite        ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384

    # Registry requires these headers
    RequestHeader set X-Forwarded-Proto "https"

    # Proxy to local registry
    ProxyPass        / http://127.0.0.1:5000/ nocanon
    ProxyPassReverse / http://127.0.0.1:5000/

    # Critical: increase timeout for large image pushes
    ProxyTimeout 900

    # Increase request body size for layer uploads
    LimitRequestBody 0

    # Basic auth (or integrate with your IdP)
    <Location /v2/>
        AuthType Basic
        AuthName "Registry Authentication"
        AuthUserFile /etc/registry/htpasswd
        Require valid-user
    </Location>
</VirtualHost>

Nginx Alternative with Buffer Tuning

If using nginx as the internal proxy layer (for example, between an outer reverse proxy and the registry), buffer sizing and tmpfs configuration are critical for reliability under load:

upstream docker-registry {
    server 127.0.0.1:5000;
}

server {
    listen 80;
    server_name registry.internal;

    # This is the single most important setting for registry reliability.
    # Without sufficient buffer space, concurrent pushes of large images
    # will fail with 502 errors.
    client_max_body_size 0;

    proxy_buffering on;
    proxy_buffer_size 32k;
    proxy_buffers 16 64k;
    proxy_busy_buffers_size 128k;

    # Temp file storage for large layer uploads
    proxy_temp_path /var/cache/nginx/proxy_temp;
    proxy_max_temp_file_size 4096m;

    # Timeouts for large pushes
    proxy_read_timeout 900s;
    proxy_send_timeout 900s;
    proxy_connect_timeout 60s;

    location /v2/ {
        proxy_pass http://docker-registry;
        proxy_set_header Host $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;
    }
}

tmpfs Cache Tuning

A subtle but devastating issue: if nginx’s temp file storage runs on a small filesystem, concurrent large pushes will fill it and return 502 errors with no useful log message. Mount tmpfs with adequate space:

# /etc/fstab entry
tmpfs /var/cache/nginx tmpfs defaults,size=2G,noexec,nosuid 0 0

# Apply without reboot
mount -o remount /var/cache/nginx

# Verify
df -h /var/cache/nginx

Size this at 2x your largest expected image layer. Multi-gigabyte ML model images can have individual layers exceeding 1GB. If you are regularly pushing images over 500MB, allocate at least 2GB for the tmpfs cache.

Registry Configuration

The registry itself needs hardening beyond defaults:

# /etc/docker-registry/config.yml
version: 0.1
log:
  level: info
  formatter: json

storage:
  filesystem:
    rootdirectory: /var/lib/registry
  delete:
    enabled: true  # Required for garbage collection
  maintenance:
    uploadpurging:
      enabled: true
      age: 168h      # Purge incomplete uploads after 7 days
      interval: 24h
      dryrun: false

http:
  addr: 127.0.0.1:5000   # Bind to loopback only
  host: https://registry.example.com
  headers:
    X-Content-Type-Options: [nosniff]
  http2:
    disabled: false

health:
  storagedriver:
    enabled: true
    interval: 10s
    threshold: 3

Network Segmentation

The registry should live on an isolated network segment accessible only to:

  1. The reverse proxy (for client access)
  2. CI/CD runners (for image pushes)
  3. Container runtime hosts (for image pulls)

Using firewall rules:

# Allow reverse proxy to reach registry
iptables -A INPUT -s 10.0.1.10/32 -p tcp --dport 5000 -j ACCEPT

# Allow CI/CD runner subnet
iptables -A INPUT -s 10.0.5.0/24 -p tcp --dport 5000 -j ACCEPT

# Allow container host subnet
iptables -A INPUT -s 10.0.10.0/24 -p tcp --dport 5000 -j ACCEPT

# Drop everything else to registry port
iptables -A INPUT -p tcp --dport 5000 -j DROP

For environments using VLANs or software-defined networking, place the registry on a dedicated VLAN with ACLs enforcing the same restrictions at the network layer.

Token-Based Authentication

For environments with more than a handful of users, move beyond htpasswd to token-based authentication. The Docker registry supports a token authentication protocol where a separate auth service issues JWT tokens:

# Registry config addition
auth:
  token:
    realm: https://auth.example.com/token
    service: registry.example.com
    issuer: "Registry Auth Service"
    rootcertbundle: /etc/registry/auth-cert.pem
    autoredirect: false

The auth service validates credentials against your identity provider (LDAP, OIDC, etc.) and issues scoped tokens:

{
  "access": [
    {
      "type": "repository",
      "name": "myapp/backend",
      "actions": ["pull"]
    }
  ]
}

This enables fine-grained access control — developers can push to their project repositories but only pull from shared base images.

Image Signing with Cosign

Unsigned images are unverified images. Use Cosign to sign images at build time and verify signatures at pull time:

# Generate a signing keypair (store the private key securely)
cosign generate-key-pair

# Sign an image after pushing
cosign sign --key cosign.key registry.example.com/myapp/backend:v1.2.0

# Verify before deploying
cosign verify --key cosign.pub registry.example.com/myapp/backend:v1.2.0

Integrate signing into your CI/CD pipeline:

# .gitlab-ci.yml example
sign-image:
  stage: sign
  script:
    - docker push ${REGISTRY}/${IMAGE}:${TAG}
    - cosign sign --key ${COSIGN_PRIVATE_KEY} ${REGISTRY}/${IMAGE}:${TAG}
  only:
    - main

For keyless signing in CI/CD environments, use Cosign’s OIDC-based signing with Fulcio and Rekor:

# Keyless signing (uses OIDC identity from CI/CD environment)
COSIGN_EXPERIMENTAL=1 cosign sign ${REGISTRY}/${IMAGE}:${TAG}

Enforcing Signature Verification

Signing images is pointless if nothing enforces verification. Use a Kubernetes admission controller to reject unsigned images:

# Kyverno policy to enforce Cosign signatures
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: Enforce
  background: false
  rules:
    - name: verify-cosign-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "registry.example.com/"
          attestors:
            - entries:
                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                      MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
                      -----END PUBLIC KEY-----

Vulnerability Scanning

Scan every image on push using Trivy integrated into your CI pipeline:

# Scan before pushing to registry
trivy image --severity HIGH,CRITICAL --exit-code 1 
  ${REGISTRY}/${IMAGE}:${TAG}

# Periodic scan of all images in registry
trivy repo --scanners vuln registry.example.com

For continuous scanning of images already in the registry, run a scheduled job:

#!/bin/bash
# scan-registry.sh — run nightly via cron
REGISTRY="registry.example.com"

# List all repositories
repos=$(curl -s -u "${REG_USER}:${REG_PASS}" 
  "https://${REGISTRY}/v2/_catalog" | jq -r '.repositories[]')

for repo in $repos; do
  tags=$(curl -s -u "${REG_USER}:${REG_PASS}" 
    "https://${REGISTRY}/v2/${repo}/tags/list" | jq -r '.tags[]')

  for tag in $tags; do
    echo "Scanning ${repo}:${tag}"
    trivy image --severity HIGH,CRITICAL 
      --format json 
      --output "/var/log/registry-scans/${repo////_}_${tag}.json" 
      "${REGISTRY}/${repo}:${tag}"
  done
done

Garbage Collection and Storage Management

Deleted image tags do not free disk space until garbage collection runs. Schedule it during maintenance windows:

# Dry run first
docker exec registry bin/registry garbage-collect 
  /etc/docker/registry/config.yml --dry-run

# Actual garbage collection (registry should be read-only during this)
docker exec registry bin/registry garbage-collect 
  /etc/docker/registry/config.yml

Set the registry to read-only mode during GC to prevent data corruption:

storage:
  maintenance:
    readonly:
      enabled: true  # Enable during GC, disable after

Monitoring and Alerting

Monitor registry health and usage:

# Check registry health endpoint
curl -s https://registry.example.com/v2/ | jq .

# Monitor storage usage
du -sh /var/lib/registry/docker/registry/v2/

# Alert on disk usage exceeding threshold
USAGE=$(df /var/lib/registry --output=pcent | tail -1 | tr -d ' %')
if [ "$USAGE" -gt 85 ]; then
  echo "Registry storage at ${USAGE}% — run garbage collection" | 
    mail -s "Registry Storage Alert" [email protected]
fi

A hardened self-hosted registry is more work than pulling from Docker Hub, but it gives you a verifiable, auditable, and air-gap-compatible image supply chain. Every image your infrastructure runs came from a source you control, was signed by a key you manage, and was scanned before it ever reached a production host.

Scroll to Top