42

Host Your Private Docker Registry on Kubernetes

CKA prep • Run registry:2 in-cluster with PVC persistence, TLS, htpasswd auth, imagePullSecrets

Key terms

TermMeaning
registry:2The official Docker Registry (Distribution) image
PVCPersistentVolumeClaim that keeps pushed images across restarts
htpasswdBasic-auth user file the registry reads for login
TLS Secretkubernetes.io/tls cert + key so pushes use HTTPS
imagePullSecretdocker-registry Secret a pod uses to pull private images
insecure registryA registry served over plain HTTP (must be allow-listed)
/v2/_catalogRegistry API endpoint that lists repositories

Problem & solution

Public registries cost money, rate-limit pulls, and may not be allowed for internal images. Running your own registry inside the cluster gives you a private, fast image store — but a bare registry:2 pod loses every image on restart and accepts anonymous pushes. You need persistence, TLS, and authentication to make it real.

Solution: Deploy registry:2 backed by a PVC, front it with a TLS Secret and an htpasswd auth file, then let pods pull from it via a docker-registry imagePullSecret.

The analogy

At the port, instead of fetching every cargo design from the crowded public dock, you build your own private bonded warehouse that stores your container blueprints under lock and key. A guard at the gate checks papers before anyone may deposit or collect a blueprint, and the warehouse keeps everything on durable storage racks so nothing is lost when the lights go out. A ship may collect a blueprint only if it carries the right warehouse pass. In Kubernetes that warehouse is your private registry, the blueprints are container images, the guard is TLS plus htpasswd auth, the racks are a PVC, and the pass is an imagePullSecret.

Architecture at a glance

Create the auth and TLS secrets

The registry reads a bcrypt htpasswd file for basic auth and a tls Secret for HTTPS.

# 1) htpasswd file (bcrypt) -> a generic Secret
docker run --rm --entrypoint htpasswd httpd:2 -Bbn admin S3cret! > htpasswd
kubectl create secret generic registry-auth --from-file=htpasswd

# 2) a TLS cert for the registry's DNS name (self-signed for a lab)
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout tls.key -out tls.crt \
  -subj "/CN=registry.default.svc" \
  -addext "subjectAltName=DNS:registry.default.svc"
kubectl create secret tls registry-tls --cert=tls.crt --key=tls.key

Persist images with a PVC

A registry that writes to the container filesystem loses every image when the pod restarts. Back it with a PersistentVolumeClaim so pushed images survive:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: registry-data
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 10Gi

Deploy registry:2

