K8s Homelab: Deploying Container Image Registries
Table of Contents
This is the sixth post in our “K8s Homelab” series. Check out the previous post to see how we deployed the observability stack.
The Need for Container Registries
With the cluster infrastructure in place—MetalLB, Traefik, Longhorn, and the LGTM observability stack—I had a solid, production-ready Kubernetes environment. But I needed a way to store custom container images and cache public images to avoid rate limiting and speed up pulls.
I needed:
- A main registry for storing custom-built images (e.g., custom applications, modified base images)
- Mirror registries for caching upstream sources (Docker Hub, GHCR) to avoid rate limits and improve pull speeds
- TLS-secured access via Traefik ingress
- Persistent storage using Longhorn
- Easy management via Ansible automation
Enter Docker Registry v2, a simple and reliable container registry that can serve both as a storage backend and as a proxy/mirror for upstream registries.
What are Container Registries?
Container registries are storage and distribution systems for container images. In this setup, I deploy two types:
Main Registry: Stores custom-built images locally
- Acts as a private registry for images built in the homelab
- Uses persistent storage with
Retainpolicy (data should persist) - Accessible at
registry.lab.x.y.z
Mirror Registries: Cache upstream registries
- Proxy requests to upstream registries (Docker Hub, GHCR)
- Cache images locally to speed up subsequent pulls
- Reduce rate limiting issues with public registries
- Uses persistent storage with
Deletepolicy (cache can be evicted)
Both types use Docker Registry v2 and are exposed via Traefik with TLS termination.
Architecture: Registries in the Cluster
graph TB
subgraph "Cluster"
MainReg[Main Registry<br/>registry.lab.x.y.z]
DockerMirror[Docker Hub Mirror<br/>docker-io.lab.x.y.z]
GHCRMirror[GHCR Mirror<br/>ghcr-io.lab.x.y.z]
Longhorn[Longhorn Storage]
Traefik[Traefik Ingress]
end
subgraph "External"
DockerHub[Docker Hub<br/>docker.io]
GHCR[GitHub Container Registry<br/>ghcr.io]
end
MainReg -->|Stores| Longhorn
DockerMirror -->|Caches| DockerHub
GHCRMirror -->|Caches| GHCR
DockerMirror -->|Stores| Longhorn
GHCRMirror -->|Stores| Longhorn
MainReg -->|Exposes via| Traefik
DockerMirror -->|Exposes via| Traefik
GHCRMirror -->|Exposes via| Traefik
Main Registry stores custom images with persistent Longhorn storage.
Mirror Registries proxy and cache images from upstream registries, reducing external traffic and rate limiting.
Traefik handles TLS termination and routing to the appropriate registry service.
Longhorn provides persistent storage for both registry types.
Implementation: Registries Ansible Role
Following the established pattern from other roles (CA, LGTM, etc.), I created an Ansible role for registries:
cluster/roles/registries/
├── defaults/main.yaml # Default variables
├── tasks/
│ ├── install.yaml # Deploy registries and mirrors
│ ├── configure.yaml # Configuration tasks (currently empty)
│ └── uninstall.yaml # Cleanup tasks
└── templates/
├── container-image-registry-deployment.yaml.j2
├── container-image-registry-service.yaml.j2
├── container-image-registry-ingress.yaml.j2
├── container-image-registry-pvc.yaml.j2
├── container-image-mirror-deployment.yaml.j2
├── container-image-mirror-service.yaml.j2
├── container-image-mirror-ingress.yaml.j2
├── container-image-mirror-pvc.yaml.j2
└── container-image-mirror-config.yaml.j2
Key Variables
registries_namespace: registry-system
# Main Registry
container_image_registry_name: registry
container_image_registry_image: registry:2
container_image_registry_port: 5000
container_image_registry_storage_class: lg-hdd-raw-x3
container_image_registry_storage_size: 2Gi
container_image_registry_ingress_host: registry.lab.x.y.z
# Mirrors (configured via inventory)
container_image_mirrors:
- url: https://registry-1.docker.io
registry: docker.io
size: 2Gi
- url: https://ghcr.io
registry: ghcr.io
size: 2Gi
Main Registry Configuration
The main registry is a standard Docker Registry v2 deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: registry
namespace: registry-system
spec:
replicas: 1
template:
spec:
containers:
- name: registry
image: registry:2
ports:
- containerPort: 5000
env:
- name: REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY
value: /var/lib/registry
volumeMounts:
- name: registry-storage
mountPath: /var/lib/registry
volumes:
- name: registry-storage
persistentVolumeClaim:
claimName: registry-storage
The registry uses:
- Persistent storage: 2Gi Longhorn volume with
Retainpolicy - TLS certificates: Generated using existing CA role
- Resource limits: 100m CPU / 256Mi memory (requests), 500m CPU / 1Gi memory (limits)
- Health checks: Liveness and readiness probes on port 5000
Mirror Registry Configuration
Mirror registries use Docker Registry v2 with proxy configuration:
apiVersion: v1
kind: ConfigMap
metadata:
name: docker-io-config
namespace: registry-system
data:
config.yml: |
version: 0.1
storage:
filesystem:
rootdirectory: /var/lib/registry
http:
addr: :5000
proxy:
remoteurl: https://registry-1.docker.io
ttl: 168h
The mirror:
- Proxies requests to the upstream registry
- Caches images locally for 168 hours (7 days)
- Uses persistent storage with
Deletepolicy (cache can be evicted)
TLS Certificates
All registries use TLS certificates generated by the CA role. The certificates are loaded as Kubernetes secrets:
apiVersion: v1
kind: Secret
metadata:
name: registry-tls
namespace: registry-system
type: kubernetes.io/tls
data:
tls.crt: <certificate chain>
tls.key: <private key>
TLS is terminated at Traefik, so the registries accept HTTP connections from Traefik. This simplifies configuration while maintaining end-to-end encryption for clients.
Traefik Ingress Configuration
Each registry is exposed via Traefik ingress with TLS:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: registry
namespace: registry-system
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
tls:
- hosts:
- registry.lab.x.y.z
secretName: registry-tls
rules:
- host: registry.lab.x.y.z
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: registry
port:
number: 5000
The ingress:
- Uses the
websecureentrypoint (HTTPS only) - Terminates TLS using the registry certificate
- Routes traffic to the registry service on port 5000
Using the Registries
Pushing to the Main Registry
To push a custom image to the main registry:
# Tag the image
docker tag my-app:latest registry.lab.x.y.z/my-app:latest
# Push to registry
docker push registry.lab.x.y.z/my-app:latest
Pulling from Mirrors
To use a mirror, configure your container runtime to use the mirror URL:
# In containerd config.toml or Docker daemon.json
[plugins."io.containerd.grpc.v1.cri".registry.mirrors."docker.io"]
endpoint = ["https://docker-io.lab.x.y.z"]
[plugins."io.containerd.grpc.v1.cri".registry.mirrors."ghcr.io"]
endpoint = ["https://ghcr-io.lab.x.y.z"]
Or reference the mirror directly in Kubernetes manifests:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
template:
spec:
containers:
- name: my-app
image: docker-io.lab.x.y.z/library/nginx:latest
Challenges and Solutions
Challenge 1: TLS Certificate Management
Problem: Each registry needs a TLS certificate for secure access.
Solution: Reused the existing CA role to generate certificates for each registry. The certificates are loaded as Kubernetes secrets and referenced in the ingress configuration.
Challenge 2: Registry Storage Policies
Problem: Main registry data should persist, but mirror cache can be evicted.
Solution: Used different StorageClasses:
- Main registry:
lg-hdd-raw-x3(data persists) - Mirrors:
lg-hdd-raw-x3-delete(cache can be evicted)
Challenge 3: Mirror Configuration
Problem: Docker Registry v2 proxy mode requires specific configuration.
Solution: Created ConfigMaps with the proxy configuration for each mirror, specifying the upstream URL and cache TTL.
Challenge 4: TLS Termination
Problem: Registries need TLS for security, but Traefik should handle termination.
Solution: Configured Traefik to terminate TLS and forward HTTP to registries. Registries accept HTTP from Traefik, simplifying their configuration.
Challenge 5: Inventory Configuration
Problem: Need flexible configuration for multiple mirrors.
Solution: Used Ansible inventory variables to define mirrors dynamically:
container_image_mirrors:
- url: https://registry-1.docker.io
registry: docker.io
size: 2Gi
- url: https://ghcr.io
registry: ghcr.io
size: 2Gi
The role loops through these mirrors and creates deployments, services, and ingresses for each.
Testing: Verification Steps
To verify the registries are working:
Check registry pods:
kubectl get pods -n registry-systemTest main registry push:
docker pull nginx:latest docker tag nginx:latest registry.lab.x.y.z/nginx:latest docker push registry.lab.x.y.z/nginx:latestTest mirror pull:
docker pull docker-io.lab.x.y.z/library/nginx:latestVerify TLS:
curl -k https://registry.lab.x.y.z/v2/
All tests should pass, confirming the registries are accessible and functioning correctly.
Lessons Learned
- Separate storage policies: Main registry needs persistent storage, mirrors can use evictable cache
- TLS termination at Traefik: Simplifies registry configuration while maintaining security
- Mirror configuration is straightforward: Docker Registry v2 proxy mode works well for caching
- Inventory-driven mirrors: Flexible configuration allows easy addition of new mirrors
- Health checks are essential: Liveness and readiness probes ensure registry availability
What’s Next?
With container registries in place, I now have:
- ✅ Main registry for custom images
- ✅ Mirror registries for upstream caching
- ✅ TLS-secured access via Traefik
- ✅ Persistent storage with appropriate policies
- ✅ Easy management via Ansible automation
Future enhancements could include:
- Registry authentication: Add authentication for the main registry
- Image scanning: Integrate vulnerability scanning for stored images
- Registry UI: Add a web interface for browsing images
- Backup strategy: Automated backups of registry data
- Additional mirrors: Add more upstream registries as needed
The registries provide a solid foundation for storing and caching container images, reducing external dependencies and improving deployment speeds.
comments powered by Disqus