Module 05 — Container Registry
You have a working multi-container stack defined in Docker Compose. But the customerapp:v1 image only exists on app-server — it was built there and stays there. When you set up the Kubernetes cluster in later modules, the worker nodes need to pull that image. They cannot reach a local image on a different machine.
A container registry solves this. It is an HTTP service that stores and serves container images. You push images to it, and any machine with network access can pull them.
In this module you run a private registry on app-server (192.168.56.12), push the customerapp image to it, secure it with TLS and authentication, and prepare it for the Kubernetes worker nodes that will join the network in Module 06.
1. Why a Private Registry
You could push images to Docker Hub, but there are reasons to run your own:
| Consideration | Docker Hub | Private Registry |
|---|---|---|
| Pull rate limits | 100 pulls/6h (anonymous) | Unlimited |
| Network latency | Internet round-trip | LAN speed |
| Data control | Images on Docker's servers | Images stay on your network |
| Air-gapped environments | Not available | Works offline |
| Cost | Free tier has limits | Free (you host it) |
For this training, the private registry runs on the same network as the Kubernetes cluster. Worker nodes can pull images in seconds over the host-only network instead of going out to the internet.
2. Run the Registry
The official registry:2 image is a production-ready registry in a single container.
2.1 Start the registry
ssh app-server
docker run -d \
--name registry \
--restart unless-stopped \
-p 5000:5000 \
registry:2
-p 5000:5000— exposes the registry API on port 5000registry:2— version 2 of the Docker Distribution registry
2.2 Verify it's running
curl http://localhost:5000/v2/
Expected output:
{}
An empty JSON object means the registry is running and the v2 API is available. The /v2/ endpoint is the health check for any Docker registry.
Checkpoint:
curl http://localhost:5000/v2/returns{}.
3. Image Naming and Tagging
Before you can push an image, you need to understand how Docker identifies where to push it. The image name encodes the registry address.
Naming format
registry-host:port/repository:tag
| Part | Example | Meaning |
|---|---|---|
registry-host:port | 192.168.56.12:5000 | Where to push/pull |
repository | customerapp | Image name |
tag | v1 | Version identifier |
When you run docker push customerapp:v1, Docker tries to push to Docker Hub (the default registry). To push to your private registry, the image name must include the registry address.
3.1 Tag the image for the private registry
docker tag customerapp:v1 192.168.56.12:5000/customerapp:v1
This does not copy the image — it creates a second name (tag) pointing to the same image layers.
docker images | grep customerapp
customerapp v1 abc123 ... ~20MB
192.168.56.12:5000/customerapp v1 abc123 ... ~20MB
Same image ID, two names.
Checkpoint:
docker imagesshows the192.168.56.12:5000/customerapp:v1tag.
4. Configure Docker for Insecure Registry
By default, Docker refuses to push to registries that do not use HTTPS. Your registry is running plain HTTP. You have two options: configure Docker to allow insecure access (temporary, for testing) or add TLS (which you will do in Section 7).
For now, configure Docker to trust the registry over HTTP:
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
"insecure-registries": ["192.168.56.12:5000"]
}
EOF
Restart Docker to apply:
sudo systemctl restart docker
Note: The
insecure-registriessetting is acceptable for learning and isolated lab networks. In production, always use TLS.
Restart the registry
Restarting Docker stops all containers. Bring the registry back:
docker start registry
Verify it's running:
curl http://localhost:5000/v2/
5. Push and Pull Images
5.1 Push the image
docker push 192.168.56.12:5000/customerapp:v1
The push refers to repository [192.168.56.12:5000/customerapp]
a1b2c3d4e5f6: Pushed
f6e5d4c3b2a1: Pushed
v1: digest: sha256:... size: 735
5.2 List images in the registry
curl http://192.168.56.12:5000/v2/_catalog
{"repositories":["customerapp"]}
List tags for a specific image:
curl http://192.168.56.12:5000/v2/customerapp/tags/list
{"name":"customerapp","tags":["v1"]}
5.3 Prove the pull works
Remove the local image and pull it back from the registry to prove the registry is the source of truth:
docker rmi 192.168.56.12:5000/customerapp:v1
docker pull 192.168.56.12:5000/customerapp:v1
v1: Pulling from customerapp
...
Status: Downloaded newer image for 192.168.56.12:5000/customerapp:v1
The image was pulled from your private registry, not Docker Hub.
Checkpoint:
curl http://192.168.56.12:5000/v2/_cataloglistscustomerapp. Pulling the image after removing it locally succeeds.
6. Push the Nginx Image
The Kubernetes cluster will also need the Nginx image. Tag and push it:
docker tag nginx:alpine 192.168.56.12:5000/nginx:alpine
docker push 192.168.56.12:5000/nginx:alpine
Verify both images are in the registry:
curl http://192.168.56.12:5000/v2/_catalog
{"repositories":["customerapp","nginx"]}
7. Add TLS with Self-Signed Certificates
HTTP works on a trusted LAN, but the Kubernetes worker nodes (containerd) require HTTPS by default. Setting up TLS now avoids configuration headaches later.
7.1 Create a certificate directory
sudo mkdir -p /etc/docker/registry/certs
7.2 Generate a self-signed certificate
sudo openssl req -newkey rsa:4096 -nodes -sha256 \
-keyout /etc/docker/registry/certs/registry.key \
-x509 -days 365 \
-subj "/CN=192.168.56.12" \
-addext "subjectAltName=IP:192.168.56.12" \
-out /etc/docker/registry/certs/registry.crt
-nodes— no passphrase on the private key-subj "/CN=192.168.56.12"— common name matches the registry's IP-addext "subjectAltName=IP:192.168.56.12"— required for modern TLS clients that validate the Subject Alternative Name
7.3 Restart the registry with TLS
Stop and remove the current registry:
docker stop registry && docker rm registry
Run it with the certificate and key:
docker run -d \
--name registry \
--restart unless-stopped \
-p 5000:5000 \
-v /etc/docker/registry/certs:/certs \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/registry.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/registry.key \
registry:2
7.4 Test HTTPS
curl https://192.168.56.12:5000/v2/ --cacert /etc/docker/registry/certs/registry.crt
{}
Without --cacert, curl rejects the self-signed certificate:
curl https://192.168.56.12:5000/v2/
curl: (60) SSL certificate problem: self-signed certificate
This is expected — the certificate is not trusted by the system yet.
7.5 Trust the certificate on app-server
Docker needs the certificate in a specific location to trust it:
sudo mkdir -p /etc/docker/certs.d/192.168.56.12:5000
sudo cp /etc/docker/registry/certs/registry.crt \
/etc/docker/certs.d/192.168.56.12:5000/ca.crt
Now remove the insecure registry entry since TLS handles trust:
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{}
EOF
sudo systemctl restart docker
docker start registry
Test the push over HTTPS:
docker push 192.168.56.12:5000/customerapp:v1
The push succeeds over TLS without the insecure-registries workaround.
Checkpoint:
curl https://192.168.56.12:5000/v2/ --cacert /etc/docker/registry/certs/registry.crtreturns{}. Pushing works withoutinsecure-registries.
8. Add Basic Authentication
TLS encrypts the connection, but anyone on the network can still push and pull. Add password authentication with htpasswd.
8.1 Create a password file
sudo mkdir -p /etc/docker/registry/auth
docker run --rm --entrypoint htpasswd \
httpd:2 -Bbn admin registrypass123 | \
sudo tee /etc/docker/registry/auth/htpasswd > /dev/null
This generates a bcrypt-hashed password file. The credentials are admin / registrypass123.
8.2 Restart the registry with auth
docker stop registry && docker rm registry
docker run -d \
--name registry \
--restart unless-stopped \
-p 5000:5000 \
-v /etc/docker/registry/certs:/certs \
-v /etc/docker/registry/auth:/auth \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/registry.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/registry.key \
-e REGISTRY_AUTH=htpasswd \
-e REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm" \
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
registry:2
8.3 Test authentication
Without credentials:
curl https://192.168.56.12:5000/v2/ \
--cacert /etc/docker/registry/certs/registry.crt
{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}
With credentials:
curl -u admin:registrypass123 \
https://192.168.56.12:5000/v2/ \
--cacert /etc/docker/registry/certs/registry.crt
{}
8.4 Log in with Docker
docker login 192.168.56.12:5000
Enter admin and registrypass123 when prompted.
Login Succeeded
Docker stores the credentials in ~/.docker/config.json. Subsequent push/pull commands use them automatically.
8.5 Push after login
docker push 192.168.56.12:5000/customerapp:v1
The push succeeds using the stored credentials.
Checkpoint: Unauthenticated requests return
UNAUTHORIZED.docker loginanddocker pushsucceed with credentials.
9. Add a Storage Volume
The registry currently stores images in the container filesystem. If the container is removed, all images are lost — the same problem PostgreSQL had before you added a named volume.
9.1 Recreate with a volume
docker stop registry && docker rm registry
docker run -d \
--name registry \
--restart unless-stopped \
-p 5000:5000 \
-v registry-data:/var/lib/registry \
-v /etc/docker/registry/certs:/certs \
-v /etc/docker/registry/auth:/auth \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/registry.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/registry.key \
-e REGISTRY_AUTH=htpasswd \
-e REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm" \
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
registry:2
The -v registry-data:/var/lib/registry mount ensures images survive container removal.
9.2 Re-push the images
The new volume is empty, so push both images again:
docker push 192.168.56.12:5000/customerapp:v1
docker push 192.168.56.12:5000/nginx:alpine
Verify:
curl -u admin:registrypass123 \
https://192.168.56.12:5000/v2/_catalog \
--cacert /etc/docker/registry/certs/registry.crt
{"repositories":["customerapp","nginx"]}
Checkpoint:
docker volume inspect registry-datashows the volume. Both images are in the catalog.
10. Prepare for Kubernetes
In Modules 06–15 you will build a Kubernetes cluster on 5 new VMs. The worker nodes run containerd (not Docker) and need to pull the customerapp image from this registry. Here is what that will look like — you do not need to do this now, but understanding the plan helps.
What each worker node will need
- The registry's CA certificate — copied to
/etc/containerd/certs.d/192.168.56.12:5000/ca.crtso containerd trusts the self-signed cert - Registry credentials — configured in containerd's config or as a Kubernetes Secret (for
imagePullSecrets) - Image references in Kubernetes manifests — using the full registry path:
# Kubernetes Deployment (preview — you'll write this in Module 14)
spec:
containers:
- name: customerapp
image: 192.168.56.12:5000/customerapp:v1
Why this registry location works
The registry runs on 192.168.56.12 (app-server), which is on the VirtualBox host-only network 192.168.56.0/24. The Kubernetes VMs (192.168.56.20–24) are on the same network and can reach the registry directly.
worker1 (192.168.56.23) ──── host-only network ──── registry (192.168.56.12:5000)
worker2 (192.168.56.24) ──── host-only network ──── registry (192.168.56.12:5000)
No internet required. Pulls happen at LAN speed.
11. Update Docker Compose
Update the Compose file to use registry image references. This makes the Compose setup consistent with how Kubernetes will reference the images.
Edit ~/customerapp/docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
# ... (unchanged — postgres comes from Docker Hub)
app:
image: 192.168.56.12:5000/customerapp:v1
# ... (rest unchanged)
nginx:
image: 192.168.56.12:5000/nginx:alpine
# ... (rest unchanged)
Test the change:
cd ~/customerapp
docker compose down
docker compose up -d
Compose pulls the images from your private registry. Verify:
docker compose ps
All three services should be running.
Checkpoint:
docker compose psshows all services running with registry image references.
12. Registry API Reference
The registry exposes a REST API. These endpoints are useful for scripting and debugging:
| Endpoint | Method | Purpose |
|---|---|---|
/v2/ | GET | Health check / API version |
/v2/_catalog | GET | List all repositories |
/v2/<name>/tags/list | GET | List tags for an image |
/v2/<name>/manifests/<tag> | GET | Get image manifest |
/v2/<name>/manifests/<tag> | DELETE | Delete an image tag |
All endpoints require authentication when auth is enabled. Use -u admin:registrypass123 with curl or docker login for Docker commands.
13. Troubleshooting
Get "https://192.168.56.12:5000/v2/": x509: certificate signed by unknown authority
Docker does not trust the self-signed certificate. Copy it to the Docker certs directory:
sudo mkdir -p /etc/docker/certs.d/192.168.56.12:5000
sudo cp /etc/docker/registry/certs/registry.crt \
/etc/docker/certs.d/192.168.56.12:5000/ca.crt
No Docker restart needed — Docker reads this directory dynamically.
denied: requested access to the resource is denied
You are not logged in. Run docker login 192.168.56.12:5000 with the correct credentials.
connection refused on port 5000
The registry container is not running. Check:
docker ps -a | grep registry
If it exited, check logs: docker logs registry. Common cause: invalid certificate paths in the -e REGISTRY_HTTP_TLS_* variables.
Push hangs or times out
Check that port 5000 is not blocked by a firewall:
sudo iptables -L -n | grep 5000
Also verify the registry is listening:
ss -tlnp | grep 5000
Images disappear after docker rm registry
The registry was running without a storage volume. Recreate it with -v registry-data:/var/lib/registry and re-push the images.
14. What You Have Now
| Capability | Verification Command |
|---|---|
| Private registry running with TLS | curl -u admin:registrypass123 https://192.168.56.12:5000/v2/ --cacert /etc/docker/registry/certs/registry.crt |
| Authentication required | Unauthenticated curl returns UNAUTHORIZED |
| customerapp image in registry | curl -u admin:registrypass123 https://192.168.56.12:5000/v2/customerapp/tags/list --cacert /etc/docker/registry/certs/registry.crt |
| nginx image in registry | curl -u admin:registrypass123 https://192.168.56.12:5000/v2/nginx/tags/list --cacert /etc/docker/registry/certs/registry.crt |
| Persistent storage volume | docker volume inspect registry-data |
| Docker logged in to registry | cat ~/.docker/config.json — shows auth entry |
| Compose uses registry images | docker compose ps — all services running |
Final architecture on app-server
app-server (192.168.56.12)
├── Registry (:5000, HTTPS + auth)
│ ├── customerapp:v1
│ └── nginx:alpine
└── Docker Compose stack
├── postgres (:5432, internal only)
├── customerapp (:8080)
└── nginx (:80)
This completes the Docker section. You have built images, orchestrated them with Compose, and stored them in a private registry ready for the Kubernetes cluster.
Next up: Module 06 — Provision the 5-VM Cluster — create the VirtualBox VMs that will become your Kubernetes control planes and worker nodes.