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:
- The reverse proxy (for client access)
- CI/CD runners (for image pushes)
- 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.
