Docker Hub is very practical for public images, but many organizations cannot simply place internal application images in a third-party registry, whether due to security policies, regulatory compliance, limits on the number of private repositories in free plans, or simply the need for lower push/pull latency because the registry resides on the same network as production servers. The solution is running a self-hosted private registry, a self-managed and self-hosted registry server, usually using Distribution, an open-source project under the CNCF that serves as the official Docker registry implementation. This chapter covers how to run your own registry, configure it via config.yml, secure it with TLS and authentication, and interact directly with the registry API for automation needs.
20.1 Self-Hosted Registry
Distribution is packaged in an official image named registry, making running your own registry fundamentally as easy as running any other container. This section discusses how to run it along with data storage considerations.
20.1.1 Running a Registry with Docker
Run the registry container using the official registry image with major version tag 3, the version of Distribution currently actively developed and recommended for new deployments.
docker run -d -p 5000:5000 --restart=always --name registry registry:3The command above runs the registry in the background (-d), maps port 5000 on the host to port 5000 in the container where the registry listens for HTTP requests, and sets a restart policy of always so that the registry automatically restarts if the Docker daemon restarts. Once the container is running, the registry can immediately be tested by re-tagging a local image with this registry address and then pushing as usual.
docker tag my-app localhost:5000/my-app:1.0
docker push localhost:5000/my-app:1.0
docker pull localhost:5000/my-app:1.0Note the localhost:5000/ prefix in front of the image name. Just like the image naming format discussed in the chapter on Docker Hub, this part represents HOST[:PORT], which tells the Docker CLI where push/pull requests should be directed instead of to docker.io by default.
If the registry address is not localhost, for instance accessed via an internal IP or domain, push attempts usually fail immediately with the error http: server gave HTTP response to HTTPS client. This message appears because the Docker CLI always assumes the registry is running over HTTPS unless the address is localhost. Such a registry requires a TLS certificate to be accessed securely from other machines, or if it is strictly for experiments on a closed network, its address can be registered as an insecure registry in the Docker Engine configuration.
20.1.2 Registry Data Persistence
By default, the registry stores all image layers pushed to it inside the /var/lib/registry directory within the container. Without a volume, this data is lost as soon as the container is removed, a scenario that frequently traps Sysadmins/DevOps Engineers trying out a custom registry for the first time who are caught off guard when their container is removed via docker rm for upgrade purposes and all previously pushed images disappear alongside it. Attach a bind mount or named volume so that registry data persists regardless of the container's lifecycle.
docker run -d -p 5000:5000 --restart=always --name registry \
-v /mnt/registry-data:/var/lib/registry \
registry:3For production needs with large data volumes or cross-server replication requirements, Distribution also supports storage drivers other than the local filesystem, such as Amazon S3, Google Cloud Storage, or Azure Blob Storage. The choice of storage driver is configured under the storage section inside config.yml.
20.2 Registry Configuration
Running a registry using simple docker run options is sufficient for quick experimentation, but more complete settings like storage drivers, TLS, and authentication need to be written in a config.yml file loaded by the registry at startup.
20.2.1 Structure of config.yml
The registry reads its configuration from a YAML file looked for by default at the path /etc/distribution/config.yml inside the container. Its minimal structure consists of several main sections: version, storage, http, and optionally auth and health.
version: 0.1
log:
fields:
service: registry
storage:
filesystem:
rootdirectory: /var/lib/registry
http:
addr: :5000
headers:
X-Content-Type-Options: [nosniff]
health:
storagedriver:
enabled: true
interval: 10s
threshold: 3Save this configuration in a local file, for example config.yml, and mount it to the path read by the registry when the container is executed.
docker run -d -p 5000:5000 --restart=always --name registry \
-v /mnt/registry-data:/var/lib/registry \
-v "$(pwd)"/config.yml:/etc/distribution/config.yml \
registry:3The health.storagedriver section is important for Sysadmins/DevOps Engineers operating registries in production, as the registry automatically flags itself as unhealthy via the /debug/health endpoint if the storage backend encounters issues, allowing it to be detected earlier by monitoring systems before push/pull requests to the registry begin failing on a massive scale.
20.2.2 Filesystem vs Object Storage
The filesystem driver uses local disk and is suitable for development or small-scale deployments with a single registry instance. For larger production needs, especially when the registry must run across multiple instances for high availability, object storage such as Amazon S3 is more appropriate because all registry instances can share the same data backend without depending on the local disk of any single server.
storage:
s3:
accesskey: AKIAEXAMPLE
secretkey: secretkeyexample
region: ap-southeast-1
bucket: registry-images-internal
encrypt: true
secure: true
rootdirectory: /registryThe accesskey and secretkey credentials above should not be written directly inside the config.yml file that gets committed to Git. The registry supports overriding configuration values via environment variables using the pattern REGISTRY_STORAGE_S3_ACCESSKEY and REGISTRY_STORAGE_S3_SECRETKEY, allowing credentials to be injected at runtime via secrets management in the orchestration platform used, rather than being permanently stored in configuration files.
20.2.3 Enabling TLS
The Docker CLI refuses to communicate with a registry over plain HTTP unless its address is localhost or explicitly registered as an insecure registry. For a registry accessed from other machines over a network, TLS is mandatory. Prepare a TLS certificate (from an internal certificate authority or a service like Let's Encrypt), then instruct the registry to use it via config.yml.
http:
addr: :443
tls:
certificate: /certs/domain.crt
key: /certs/domain.keydocker run -d -p 443:443 --restart=always --name registry \
-v /mnt/registry-data:/var/lib/registry \
-v "$(pwd)"/config.yml:/etc/distribution/config.yml \
-v "$(pwd)"/certs:/certs \
registry:3Once TLS is active, the registry address used for push, pull, and login operations changes to use the domain specified in the certificate, such as registry.internal.company.com, instead of localhost:5000.
20.3 Security and Authentication
A registry without authentication means anyone able to reach its network address can push or pull images, a high-risk scenario if the registry stores internal application images containing proprietary source code. This section covers the insecure registry option for testing purposes, followed by basic authentication using htpasswd for more serious use cases.
20.3.1 Insecure Registry for Testing
While still experimenting on a local network without an official TLS certificate, the Docker Engine can be configured to accept plain HTTP registries via the insecure-registries option in the daemon.json file, usually located at /etc/docker/daemon.json on Linux.
{
"insecure-registries": ["registry.internal.lab:5000"]
}Restart the Docker daemon after modifying daemon.json so that the new configuration is loaded, and repeat the same steps on every machine that needs to access the registry.
sudo systemctl restart dockerThis option causes Docker to bypass certificate validation and allow cleartext HTTP connections to the specified registry, meaning push/pull traffic including login credentials can be intercepted by anyone on the same network path (man-in-the-middle). An attacker who successfully intercepts such traffic can steal registry credentials or inject malicious images along the way. Therefore, insecure-registries should only be used in closed lab networks or fully isolated testing environments, never for registries hosting production images.
20.3.2 Basic Auth with htpasswd
The simplest way to secure registry access is basic authentication using an htpasswd file, a legacy Apache HTTP Server authentication format that stores username and hashed password pairs in a single text file. The registry only supports bcrypt hashing for this method, so the htpasswd file must be created using the bcrypt option explicitly.
mkdir -p auth
docker run --rm --entrypoint htpasswd httpd:2.4 -Bbn admin strongpassword123 > auth/htpasswdThe command above leverages the httpd:2.4 image solely to run its built-in htpasswd utility, without actually running an Apache web server. The -B flag forces bcrypt hashing, -b reads the password directly from command-line arguments, and -n prints the result to stdout instead of writing directly to a file. Add another user by redirecting output to the same file using >> so previous lines are not overwritten.
docker run --rm --entrypoint htpasswd httpd:2.4 -Bbn deploy deploypassword456 >> auth/htpasswdAdd an auth section in config.yml pointing to that htpasswd file.
auth:
htpasswd:
realm: basic-realm
path: /auth/htpasswddocker run -d -p 443:443 --restart=always --name registry \
-v /mnt/registry-data:/var/lib/registry \
-v "$(pwd)"/config.yml:/etc/distribution/config.yml \
-v "$(pwd)"/certs:/certs \
-v "$(pwd)"/auth:/auth \
registry:3Basic auth sends credentials encoded in Base64, which is technically encoding, not encryption, making it trivial to decode for anyone eavesdropping on the traffic. Basic auth must run over a TLS connection so credentials remain encrypted in transit across the network; never enable auth.htpasswd without http.tls also active.
20.3.3 Login and Access Control
Once authentication is active, Developers and Sysadmins/DevOps Engineers wishing to push or pull images from the registry must log in first, just like logging into Docker Hub, except by explicitly specifying the registry address.
docker login registry.internal.company.comThe htpasswd file provides only a single level of access: any user who successfully logs in can push to and pull from any repository in that registry, without granular access control per repository or team. If requirements expand to fine-grained access control, where specific teams should only pull while others can push, Distribution provides a token-based authentication mechanism that delegates the authorization process to a separate authentication server outside the registry. Setting up token-based authentication is significantly more complex than htpasswd because it requires building and operating a custom token server that issues JSON Web Tokens according to defined access policies, so this pattern is typically considered only when organizational access control needs are complex enough to justify the additional overhead.
20.4 Registry API
Distribution implements the Registry HTTP API V2, the official specification defining all interactions between the Docker CLI and a registry over the HTTP protocol. This same API can also be invoked directly, for instance via curl, for automation needs such as auditing registry contents or integrating with internal tooling.
20.4.1 Checking Registry Availability
The base endpoint /v2/ is used by the Docker CLI and other tools to verify whether an address is indeed a registry supporting API V2, while also triggering the authentication flow if required by the registry.
curl -u admin:strongpassword123 https://registry.internal.company.com/v2/A response of status 200 OK with an empty JSON body ({}) indicates that the registry is active and the credentials provided are valid. A status of 401 Unauthorized indicates that credentials are invalid or missing altogether.
20.4.2 Viewing Repository and Tag Lists
The _catalog endpoint returns a list of repository names stored in the registry, useful for Sysadmins/DevOps Engineers needing a quick audit of which images have been pushed without opening additional dashboards.
curl -u admin:strongpassword123 https://registry.internal.company.com/v2/_catalog{"repositories":["my-app","payment-service","worker-queue"]}To view available tags for a specific repository, call the tags/list endpoint specifying the repository name.
curl -u admin:strongpassword123 \
https://registry.internal.company.com/v2/my-app/tags/list{"name":"my-app","tags":["1.0","1.1","1.2.0","latest"]}Both endpoints support pagination via the n (number of items per page) and last (last item from previous page) parameters. These parameters become crucial once the number of repositories or tags reaches the hundreds, as without pagination, a single API call can force the registry to construct and transmit large responses all at once.
The registry also accepts DELETE requests to remove a manifest, the metadata unit linking tags to the underlying image layers. This capability is disabled by default; enable it first in config.yml before DELETE requests can be accepted.
storage:
delete:
enabled: trueDeletion via API can only be performed based on the manifest digest, not the tag name, so the digest must first be retrieved using a HEAD request to the manifest endpoint before issuing the delete command.
curl -sI -u admin:strongpassword123 \
-H "Accept: application/vnd.oci.image.manifest.v1+json" \
https://registry.internal.company.com/v2/my-app/manifests/1.0 | grep -i docker-content-digestcurl -u admin:strongpassword123 -X DELETE \
https://registry.internal.company.com/v2/my-app/manifests/sha256:3a5f2c8e...20.4.3 Garbage Collection
Deleting a tag via the API does not automatically free disk space, because the registry only removes the manifest reference to that tag while the physical layers remain stored as long as they are referenced by other tags or left uncollected. To truly clean up unreferenced layers, execute the garbage collection process via the registry binary inside the container.
docker exec registry registry garbage-collect --dry-run /etc/distribution/config.ymlAlways run it first with the --dry-run flag to inspect which blobs will be deleted without actually removing them, before executing the actual command without the flag. Garbage collection is ideally run while the registry is in a read-only state or receiving no new pushes; running it concurrently with active pushes risks the garbage collector mistakenly assuming an in-flight blob is unused, leading to corrupted images.
docker exec registry registry garbage-collect /etc/distribution/config.ymlIn practice, Sysadmins/DevOps Engineers typically schedule periodic garbage collection via cron jobs during off-peak hours, combined with automated tag retention policies (such as deleting commit hash-based tags older than 90 days) so that the registry storage size does not continuously expand with daily builds.

