Docker Registry with TLS
Private self-signed registry deployed on Kubernetes with Traefik IngressRoute and basic auth
Manually importing every image onto every cluster node is quickly unworkable. The solution: a private registry hosted inside the cluster, accessible over HTTPS with a self-signed certificate and protected by a password.
Installing Docker
Docker is installed on cube01 to build and push multi-architecture images from the local network.
sudo apt-get install ca-certificates curl gnupg lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/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/debian $(lsb_release -cs) stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin
Daemon configuration (/etc/docker/daemon.json):
{
"exec-opts": ["native.cgroupdriver=systemd"],
"experimental": true,
"log-driver": "json-file",
"storage-driver": "overlay2",
"log-opts": {
"max-size": "100m"
}
}
sudo systemctl enable docker
sudo systemctl start docker
# Multi-architecture support (arm64 + amd64)
sudo apt-get install binfmt-support qemu-user-static
docker buildx create --use --platform=linux/arm64,linux/amd64 --name multi-platform-builder
docker buildx inspect --bootstrap
# Add current user to the docker group
sudo groupadd docker
sudo usermod -aG docker $USER
Initial HTTP Registry (first step)
The registry is first deployed without TLS to validate the basic setup. Kubernetes manifests create a dedicated namespace, a PVC, a Deployment, a Service, and an Ingress.
On each node, add the registry to /etc/hosts:
<IP-cube04> registry docker-registry.local
Then create /etc/rancher/k3s/registries.yaml so containerd knows about the mirror:
mirrors:
docker-registry:
endpoint:
- "http://docker-registry.local:80"
Validation test:
curl http://docker-registry.local:80/v2/_catalog
# Returns the list of repositories
Switching to TLS with a Local CA
.cluster is not a public domain, so Let’s Encrypt cannot issue a certificate. Instead, an internal CA is created and used to self-sign the registry certificate.
Generating the CA and Certificate
mkdir registry-ca && cd registry-ca
# CA key and certificate (valid 10 years)
openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes \
-key ca.key -sha256 -days 3650 \
-out ca.crt -subj "/CN=Cluster Registry CA"
# Key and CSR for registry.cluster
openssl genrsa -out registry.key 4096
openssl req -new -key registry.key -out registry.csr -subj "/CN=registry.cluster"
# SAN extension
echo "subjectAltName = DNS:registry.cluster" > registry.ext
# Sign the certificate
openssl x509 -req \
-in registry.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out registry.crt -days 3650 -sha256 -extfile registry.ext
Trusting the CA on All Machines
On the workstation and on every Raspberry Pi node:
sudo cp ca.crt /usr/local/share/ca-certificates/cluster-ca.crt
sudo update-ca-certificates
# On nodes only:
sudo systemctl restart containerd
Kubernetes Resources
Namespace and TLS secret
kubectl create namespace registry
kubectl create secret tls registry-tls \
--cert=registry.crt --key=registry.key -n registry
Basic auth
sudo apt install apache2-utils
htpasswd -Bc htpasswd admin
kubectl create secret generic registry-auth \
--from-file=htpasswd -n registry
PVC (Longhorn storage)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: registry-pvc
namespace: registry
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 20Gi
storageClassName: longhorn
Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: registry
namespace: registry
spec:
replicas: 1
selector:
matchLabels:
app: registry
template:
metadata:
labels:
app: registry
spec:
containers:
- name: registry
image: registry:2
ports:
- containerPort: 5000
env:
- name: REGISTRY_HTTP_ADDR
value: "0.0.0.0:5000"
- name: REGISTRY_AUTH
value: "htpasswd"
- name: REGISTRY_AUTH_HTPASSWD_REALM
value: "Registry Realm"
- name: REGISTRY_AUTH_HTPASSWD_PATH
value: "/auth/htpasswd"
volumeMounts:
- name: storage
mountPath: /var/lib/registry
- name: auth
mountPath: /auth
volumes:
- name: storage
persistentVolumeClaim:
claimName: registry-pvc
- name: auth
secret:
secretName: registry-auth
Service
apiVersion: v1
kind: Service
metadata:
name: registry
namespace: registry
spec:
selector:
app: registry
ports:
- port: 5000
targetPort: 5000
Traefik IngressRoute (HTTPS)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: registry
namespace: registry
spec:
entryPoints:
- websecure
routes:
- match: Host(`registry.cluster`)
kind: Rule
services:
- name: registry
port: 5000
tls:
secretName: registry-tls
Configuring k3s on All Nodes
Edit /etc/rancher/k3s/registries.yaml on each node:
mirrors:
registry.cluster:
endpoint:
- "https://registry.cluster"
configs:
registry.cluster:
auth:
username: admin
password: <password>
tls:
ca_file: /usr/local/share/ca-certificates/cluster-ca.crt
# On the control plane
sudo systemctl restart k3s
# On workers
sudo systemctl restart k3s-agent
Testing
# Login
docker login registry.cluster
# Tag and push
docker tag nginx registry.cluster/nginx:test
docker push registry.cluster/nginx:test
# Check via the API
curl -u admin:<password> https://registry.cluster/v2/_catalog
A {"errors":[{"code":"UNAUTHORIZED"...}]} response without credentials confirms that TLS is working and the registry is properly protected.
In Kubernetes manifests, images are referenced directly:
image: registry.cluster/myapp:latest
k3s resolves the hostname and pulls the image from the private registry using the configured credentials.