This Deployment runs the registry image and wires in the auth, TLS, and storage you just created through env vars and volume mounts:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: registry
  labels: { app: 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_AUTH
              value: htpasswd
            - name: REGISTRY_AUTH_HTPASSWD_REALM
              value: "Registry Realm"
            - name: REGISTRY_AUTH_HTPASSWD_PATH
              value: /auth/htpasswd
            - name: REGISTRY_HTTP_TLS_CERTIFICATE
              value: /certs/tls.crt
            - name: REGISTRY_HTTP_TLS_KEY
              value: /certs/tls.key
          volumeMounts:
            - { name: data, mountPath: /var/lib/registry }
            - { name: auth, mountPath: /auth, readOnly: true }
            - { name: certs, mountPath: /certs, readOnly: true }
      volumes:
        - name: data
          persistentVolumeClaim: { claimName: registry-data }
        - name: auth
          secret: { secretName: registry-auth }
        - name: certs
          secret: { secretName: registry-tls }

Expose it with a Service

Give the registry a stable in-cluster address so clients and pods can reach it by name:

apiVersion: v1
kind: Service
metadata:
  name: registry
spec:
  selector: { app: registry }
  ports:
    - port: 5000
      targetPort: 5000

In-cluster, the registry is reachable at registry.default.svc:5000. For node-level docker/crictl access, expose a NodePort or LoadBalancer instead.

Push and pull images

With the registry running, you log in over TLS, then tag and push an image and read it back from the catalog:

# log in (basic auth over TLS)
docker login registry.default.svc:5000 -u admin -p 'S3cret!'

# tag, push, then list the catalog
docker tag nginx:latest registry.default.svc:5000/team/nginx:1.0
docker push registry.default.svc:5000/team/nginx:1.0
curl -u admin:'S3cret!' https://registry.default.svc:5000/v2/_catalog

docker pull registry.default.svc:5000/team/nginx:1.0

Let pods pull private images

Create a docker-registry Secret and reference it from the pod spec (or attach it to a ServiceAccount so every pod inherits it).

kubectl create secret docker-registry regcred \
  --docker-server=registry.default.svc:5000 \
  --docker-username=admin \
  --docker-password='S3cret!'
apiVersion: v1
kind: Pod
metadata:
  name: web
spec:
  imagePullSecrets:
    - name: regcred
  containers:
    - name: web
      image: registry.default.svc:5000/team/nginx:1.0

End-to-end: an image from push to running pod

End-to-end example: private registry from zero to a running Pod

A complete, runnable walkthrough: create the secrets, deploy a persistent registry over TLS, push a real image, then pull it into a Pod through an imagePullSecret and confirm the container is Running.

  1. Create a namespace and the htpasswd auth secret (bcrypt):
kubectl create namespace registry
docker run --rm --entrypoint htpasswd httpd:2 -Bbn admin 'S3cret!' > htpasswd
kubectl -n registry create secret generic registry-auth --from-file=htpasswd
  1. Mint a self-signed TLS cert whose SAN matches the in-cluster DNS name and load it as a tls Secret:
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout tls.key -out tls.crt \
  -subj "/CN=registry.registry.svc" \
  -addext "subjectAltName=DNS:registry.registry.svc"
kubectl -n registry create secret tls registry-tls --cert=tls.crt --key=tls.key
  1. Apply the PVC, Deployment, and Service in one manifest:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: registry-data
  namespace: registry
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: registry
  namespace: registry
  labels: { app: 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_AUTH, value: htpasswd }
            - { name: REGISTRY_AUTH_HTPASSWD_REALM, value: "Registry Realm" }
            - { name: REGISTRY_AUTH_HTPASSWD_PATH, value: /auth/htpasswd }
            - { name: REGISTRY_HTTP_TLS_CERTIFICATE, value: /certs/tls.crt }
            - { name: REGISTRY_HTTP_TLS_KEY, value: /certs/tls.key }
          volumeMounts:
            - { name: data, mountPath: /var/lib/registry }
            - { name: auth, mountPath: /auth, readOnly: true }
            - { name: certs, mountPath: /certs, readOnly: true }
      volumes:
        - { name: data, persistentVolumeClaim: { claimName: registry-data } }
        - { name: auth, secret: { secretName: registry-auth } }
        - { name: certs, secret: { secretName: registry-tls } }
---
apiVersion: v1
kind: Service
metadata:
  name: registry
  namespace: registry
spec:
  selector: { app: registry }
  ports: [{ port: 5000, targetPort: 5000 }]
  1. Wait for the rollout and confirm the registry answers its API:
kubectl -n registry rollout status deploy/registry
# expected: deployment "registry" successfully rolled out

kubectl -n registry run probe --rm -it --restart=Never --image=curlimages/curl -- \
  curl -sk -u admin:'S3cret!' https://registry.registry.svc:5000/v2/_catalog
# expected: {"repositories":[]}
  1. From a node (or a build host that can reach the Service), log in, tag, and push:
docker login registry.registry.svc:5000 -u admin -p 'S3cret!'
# expected: Login Succeeded

docker tag nginx:1.27 registry.registry.svc:5000/team/nginx:1.0
docker push registry.registry.svc:5000/team/nginx:1.0
# expected: 1.0: digest: sha256:... size: ...
  1. Create the docker-registry pull secret and reference it from a Pod:
kubectl create namespace app
kubectl -n app create secret docker-registry regcred \
  --docker-server=registry.registry.svc:5000 \
  --docker-username=admin \
  --docker-password='S3cret!'
apiVersion: v1
kind: Pod
metadata:
  name: web
  namespace: app
spec:
  imagePullSecrets:
    - name: regcred
  containers:
    - name: web
      image: registry.registry.svc:5000/team/nginx:1.0
  1. Verify the private image was pulled and the container is Running:
kubectl -n app get pod web -o wide
# expected: web   1/1   Running

kubectl -n app describe pod web | grep -iE "pulled|pulling"
# expected: Successfully pulled image "registry.registry.svc:5000/team/nginx:1.0"
  1. Prove persistence: delete the registry pod and re-list the catalog:
kubectl -n registry delete pod -l app=registry
kubectl -n registry rollout status deploy/registry
kubectl -n registry run probe --rm -it --restart=Never --image=curlimages/curl -- \
  curl -sk -u admin:'S3cret!' https://registry.registry.svc:5000/v2/_catalog
# expected: {"repositories":["team/nginx"]}  (survived the restart via the PVC)

Common pitfalls

These are the failures you are most likely to hit, with the usual cause for each:

   - images vanish on restart    -> no PVC; registry wrote to the container fs
   - x509 / cert errors on push  -> SAN must include the name you push to
   - http: server gave HTTPS?    -> client used plain HTTP to a TLS registry
   - ErrImagePull on private img -> missing/wrong imagePullSecret on the pod
   - login works, pull fails     -> kubelet has no creds; use a docker-registry Secret
   - want plain HTTP             -> add the host to containerd insecure registries

Key takeaways

  • registry:2 is the image; persist /var/lib/registry with a PVC or lose images.
  • Secure it with an htpasswd Secret (auth) and a tls Secret (HTTPS).
  • The cert's SAN must match the name clients push/pull with.
  • Pods pull private images via a docker-registry imagePullSecret.
  • Attach the pull Secret to a ServiceAccount to apply it cluster-wide.
  • GET /v2/_catalog lists repositories to confirm a push landed.

Checklist

  • [ ] Created htpasswd and tls Secrets for the registry
  • [ ] Backed the registry with a PVC and confirmed images survive a restart
  • [ ] Deployed registry:2 with auth + TLS env vars and a Service
  • [ ] Pushed an image and listed it via /v2/_catalog
  • [ ] Created a docker-registry Secret and pulled a private image into a pod