Why Run a Private Container Registry?
A private container registry gives you full control over where your container images are stored, who can access them, and how they are distributed across your infrastructure. Rather than pushing every image to Docker Hub or a cloud provider’s registry, you can host a registry on your own Windows Server 2022 infrastructure — keeping proprietary code and configurations off public or third-party servers, reducing pull latency for on-premises Kubernetes clusters and CI/CD pipelines, and enforcing your own authentication and access policies.
This article covers two approaches: running the lightweight open-source Docker Registry v2 container, and deploying Harbor — a production-grade registry with role-based access control, image scanning, and a web UI. Both run on a Windows Server 2022 host configured with Docker and Linux containers.
Prerequisites: Docker on Windows Server 2022 with Linux Containers
The Docker Registry v2 image and Harbor are Linux container workloads. To run Linux containers on Windows Server 2022, you need Docker Desktop with WSL 2 backend, or a dedicated Linux Docker host reachable from your Windows Server 2022 network. Many organisations run a small Linux VM (Ubuntu 22.04 or Rocky Linux 9) co-located on the same Hyper-V host specifically for the registry workload.
If you are running Docker on a Linux host (the most common configuration for production registries), install Docker Engine on Ubuntu 22.04:
# Install Docker Engine on Ubuntu 22.04
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable --now docker
Running Docker Registry v2 (Lightweight Option)
Docker Distribution (registry:2) is the official open-source registry implementation. It is stateless, fast to deploy, and sufficient for small teams or internal CI/CD pipelines that do not require a web UI or advanced features.
Create a directory for persistent registry data and start the registry container:
mkdir -p /srv/registry/data
mkdir -p /srv/registry/certs
mkdir -p /srv/registry/auth
docker run -d
--name registry
--restart=always
-p 5000:5000
-v /srv/registry/data:/var/lib/registry
-v /srv/registry/certs:/certs
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt
-e REGISTRY_HTTP_TLS_KEY=/certs/domain.key
registry:2
This starts the registry on port 5000 with TLS. The --restart=always flag ensures the registry restarts automatically if the host reboots.
Generating and Configuring TLS for the Registry
Running the registry over plain HTTP (without TLS) requires every Docker client to explicitly mark it as an insecure registry. For production use, TLS is strongly recommended. You can generate a self-signed certificate for testing, or use a certificate signed by your internal CA.
Generate a self-signed certificate valid for 10 years:
openssl req -newkey rsa:4096 -nodes -keyout /srv/registry/certs/domain.key
-x509 -days 3650 -out /srv/registry/certs/domain.crt
-subj "/C=US/ST=State/L=City/O=Organisation/CN=registry.internal.example.com"
-addext "subjectAltName=DNS:registry.internal.example.com,IP:192.168.1.50"
Replace registry.internal.example.com and 192.168.1.50 with your registry’s actual DNS name and IP address. The Subject Alternative Name (SAN) extension is required by modern TLS clients — a certificate without SAN will be rejected by current Docker versions.
If you are using an internal CA, generate a CSR instead and have your CA sign it. Store the signed certificate and key in /srv/registry/certs/ and update the docker run command to reference those files.
Configuring Docker Clients to Trust the Registry
Any Docker host or client that needs to push or pull from your private registry must trust its TLS certificate. The correct approach depends on whether the certificate is self-signed or CA-signed.
Option 1: Trust a self-signed certificate per-registry. Copy the registry’s domain.crt to each Docker client host and install it in Docker’s certificate store:
# On each Linux Docker client
sudo mkdir -p /etc/docker/certs.d/registry.internal.example.com:5000
sudo cp domain.crt /etc/docker/certs.d/registry.internal.example.com:5000/ca.crt
sudo systemctl restart docker
On Windows clients using Docker Desktop, place the certificate in C:ProgramDatadockercerts.dregistry.internal.example.com:5000ca.crt.
Option 2: Add an insecure registry (development only). If you are not using TLS at all (not recommended for production), add the registry address to Docker’s daemon.json on each client:
# /etc/docker/daemon.json
{
"insecure-registries": ["registry.internal.example.com:5000"]
}
sudo systemctl restart docker
After restarting Docker, the daemon will connect to the specified registry without validating its TLS certificate. This is acceptable for fully isolated lab environments but should never be used in production.
Pushing and Pulling Images
With the registry running and clients configured to trust it, you can push images from any Docker host. The workflow is: build or pull an image, tag it with the registry address as a prefix, then push it.
# Pull an image from Docker Hub
docker pull nginx:1.25-alpine
# Retag it for your private registry
docker tag nginx:1.25-alpine registry.internal.example.com:5000/nginx:1.25-alpine
# Push to the private registry
docker push registry.internal.example.com:5000/nginx:1.25-alpine
# Pull from the private registry on another host
docker pull registry.internal.example.com:5000/nginx:1.25-alpine
To list all repositories stored in a registry v2 instance, query the registry API directly:
curl -k https://registry.internal.example.com:5000/v2/_catalog
# Returns: {"repositories":["nginx","myapp/api","myapp/frontend"]}
To list tags for a specific repository:
curl -k https://registry.internal.example.com:5000/v2/nginx/tags/list
# Returns: {"name":"nginx","tags":["1.25-alpine","latest","1.24"]}
Deploying Harbor for Production Use
Harbor is a CNCF-graduated open-source registry that adds a web UI, role-based access control (RBAC), image vulnerability scanning (via Trivy or Clair), content signing, and replication between registries. It is suitable for teams that need governance, audit trails, and centralised image management.
Harbor is deployed using Docker Compose. Download the Harbor offline installer:
cd /opt
wget https://github.com/goharbor/harbor/releases/download/v2.10.2/harbor-offline-installer-v2.10.2.tgz
tar xzvf harbor-offline-installer-v2.10.2.tgz
cd harbor
Copy the configuration template and edit it:
cp harbor.yml.tmpl harbor.yml
nano harbor.yml
Key fields to configure in harbor.yml:
hostname: registry.internal.example.com
# HTTP/HTTPS settings
https:
port: 443
certificate: /srv/registry/certs/domain.crt
private_key: /srv/registry/certs/domain.key
# Change the default admin password
harbor_admin_password: YourSecurePassword123!
# Data storage directory
data_volume: /srv/harbor/data
Run the Harbor installer:
sudo mkdir -p /srv/harbor/data
sudo ./install.sh --with-trivy
The --with-trivy flag enables image vulnerability scanning using the Trivy scanner. After installation completes, access the Harbor web UI at https://registry.internal.example.com and log in with admin and the password you set. From the UI you can create projects, manage users, configure RBAC, and browse pushed images.
Authenticating to Harbor and Pushing Images
Harbor enforces authentication. Before pushing or pulling, Docker clients must log in:
docker login registry.internal.example.com
# Enter Harbor username and password when prompted
Images in Harbor are organised by project. To push an image into a project named myteam:
docker tag myapp:1.0 registry.internal.example.com/myteam/myapp:1.0
docker push registry.internal.example.com/myteam/myapp:1.0
If the project myteam does not exist, create it first in the Harbor UI or via the Harbor API:
curl -k -u admin:YourSecurePassword123!
-X POST https://registry.internal.example.com/api/v2.0/projects
-H "Content-Type: application/json"
-d '{"project_name":"myteam","metadata":{"public":"false"}}'
Registry Garbage Collection
When you delete an image tag from a registry, the underlying layers (blobs) are not immediately removed from disk. They become unreferenced but continue to consume storage. Garbage collection reclaims this space by removing unreferenced blobs.
For the basic Docker Registry v2, run garbage collection by executing the registry binary’s GC command inside the container:
# Stop the registry to prevent writes during GC (required for basic registry:2)
docker stop registry
# Run garbage collection
docker run --rm
-v /srv/registry/data:/var/lib/registry
registry:2 garbage-collect /etc/docker/registry/config.yml
# Restart the registry
docker start registry
In Harbor, garbage collection can be run from the Administration section of the web UI (System Settings > Garbage Collection), or scheduled to run automatically on a cron schedule. Harbor also supports a dry-run mode that shows how much space would be reclaimed without actually deleting anything — useful for capacity planning.
Configuring Windows Container Builds to Push to a Private Registry
When building Windows container images on Windows Server 2022 and pushing to your private registry, the workflow is identical to Linux containers. Ensure the Windows Docker host trusts the registry certificate:
# PowerShell on Windows Server 2022 Docker host
# Import the registry CA certificate to the Windows certificate store
Import-Certificate -FilePath "C:Certsregistry-ca.crt" -CertStoreLocation Cert:LocalMachineRoot
# Also place it in Docker's cert directory
New-Item -ItemType Directory -Force -Path "C:ProgramDatadockercerts.dregistry.internal.example.com:443"
Copy-Item "C:Certsregistry-ca.crt" "C:ProgramDatadockercerts.dregistry.internal.example.com:443ca.crt"
# Restart Docker service
Restart-Service docker
Build and push a Windows container image:
# Build a Windows container image (ensure Dockerfile uses a Windows base image)
docker build -t registry.internal.example.com/myteam/winapp:1.0 C:BuildsWinApp
# Push to private registry
docker login registry.internal.example.com
docker push registry.internal.example.com/myteam/winapp:1.0
Windows container images are typically much larger than their Linux equivalents due to the Windows base image layers (nanoserver is ~100 MB, servercore is ~2.6 GB). Ensure your registry host has sufficient storage and that your network bandwidth supports the larger layer uploads.