Skip to main content

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:

ConsiderationDocker HubPrivate Registry
Pull rate limits100 pulls/6h (anonymous)Unlimited
Network latencyInternet round-tripLAN speed
Data controlImages on Docker's serversImages stay on your network
Air-gapped environmentsNot availableWorks offline
CostFree tier has limitsFree (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 5000
  • registry: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
PartExampleMeaning
registry-host:port192.168.56.12:5000Where to push/pull
repositorycustomerappImage name
tagv1Version 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 images shows the 192.168.56.12:5000/customerapp:v1 tag.


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-registries setting 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/_catalog lists customerapp. 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.crt returns {}. Pushing works without insecure-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 login and docker push succeed 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-data shows 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

  1. The registry's CA certificate — copied to /etc/containerd/certs.d/192.168.56.12:5000/ca.crt so containerd trusts the self-signed cert
  2. Registry credentials — configured in containerd's config or as a Kubernetes Secret (for imagePullSecrets)
  3. 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:

~/customerapp/docker-compose.yml (updated image references)
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 ps shows 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:

EndpointMethodPurpose
/v2/GETHealth check / API version
/v2/_catalogGETList all repositories
/v2/<name>/tags/listGETList tags for an image
/v2/<name>/manifests/<tag>GETGet image manifest
/v2/<name>/manifests/<tag>DELETEDelete 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

CapabilityVerification Command
Private registry running with TLScurl -u admin:registrypass123 https://192.168.56.12:5000/v2/ --cacert /etc/docker/registry/certs/registry.crt
Authentication requiredUnauthenticated curl returns UNAUTHORIZED
customerapp image in registrycurl -u admin:registrypass123 https://192.168.56.12:5000/v2/customerapp/tags/list --cacert /etc/docker/registry/certs/registry.crt
nginx image in registrycurl -u admin:registrypass123 https://192.168.56.12:5000/v2/nginx/tags/list --cacert /etc/docker/registry/certs/registry.crt
Persistent storage volumedocker volume inspect registry-data
Docker logged in to registrycat ~/.docker/config.json — shows auth entry
Compose uses registry imagesdocker 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.