I will walk through spinning up a private Docker registry on your own server, securing it with basic authentication, pushing a local image to it, and pulling that image from another machine. By the end you will have a self-hosted registry that keeps your private images off public registries entirely and costs nothing beyond the server you already run.
Prerequisites
- A Linux server with Docker installed and running.
- Docker installed on your local machine.
- Port 5000 available and open in your firewall on the server.
- A domain name or static IP address pointing to the server.
- The htpasswd utility available locally, which is part of the apache2-utils package on Debian and Ubuntu systems.
- Basic familiarity with the Docker CLI.
Step 1 - Understand how the Docker registry image works
Docker maintains an official image called registry that implements the Docker Registry HTTP API. When you run that container, it listens on port 5000 and accepts push and pull requests from any Docker client that can reach it. By default it stores image layers on the container filesystem, which means data is lost when the container stops. The first thing I always do is mount a host directory into the container so image data persists across restarts.
The registry container is intentionally minimal. It has no web interface and no built-in user management beyond basic authentication via htpasswd files. For a private registry on a trusted internal network or behind a VPN, that simplicity is an advantage. For a public-facing registry, you should pair it with TLS and strong authentication, which I cover in the steps below.
Step 2 - Create the storage and configuration directories
On the server, I create a base directory for the registry that holds the image data, the authentication file, and later the TLS certificates. Keeping everything under one directory makes it easy to back up and migrate.
mkdir -p /opt/registry/data mkdir -p /opt/registry/auth mkdir -p /opt/registry/certs
The data directory is where the registry stores image layers and manifests. The auth directory holds the htpasswd file. The certs directory is for TLS certificates if you choose to enable HTTPS, which I strongly recommend for any server reachable outside your local machine.
Step 3 - Create the authentication file
I use htpasswd to create a credentials file. The registry image uses this file to authenticate push and pull requests with HTTP basic authentication. The bcrypt flag is required because the registry only accepts bcrypt-hashed passwords.
On your local machine or on the server, run the following command. Replace the username and password with your own values.
htpasswd -Bc /opt/registry/auth/htpasswd registryuser
The command prompts for a password and writes the hashed entry to the file. To add a second user, run the same command again without the c flag so the file is appended rather than overwritten.
htpasswd -B /opt/registry/auth/htpasswd seconduser
Note: The c flag creates a new file and overwrites any existing content. Only use it when creating the file for the first time.
Step 4 - Generate or obtain a TLS certificate
Docker clients refuse to push or pull from a registry over plain HTTP unless the registry address is explicitly listed as an insecure registry in the Docker daemon configuration. Using TLS avoids that configuration step on every client machine and protects credentials in transit.
If you have a domain name pointing to the server, use Certbot to obtain a free certificate from Let's Encrypt.
certbot certonly --standalone -d registry.yourdomain.com
After Certbot runs successfully, copy the certificate files to the registry certs directory.
cp /etc/letsencrypt/live/registry.yourdomain.com/fullchain.pem /opt/registry/certs/domain.crt cp /etc/letsencrypt/live/registry.yourdomain.com/privkey.pem /opt/registry/certs/domain.key
If you do not have a domain name and are running the registry on a trusted internal network only, you can generate a self-signed certificate instead. You will then need to add that certificate as a trusted CA on every Docker client that uses the registry.
openssl req -newkey rsa:4096 -nodes -sha256 \ -keyout /opt/registry/certs/domain.key \ -x509 -days 365 \ -out /opt/registry/certs/domain.crt
Gotcha: If you use a self-signed certificate, every Docker client that pushes or pulls from the registry must trust that certificate. On Linux, copy the .crt file to /etc/docker/certs.d/your-server-address:5000/ca.crt and restart Docker.
Step 5 - Start the registry container
Now I start the registry container with the data directory, auth file, and TLS certificates mounted in. I also pass the environment variables that tell the registry where to find the authentication file and the certificate files.
docker run -d \ --name registry \ --restart=always \ -p 5000:5000 \ -v /opt/registry/data:/var/lib/registry \ -v /opt/registry/auth:/auth \ -v /opt/registry/certs:/certs \ -e REGISTRY_AUTH=htpasswd \ -e REGISTRY_AUTH_HTPASSWD_REALM=Registry-Realm \ -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \ -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \ -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \ registry:2
Breaking down the important flags:
- --restart=always - the registry container restarts automatically if the server reboots or Docker restarts.
- -v /opt/registry/data:/var/lib/registry - mounts the host data directory so image layers persist outside the container.
- REGISTRY_AUTH=htpasswd - enables basic authentication using the htpasswd file.
- REGISTRY_HTTP_TLS_CERTIFICATE and REGISTRY_HTTP_TLS_KEY - enable HTTPS on the registry using the certificate and key you placed in the certs directory.
- registry:2 - pins the official registry image to the stable version 2 line.
Step 6 - Log in and push an image from your local machine
With the registry running, I switch to my local machine and log in. Docker will prompt for the username and password you created in Step 3.
docker login registry.yourdomain.com:5000
After a successful login, tag a local image with the registry address and push it.
docker tag nginx:alpine registry.yourdomain.com:5000/my-nginx:1.0docker push registry.yourdomain.com:5000/my-nginx:1.0
The push output shows each layer being uploaded. Once it completes, the image is stored in /opt/registry/data on the server.
Step 7 - Pull the image from a different machine
On any other machine that can reach the server, log in to the registry the same way and pull the image.
docker login registry.yourdomain.com:5000docker pull registry.yourdomain.com:5000/my-nginx:1.0
If the pull succeeds, the private registry is working correctly end to end. The image never touched Docker Hub or any other public registry.
Note: If you want to use the private registry in a GitHub Actions workflow or any other CI system, store the registry address, username, and password as secrets and add a docker login step at the start of the job, exactly as you would for Docker Hub.
Step 8 - Verify stored images through the registry API
The registry exposes a simple HTTP API you can query to see what is stored. From any authenticated machine, use curl to list all repositories in the registry.
curl -u registryuser:yourpassword \ https://registry.yourdomain.com:5000/v2/_catalog
To list the tags available for a specific image, query the tags endpoint.
curl -u registryuser:yourpassword \ https://registry.yourdomain.com:5000/v2/my-nginx/tags/list
These API calls are useful for scripting cleanup tasks, auditing what images are stored, and verifying that a push completed successfully before marking a CI job as done.
Wrap-up
You now have a self-hosted private Docker registry running on your own server, secured with basic authentication and TLS, with image data persisted to a host volume. Any Docker client with credentials can push and pull images without touching a public registry. From here you can automate certificate renewal with Certbot, set up a cron job to prune old image layers using the registry garbage-collect command, or integrate the registry address into your GitHub Actions deploy workflow so CI pushes production images directly to your own infrastructure.