Docker 62 🐳 Private Registry Authentication, TLS Encryption, and Garbage Collection
A private registry running without authentication and TLS is a liability. Anyone who can reach the port can push malicious images, overwrite your tags, or pull your proprietary code. Anyone on the network path can read every byte of your images in transit. And even a secured registry will eventually fill its disk with orphaned layers—blobs that no manifest references anymore because the tags that pointed to them were deleted. This chapter covers the three pillars that turn the minimal Docker Registry server from a development toy into production infrastructure: authentication, encryption, and storage reclamation.
The Docker Registry server does not ship with built-in user management. It supports three authentication modes: silly (development only, checks only for the presence of a header), htpasswd (username/password with no authorization), and token (full authentication and authorization via JWT) . The htpasswd mode is the simplest production-ready option—it requires a password file generated with the htpasswd utility, and the registry reads it at startup. The token mode is what Harbor and other enterprise registries use, delegating authentication to an external service that issues signed tokens scoped to specific repositories and actions.
TLS is not optional for any registry beyond localhost. The Docker daemon refuses to connect to a plain HTTP registry unless the address is explicitly added to the insecure-registries list in /etc/docker/daemon.json . In production, this is unacceptable. The standard pattern is to run the registry behind a reverse proxy like Nginx that terminates TLS, or to configure the registry itself with a certificate and key . Let’s Encrypt provides free certificates with automatic renewal, making it the default choice for public-facing registries .
Garbage collection is the third pillar. When you delete an image tag—whether through the API, the UI, or skopeo delete—the registry removes the manifest reference but leaves the underlying layer blobs on disk . Layers are shared across images and tags, so the registry cannot simply delete them when one tag disappears. Garbage collection scans the registry, identifies blobs not referenced by any manifest, and deletes them. For the open-source Docker Registry, this is a manual command; for Harbor, it is a scheduled job with retention policies .
Key point: A production private registry requires three independent configurations—htpasswd or token authentication to control access, TLS certificates to encrypt traffic, and a scheduled garbage collection routine to reclaim disk space from deleted image layers.
Why these three controls exist
The unauthenticated push problem. The Docker Registry server with default settings accepts docker push from anyone who can reach the port. There is no user concept, no permission model, no audit trail. An attacker who discovers the registry can push an image tagged latest for any repository and wait for your CI/CD pipeline to pull it. The htpasswd mode requires a valid username and password for every push and pull . The token mode goes further, issuing JWTs that specify which repositories a user can access and what actions they can perform—one user might pull only from repo1, while another can push and pull from repo2 .
The plaintext traffic problem. Without TLS, every credential, every image layer, and every manifest travels over the network in plaintext. On a shared network, anyone with a packet capture tool can read your passwords and steal your images. Docker enforces TLS by default: a registry without a trusted certificate produces the error http: server gave HTTP response to HTTPS client . The insecure-registries workaround exists for local testing, but using it in production means accepting that your credentials and code are exposed.
The shared layer problem. Container images are composed of layers, and layers are shared. Ten images based on ubuntu:22.04 all reference the same base layer. When you delete one image, the registry cannot delete the base layer because nine other images still need it. Over time, as tags are overwritten and images are removed, the registry accumulates layers that no manifest references. These orphaned blobs consume disk space but serve no purpose. Garbage collection identifies and removes them .
The read-only window. Garbage collection cannot run while pushes are happening, because a layer that appears unreferenced during the scan might be about to be referenced by an in-progress push. The registry enters a read-only mode during garbage collection—pushes are blocked, pulls continue . For a busy registry, this means scheduling garbage collection during off-peak hours, or accepting a brief window where pushes fail.
a. Authentication with htpasswd
The htpasswd authentication mode is the simplest way to require credentials for registry access. It uses the same password file format as Apache’s basic authentication, generated with the htpasswd utility.
The registry expects the password file to use bcrypt hashing. The -B flag forces bcrypt, and the -b flag accepts the password on the command line (for scripting) or -n writes the output to stdout :
htpasswd -Bbn admin 'SecurePassphrase!' > /opt/registry/auth/htpasswd
The htpasswd utility is provided by the apache2-utils package on Debian/Ubuntu and httpd-tools on RHEL/Fedora . If you do not want to install the utility on the host, you can run it inside a container:
docker run --rm --entrypoint htpasswd httpd:2.4 \
-Bbn deploy 'ChangeMeStrongPassphrase!' > /opt/registry/auth/htpasswd
Once the password file exists, the registry is configured to use it through environment variables:
-e REGISTRY_AUTH=htpasswd
-e REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm"
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd
The REALM is the string presented in the authentication challenge—it is informational. The PATH must point to the password file inside the container, which means mounting it as a volume .
The htpasswd mode provides authentication but not authorization. Any valid user can push to and pull from any repository. If you need per-repository permissions, you must use token authentication or a registry like Harbor that adds an authorization layer .
b. TLS encryption with certificates
Docker clients refuse to connect to a registry over plain HTTP unless the registry is explicitly marked as insecure. For production, the registry must serve HTTPS with a certificate the client trusts.
The registry accepts a certificate and key directly through environment variables:
-e REGISTRY_HTTP_ADDR=0.0.0.0:443
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt
-e REGISTRY_HTTP_TLS_KEY=/certs/domain.key
The certificate and key must be mounted into the container at the paths specified . The key must be readable by the registry process; setting permissions to 600 on the host is standard practice .
For a self-signed certificate—acceptable for internal registries where clients can be configured to trust the CA—generate the pair with OpenSSL:
openssl req -newkey rsa:4096 -nodes -sha256 \
-keyout /opt/registry/certs/domain.key \
-x509 -days 365 \
-out /opt/registry/certs/domain.crt \
-subj "/CN=registry.example.com" \
-addext "subjectAltName=DNS:registry.example.com"
The subjectAltName extension is essential. Modern TLS clients ignore the Common Name and require the hostname to appear in the SAN list. Without it, Docker will reject the certificate even if the CN matches .
For a certificate from a trusted CA—including Let’s Encrypt—the process differs. Let’s Encrypt certificates are issued through the ACME protocol and must be renewed every 90 days. The standard pattern is to run Certbot alongside Nginx, with Nginx terminating TLS and proxying to the registry on port 5000 . The Certbot container runs a renewal loop every 12 hours, and Nginx serves the challenge responses for the ACME validation .
If the registry is running on a host with a self-signed certificate, every Docker client that pulls from it must trust the CA. On Linux, this means copying the certificate to /usr/local/share/ca-certificates/ and running update-ca-certificates on Debian/Ubuntu, or to /etc/pki/ca-trust/source/anchors/ and running update-ca-trust on RHEL/Fedora . Alternatively, the certificate can be placed in /etc/docker/certs.d/registry.example.com:5000/ca.crt on each client, which Docker reads specifically for registry connections.
c. Garbage collection
Garbage collection is the process of identifying and deleting blobs that no manifest references. For the open-source Docker Registry, it is a command-line operation run inside the registry container :
docker exec -it registry /usr/bin/registry garbage-collect /etc/docker/registry/config.yml
The registry must be configured with delete: enabled: true under the storage section of config.yml. Without this setting, the API rejects manifest deletion requests, and garbage collection has nothing to clean up because nothing can be marked for deletion .
The -m flag (or --delete-untagged in some versions) extends garbage collection to include untagged manifests—manifests that were pushed but never tagged, or whose tags were removed without deleting the manifest itself . This is usually desirable, as untagged manifests are conventionally abandoned artifacts. The flag is passed to the garbage-collect command:
docker exec -it registry /usr/bin/registry garbage-collect -m /etc/docker/registry/config.yml
Garbage collection is CPU- and I/O-intensive. It scans every manifest and every blob in the registry, builds a reference graph, and deletes the unreferenced nodes. For a registry with terabytes of images, this can take hours . The registry enters read-only mode during the process—pushes are blocked, pulls are allowed . For a busy registry, scheduling garbage collection during off-peak hours is standard practice.
Harbor provides a more sophisticated approach. Instead of a manual command, Harbor has a garbage collection job that can be scheduled, run on demand, or triggered by retention policies . Retention policies define rules like “keep the latest 3 tags” or “delete images not pushed in 90 days,” and Harbor automatically marks images for deletion and runs garbage collection . The Harbor garbage collection process has three phases: read-only mode (pushes blocked), marking (scanning for unreferenced layers), and deletion (removing marked layers) .
The key operational point is that garbage collection is not automatic for the open-source registry. You must run it manually, or schedule it with cron or a CI job. Without it, deleted images continue to consume disk space indefinitely.
Complete Example Session
The following session deploys a Docker Registry with htpasswd authentication and TLS, then demonstrates garbage collection.
# ============================================
# PART 1: PREPARE DIRECTORIES AND CERTIFICATES
# ============================================
sudo mkdir -p /opt/registry/{data,auth,certs}
sudo chown -R $USER:$USER /opt/registry
# Generate self-signed certificate with SAN
openssl req -newkey rsa:4096 -nodes -sha256 \
-keyout /opt/registry/certs/domain.key \
-x509 -days 365 \
-out /opt/registry/certs/domain.crt \
-subj "/CN=registry.example.com" \
-addext "subjectAltName=DNS:registry.example.com"
chmod 600 /opt/registry/certs/domain.key
# ============================================
# PART 2: CREATE HTPASSWD FILE
# ============================================
docker run --rm --entrypoint htpasswd httpd:2.4 \
-Bbn admin 'SecurePassphrase!' > /opt/registry/auth/htpasswd
# ============================================
# PART 3: CONFIGURE REGISTRY WITH DELETION ENABLED
# ============================================
# Create config.yml with delete enabled
cat > /opt/registry/config.yml << 'EOF'
version: 0.1
storage:
filesystem:
rootdirectory: /var/lib/registry
delete:
enabled: true
http:
addr: 0.0.0.0:443
tls:
certificate: /certs/domain.crt
key: /certs/domain.key
auth:
htpasswd:
realm: Registry Realm
path: /auth/htpasswd
EOF
# ============================================
# PART 4: RUN THE REGISTRY
# ============================================
docker run -d \
--name registry \
--restart=always \
-p 443:443 \
-v /opt/registry/data:/var/lib/registry \
-v /opt/registry/certs:/certs \
-v /opt/registry/auth:/auth \
-v /opt/registry/config.yml:/etc/docker/registry/config.yml \
registry:2
# ============================================
# PART 5: VERIFY AUTHENTICATION AND TLS
# ============================================
# Without credentials: 401
curl -k https://localhost/v2/_catalog
# With credentials: 200
curl -k -u admin:SecurePassphrase! https://localhost/v2/_catalog
# ============================================
# PART 6: LOG IN AND PUSH IMAGES
# ============================================
docker login localhost -u admin
# Enter password when prompted
docker pull hello-world:latest
docker tag hello-world:latest localhost/hello-world:v1
docker tag hello-world:latest localhost/hello-world:v2
docker push localhost/hello-world:v1
docker push localhost/hello-world:v2
# ============================================
# PART 7: DELETE A TAG (LEAVES ORPHANED LAYER)
# ============================================
# Get the manifest digest for v1
DIGEST=$(curl -k -u admin:SecurePassphrase! \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
-I https://localhost/v2/hello-world/manifests/v1 | grep -i docker-content-digest | awk '{print $2}' | tr -d '\r')
# Delete the manifest
curl -k -u admin:SecurePassphrase! -X DELETE \
https://localhost/v2/hello-world/manifests/$DIGEST
# ============================================
# PART 8: RUN GARBAGE COLLECTION
# ============================================
# First, see how much space is used
du -sh /opt/registry/data/docker/registry/v2/blobs/
# Run garbage collection with untagged manifest deletion
docker exec -it registry /usr/bin/registry garbage-collect -m /etc/docker/registry/config.yml
# Check space again — may be reduced if the layer was only referenced by v1
du -sh /opt/registry/data/docker/registry/v2/blobs/
# ============================================
# PART 9: VERIFY CATALOG STILL WORKS
# ============================================
curl -k -u admin:SecurePassphrase! https://localhost/v2/_catalog
# Should still show hello-world (v2 remains)
# ============================================
# PART 10: SCHEDULE GARBAGE COLLECTION
# ============================================
# Add a cron job to run GC weekly at 2 AM
# 0 2 * * 0 docker exec -it registry /usr/bin/registry garbage-collect -m /etc/docker/registry/config.yml
The ten parts cover certificate generation, htpasswd creation, registry configuration with deletion enabled, deployment with volumes, verification of auth and TLS, image push, tag deletion to create orphaned layers, manual garbage collection, catalog verification, and scheduling. What the session demonstrates is that authentication and TLS are configuration—they require specific environment variables and mounted files—while garbage collection is an operational routine that must be scheduled and executed deliberately.
Quick Reference
Authentication Modes
| Mode | Security | Authorization | Setup |
|---|---|---|---|
silly | None (header check only) | None | Development only |
htpasswd | Username/password | None (all users equal) | htpasswd file |
token | JWT-based | Per-repository | External auth service |
TLS Configuration
| Method | Certificate | Renewal |
|---|---|---|
| Self-signed | Generated with OpenSSL | Manual (365 days) |
| Let’s Encrypt | Certbot + Nginx | Automatic (90 days) |
| Internal CA | Organization-issued | Per CA policy |
Garbage Collection Commands
| Registry | Command |
|---|---|
| Docker Registry | docker exec -it registry /usr/bin/registry garbage-collect /etc/docker/registry/config.yml |
| Docker Registry (untagged) | docker exec -it registry /usr/bin/registry garbage-collect -m /etc/docker/registry/config.yml |
| Harbor | UI: System → Garbage Collection; or API |
| DigitalOcean | doctl registries garbage-collection start --include-untagged-manifests |
Required Environment Variables
| Variable | Purpose |
|---|---|
REGISTRY_AUTH=htpasswd | Enable htpasswd authentication |
REGISTRY_AUTH_HTPASSWD_REALM | Realm string in challenge |
REGISTRY_AUTH_HTPASSWD_PATH | Path to password file |
REGISTRY_HTTP_TLS_CERTIFICATE | Path to certificate |
REGISTRY_HTTP_TLS_KEY | Path to private key |
REGISTRY_STORAGE_DELETE_ENABLED=true | Enable manifest deletion |
Best Practices
✅ Do This:
htpasswd -Bbn user 'password' > auth/htpasswd # ✅ bcrypt hash
docker run -d -p 443:443 -v /certs:/certs registry:2 # ✅ TLS enabled
REGISTRY_STORAGE_DELETE_ENABLED=true # ✅ enable deletion
docker exec registry registry garbage-collect -m config.yml # ✅ GC untagged
❌ Don’t Do This:
htpasswd -bn user 'password' > auth/htpasswd # ❌ no -B, weak hash
docker run -d -p 5000:5000 registry:2 # ❌ no auth, no TLS
# never run garbage collection # ❌ disk fills silently
curl -k -u admin:pass https://localhost/v2/_catalog # ❌ -k ignores CA
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
http: server gave HTTP response | Docker expects HTTPS | Add to insecure-registries or enable TLS |
| Auth fails after restart | htpasswd not persisted | Mount /auth as volume |
| Certificate rejected | SAN missing hostname | Regenerate with subjectAltName |
| Deleted images still consume space | Garbage collection not run | Run GC manually or schedule |
| GC fails with “delete disabled” | delete: enabled not set | Update config.yml |
| Read-only mode during GC | Expected behavior | Schedule during off-peak |
| Push fails during GC | Pushes blocked | Wait for GC to finish |
Real-World Examples
1. Self-signed certificate for internal registry
openssl req -newkey rsa:4096 -nodes -sha256 -keyout domain.key -x509 -days 365 -out domain.crt -subj "/CN=registry.internal" -addext "subjectAltName=DNS:registry.internal"
2. Let’s Encrypt with Nginx
server {
listen 443 ssl;
server_name registry.example.com;
ssl_certificate /etc/letsencrypt/live/registry.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/registry.example.com/privkey.pem;
location / { proxy_pass http://localhost:5000; }
}
3. htpasswd with multiple users
htpasswd -Bbn alice 'pass1' > auth/htpasswd
htpasswd -Bb auth/htpasswd bob 'pass2'
htpasswd -Bb auth/htpasswd carol 'pass3'
4. Manual garbage collection
docker exec -it registry /usr/bin/registry garbage-collect -m /etc/docker/registry/config.yml
5. Kubernetes pull secret
kubectl create secret docker-registry regcred \
--docker-server=registry.example.com \
--docker-username=admin \
--docker-password=SecurePassphrase!
6. Harbor retention policy
# Keep latest 3 tags, delete images older than 90 days
rules:
- repository: "**"
parameters:
latestPushedArtifacts: 3
daysSinceLastPush: 90
7. DigitalOcean garbage collection
doctl registries garbage-collection start --include-untagged-manifests my-registry
8. Client trust for self-signed
sudo mkdir -p /etc/docker/certs.d/registry.example.com:443
sudo cp domain.crt /etc/docker/certs.d/registry.example.com:443/ca.crt
9. Cron job for garbage collection
0 2 * * 0 docker exec -it registry /usr/bin/registry garbage-collect -m /etc/docker/registry/config.yml
10. Config.yml with auth and TLS
version: 0.1
storage:
filesystem:
rootdirectory: /var/lib/registry
delete:
enabled: true
http:
addr: 0.0.0.0:443
tls:
certificate: /certs/domain.crt
key: /certs/domain.key
auth:
htpasswd:
realm: Registry Realm
path: /auth/htpasswd
Visual
Security Layers of a Private Registry
┌──────────────────────────────────────────────┐
│ LAYER 1: TLS ENCRYPTION │
│ Certificate + key │
│ Encrypts all traffic │
│ Docker requires trusted CA │
│ │
│ LAYER 2: AUTHENTICATION │
│ htpasswd or token │
│ Verifies identity │
│ docker login required │
│ │
│ LAYER 3: AUTHORIZATION (token only) │
│ Per-repository permissions │
│ Pull-only, push+pull, admin │
│ │
│ LAYER 4: STORAGE MANAGEMENT │
│ Garbage collection │
│ Reclaims orphaned layers │
└──────────────────────────────────────────────┘
htpasswd Authentication Flow
┌──────────────────────────────────────────────┐
│ docker push registry:5000/app:v1 │
│ │ │
│ ▼ │
│ Registry checks Authorization header │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ absent present │
│ │ │ │
│ ▼ ▼ │
│ 401 Verify against htpasswd │
│ WWW- │ │
│ Authenticate │ │
│ ┌───────┴───────┐ │
│ │ │ │
│ valid invalid │
│ │ │ │
│ ▼ ▼ │
│ proceed 401 Unauthorized │
└──────────────────────────────────────────────┘
Garbage Collection Phases
┌──────────────────────────────────────────────┐
│ PHASE 1: READ-ONLY MODE │
│ Pushes blocked │
│ Pulls continue │
│ Wait for in-flight pushes to finish │
│ │
│ PHASE 2: MARKING │
│ Scan all manifests │
│ Build reference graph │
│ Mark unreferenced blobs │
│ │
│ PHASE 3: DELETION │
│ Delete marked blobs │
│ Reclaim disk space │
│ Return to normal mode │
└──────────────────────────────────────────────┘
TLS with Nginx Reverse Proxy
┌──────────────────────────────────────────────┐
│ CLIENT │
│ docker push registry.example.com/app:v1 │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ NGINX (port 443) │ │
│ │ - TLS termination │ │
│ │ - Let's Encrypt certificate │ │
│ │ - Proxy to registry:5000 │ │
│ └────────────────┬───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ REGISTRY (port 5000, HTTP internal) │ │
│ │ - htpasswd auth │ │
│ │ - Filesystem storage │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Auth modes | silly, htpasswd, token |
| htpasswd hash | bcrypt (-B flag) |
| Auth realm | Displayed in challenge |
| TLS enforcement | Docker requires HTTPS for non-localhost |
| Self-signed | OpenSSL with SAN extension |
| Let’s Encrypt | Certbot + Nginx, 90-day renewal |
| Garbage collection | Manual for Docker Registry |
| GC flag | -m for untagged manifests |
| GC mode | Read-only during scan |
| Delete enabled | REGISTRY_STORAGE_DELETE_ENABLED=true |
Key takeaways:
- htpasswd is the simplest production auth. It requires only a password file and three environment variables. It authenticates but does not authorize—all users are equal .
- Token auth adds per-repository permissions. The JWT scopes what each user can pull and push. This is what Harbor uses internally .
- TLS is mandatory beyond localhost. Docker refuses plain HTTP connections. Self-signed certificates require client-side trust configuration .
- Let’s Encrypt automates certificate renewal. Run Certbot alongside Nginx, and certificates renew every 90 days without manual intervention .
- Garbage collection is not automatic. The open-source registry accumulates orphaned layers indefinitely unless you run GC manually or schedule it .
- The
-mflag deletes untagged manifests. Without it, only blobs referenced by deleted manifests are removed, leaving untagged manifests behind . - GC blocks pushes. The registry enters read-only mode during collection. Schedule it during off-peak hours .
- Deletion must be enabled. The registry rejects manifest deletion API calls unless
REGISTRY_STORAGE_DELETE_ENABLED=trueis set .
Remember: A private registry without authentication is a public registry. Without TLS, it is an unencrypted public registry. Without garbage collection, it is a public registry that will eventually run out of disk. The three controls are independent and all necessary. Authentication uses htpasswd for simplicity or tokens for granular permissions. TLS uses self-signed certificates for internal networks or Let’s Encrypt for public-facing registries. Garbage collection is an operational routine—it does not run itself, and the registry will grow indefinitely without it. Configure all three, verify they work, and schedule garbage collection before the disk fills.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!