Running a private Docker registry looks simple right up until you have to support it for more than a day. Starting the registry:2 container is the easy part. The real work is making sure clients trust it, credentials are handled sanely, storage does not sprawl forever, and your “internal shortcut” does not turn into a permanent insecure exception in every Docker daemon you manage.
I usually build a private registry for one of four reasons: speed, privacy, compliance, or resilience against public registry limits. In all four cases, TLS matters immediately. Even on an internal network, I do not like sending registry credentials across the wire without encryption, and I especially do not like normalizing insecure-registries unless I have no other option.
This is the practical setup I use: the official Docker Registry v2 image, TLS from either a self-signed internal CA or Let’s Encrypt, htpasswd auth for smaller environments, and enough operational detail to survive after initial deployment.
Why run a private registry at all?
A private registry solves different problems depending on the environment.
The common ones are:
- faster pulls inside a LAN or data center
- reduced dependence on Docker Hub availability and rate limits
- keeping proprietary images private
- meeting internal software distribution requirements
- maintaining a controlled retention policy
- placing a cache close to the workloads that need it
If you build images in CI and deploy them frequently, the time savings from local pulls are real. If you operate regulated or isolated environments, the privacy and control benefits matter even more. I have also used internal registries as a staging point for signed or approved images, where the point was not just convenience but policy enforcement.
A registry does not fix sloppy image construction, though. I want clean images going in, which is why I treat the Dockerfile best practices guide as a prerequisite reading for teams that are about to publish lots of internal images.
The official Registry v2 image
The official implementation is published as registry:2.
Pull it first:
docker pull registry:2
You can prove the registry works with a bare test container:
docker run -d \
--name registry \
-p 5000:5000 \
registry:2
Then check it:
docker logs registry
curl http://127.0.0.1:5000/v2/
That minimal test is useful, but it is not a production configuration. There is no persistence unless you mount storage, no TLS, and no authentication. I use it only to verify the software path, never as the final state.
Host directory layout that stays maintainable
I like a predictable host layout from day one:
sudo mkdir -p /opt/registry/{auth,certs,data}
sudo chown -R root:root /opt/registry
sudo chmod 700 /opt/registry/auth
sudo chmod 755 /opt/registry/certs /opt/registry/data
That gives me:
/opt/registry/certsfor certificate and key files/opt/registry/authfor the htpasswd database/opt/registry/datafor blobs and metadata
It is boring, which is exactly what I want. Six months later, another admin should be able to see the layout and understand it immediately.
TLS certificate options: self-signed, internal CA, or Let’s Encrypt
The certificate choice depends on who the clients are and how the hostname is routed.
Self-signed certificate for a lab or tightly controlled network
For quick internal use, a self-signed certificate works. Generate one like this:
openssl req -x509 -newkey rsa:4096 -sha256 -days 825 -nodes \
-keyout /opt/registry/certs/registry.key \
-out /opt/registry/certs/registry.crt \
-subj '/CN=registry.example.internal' \
-addext 'subjectAltName=DNS:registry.example.internal'
This is fine for a lab, but for anything larger I prefer an internal CA rather than trusting self-signed leaf certificates on every Docker host individually. If you need to convert or inspect certificate material, the OpenSSL conversion guide is useful.
Let’s Encrypt for a real public or DNS-resolvable hostname
If the registry is reachable on a proper DNS name and you want clients to trust it without custom CA distribution, Let’s Encrypt is the cleanest route. I often terminate TLS at Nginx and keep the registry container behind it, because that gives me better control over headers, rate limits, and certificate renewal.
If that is your route, the Certbot automation guide and the Nginx reverse proxy SSL guide are the references I come back to.
What I avoid
I avoid plain HTTP except in isolated throwaway testing, and I avoid teaching teams to rely on Docker’s insecure-registries setting as the normal solution. It works, but it is a fallback, not a design goal.
Registry configuration with config.yml
You can configure the registry through environment variables, but once TLS and auth are involved I strongly prefer a config file. Create /opt/registry/config.yml:
version: 0.1
log:
level: info
storage:
filesystem:
rootdirectory: /var/lib/registry
http:
addr: :5000
tls:
certificate: /certs/registry.crt
key: /certs/registry.key
headers:
X-Content-Type-Options: [nosniff]
auth:
htpasswd:
realm: basic-realm
path: /auth/htpasswd
This enables filesystem-backed storage, built-in TLS, and basic authentication.
If the registry will sit behind a reverse proxy that terminates TLS, I remove the http.tls block and leave the registry listening on plain HTTP internally. The important part is that TLS exists somewhere trustworthy at the edge.
Creating the htpasswd file
For small environments, htpasswd auth is easy and effective. I usually generate it without installing extra packages on the host:
docker run --rm --entrypoint htpasswd httpd:2 -Bbn registryuser 'StrongPasswordHere' \
| sudo tee /opt/registry/auth/htpasswd >/dev/null
The -B flag selects bcrypt, which is what I want. Protect the file afterward:
sudo chmod 640 /opt/registry/auth/htpasswd
Test that the file exists and is readable only where it should be:
sudo ls -l /opt/registry/auth/htpasswd
I do not store this file in shared world-readable paths, and I do not keep placeholder credentials lying around in shell history if I can avoid it. On longer-lived deployments, I prefer generating credentials through automation rather than typing them by hand on production nodes.
Running the registry with docker run
Once the config, auth, and certificate material are ready, start the registry like this:
docker run -d \
--name registry \
--restart unless-stopped \
-p 5000:5000 \
-v /opt/registry/config.yml:/etc/docker/registry/config.yml:ro \
-v /opt/registry/auth:/auth:ro \
-v /opt/registry/certs:/certs:ro \
-v /opt/registry/data:/var/lib/registry \
registry:2
Verify that it is up:
docker ps --filter name=registry
curl -k -I https://127.0.0.1:5000/v2/
If auth is enabled, an unauthenticated request should return 401 Unauthorized. That is good here. Then test with credentials:
curl -k -u registryuser:StrongPasswordHere https://127.0.0.1:5000/v2/
Running it with Docker Compose
For anything beyond a one-off test, I usually switch to Compose because it makes lifecycle management easier.
compose.yaml:
services:
registry:
image: registry:2
container_name: registry
restart: unless-stopped
ports:
- "5000:5000"
volumes:
- /opt/registry/config.yml:/etc/docker/registry/config.yml:ro
- /opt/registry/auth:/auth:ro
- /opt/registry/certs:/certs:ro
- /opt/registry/data:/var/lib/registry
Start it:
docker compose up -d
Follow logs:
docker compose logs -f registry
If Compose is already part of your deployment workflow, the Compose practical guide covers file structure, overrides, and secrets in more detail.
Trusting a self-signed CA on Docker clients
This is the piece that breaks most internal TLS deployments the first time.
For a properly trusted private registry, the Docker daemon on each client host needs to trust the CA that signed the registry certificate. For a self-signed leaf certificate, that certificate itself is what the client trusts.
Create the directory named after the registry host and port:
sudo mkdir -p /etc/docker/certs.d/registry.example.internal:5000
Copy the CA or self-signed cert there as ca.crt:
sudo cp /path/to/registry-ca.crt /etc/docker/certs.d/registry.example.internal:5000/ca.crt
sudo systemctl restart docker
Then test login:
docker login registry.example.internal:5000
If it still fails, I verify three things immediately:
- the hostname in the certificate matches exactly
- the client is connecting to the same host and port represented by the directory name
- the daemon was restarted after the certificate was added
daemon.json and when it matters
A lot of admins expect TLS trust to be configured in /etc/docker/daemon.json. For normal CA trust, the per-registry path under /etc/docker/certs.d/ is the correct approach.
daemon.json is relevant for settings such as registry mirrors or, as a last resort, insecure registries. Example insecure fallback:
{
"insecure-registries": ["registry.example.internal:5000"]
}
Then restart Docker:
sudo systemctl restart docker
I treat insecure-registries as a temporary workaround for a broken trust path, not a preferred permanent solution. It is useful for emergency compatibility. It is not where I want to land.
Logging in, tagging, pushing, and pulling
Log in first:
docker login registry.example.internal:5000
Docker stores credentials in the client’s config file, usually:
~/.docker/config.json
Inspect it if you need to confirm whether a credential helper is in use:
cat ~/.docker/config.json
Do not confuse “the file exists” with “the password is safely stored in plaintext.” On many systems Docker uses a credential store or helper. On simpler hosts it may keep an auth token in the JSON. That affects how you harden developer workstations and CI runners.
Tag an image for the private registry:
docker tag myapp:1.0 registry.example.internal:5000/myteam/myapp:1.0
Push it:
docker push registry.example.internal:5000/myteam/myapp:1.0
Pull it elsewhere:
docker pull registry.example.internal:5000/myteam/myapp:1.0
If pushes fail with certificate or auth errors, I test with curl first, because it tells me whether the problem is basic HTTP/TLS behavior or Docker-specific trust handling.
Storage backends: filesystem first, object storage when needed
For small to medium single-node deployments, the default filesystem backend is fine. It is simple, fast, and easy to reason about as long as the underlying storage is reliable and backed up.
That is why the initial config used:
storage:
filesystem:
rootdirectory: /var/lib/registry
For larger or more durable deployments, object storage such as S3 becomes attractive. A minimal S3-backed example looks like this:
storage:
s3:
region: us-east-1
bucket: my-registry-bucket
encrypt: true
secure: true
rootdirectory: /registry
I reach for S3 when the registry needs durable external storage, easier node replacement, or better scalability characteristics than local disk. The trade-off is complexity: credentials, latency, and object-store behavior now matter.
For local storage, do not forget the boring host-level basics: free space, I/O performance, and backups. Those are the same operational lessons that show up in the disk space management, just with container blobs instead of log files.
Garbage collection and reclaiming space
Deleting tags from a registry does not immediately reclaim disk space. Blob data remains until garbage collection runs.
If deletion is enabled, add this to the config:
storage:
delete:
enabled: true
Then run garbage collection during a maintenance window, ideally with the registry not receiving writes:
docker exec registry registry garbage-collect /etc/docker/registry/config.yml
I prefer stopping pushes before running GC because reclaiming space while the registry is actively changing is not where I like surprises. Also, remember that deleting a tag does not necessarily delete unique blobs if other manifests still reference them.
Validate disk use before and after:
du -sh /opt/registry/data
Putting Nginx in front of the registry
I often terminate TLS and enforce auth-related headers at Nginx instead of inside the registry, especially when I already run a standardized proxy tier.
A minimal Nginx location block looks like this:
server {
listen 443 ssl http2;
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;
client_max_body_size 0;
chunked_transfer_encoding on;
location /v2/ {
proxy_pass http://127.0.0.1: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;
}
}
Validate the config before reload:
sudo nginx -t
sudo systemctl reload nginx
This pattern works well when the registry listens only on loopback and Nginx handles the public edge. It also makes certificate automation with Certbot easier in many environments.
Listing images and tags through the Registry API
The Registry HTTP API is useful for validation and troubleshooting.
List repositories:
curl -u registryuser:StrongPasswordHere https://registry.example.internal:5000/v2/_catalog
List tags for one repository:
curl -u registryuser:StrongPasswordHere https://registry.example.internal:5000/v2/myteam/myapp/tags/list
With self-signed TLS during testing, add -k until trust is fixed properly:
curl -k -u registryuser:StrongPasswordHere https://registry.example.internal:5000/v2/_catalog
I use these API calls to distinguish registry-side problems from Docker CLI-side problems.
Mirror mode for Docker Hub
Sometimes you do not need a primary private registry for internal images right away. What you need first is a pull-through cache for Docker Hub.
Registry supports proxy mode. Example config snippet:
proxy:
remoteurl: https://registry-1.docker.io
Then point Docker clients at it as a registry mirror in /etc/docker/daemon.json:
{
"registry-mirrors": ["https://registry.example.internal:5000"]
}
Restart Docker:
sudo systemctl restart docker
This helps reduce upstream pulls and can smooth over public rate limits. It is also a nice stepping stone if you want to build registry familiarity without moving every internal image workflow on day one.
A quick validation checklist after deployment
After the registry is live, I do not stop at docker ps. My short checklist is: TLS handshake works, unauthenticated requests are rejected, authenticated catalog access works, a small test image can be pushed and pulled, and the storage path grows where I expect it to. Those checks take a couple of minutes and catch most bad first deployments before users hit them.
Common mistakes and security issues
These are the mistakes I see repeatedly:
- Using HTTP and promising to fix it later. Later rarely comes.
- Trusting the wrong certificate path on clients. Docker wants
/etc/docker/certs.d/host:port/ca.crt. - Relying on
insecure-registriespermanently. It becomes institutional debt. - Forgetting persistence. Images vanish after container recreation.
- Skipping auth because the registry is “internal.” Internal networks are not magic safe zones.
- Ignoring garbage collection. Disk use grows until the host complains.
- Using weak or shared credentials. Registry access is part of your supply chain.
From a security perspective, I want three things minimum:
- TLS for every login and push/pull operation
- authenticated access, even on internal networks
- image provenance and scanning elsewhere in the pipeline
If the registry becomes important infrastructure, I also think about host hardening, backup strategy, and whether object storage or an HA-capable product would serve better than a single local container.
Conclusion
A private Docker registry is not hard to start, but it is easy to deploy carelessly. The difference between a useful internal service and a permanent insecure workaround is mostly in the details: trustworthy TLS, correct client trust distribution, persistent storage, authentication, and operational cleanup.
My default recommendation is simple: use the official registry image, put it behind proper TLS, trust a CA instead of disabling verification, and keep the configuration boring. That combination scales much better than shortcuts, and it is far easier to defend later when the registry stops being “just a small internal tool.”