Self-hosted Docker registry in Kubernetes: setup, auth, and cleanup

Published: 2026-03-10

A self-hosted registry gives you full control over image availability and eliminates DockerHub pull-rate limits. We run the official registry:2 image on a bare NodePort — simple, no external dependencies, works in air-gapped setups.


Why self-host

  • Air-gapped environments — production VMs with no internet access pull from the internal registry only
  • Pull rate limits — DockerHub throttles unauthenticated pulls at 100/6h per IP; registry mirrors fix this
  • Latency — pulling 500 MB .NET images from DockerHub across the internet takes minutes; from a local registry it takes seconds
  • Audit trail — you control which images are present; no surprise deletions
  • Cost — no per-seat or per-GB fees

Deploy

yamlapiVersion: 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_STORAGE_FILESYSTEM_ROOTDIRECTORY
              value: /var/lib/registry
            - name: REGISTRY_HTTP_SECRET
              valueFrom:
                secretKeyRef:
                  name: registry-secret
                  key: http-secret
            # Enable deletion API
            - name: REGISTRY_STORAGE_DELETE_ENABLED
              value: "true"
          volumeMounts:
            - name: registry-data
              mountPath: /var/lib/registry
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
      volumes:
        - name: registry-data
          persistentVolumeClaim:
            claimName: registry-pvc
---
apiVersion: v1
kind: Service
metadata:
  name: registry
  namespace: registry
spec:
  type: NodePort
  selector:
    app: registry
  ports:
    - port: 5000
      targetPort: 5000
      nodePort: 30500

NodePort 30500 is accessible from any machine that can reach the node. For CI runners on the same LAN, this is all you need.

REGISTRY_STORAGE_DELETE_ENABLED: "true" enables the manifest delete API — required for garbage collection to work.


Persistent storage

yamlapiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: registry-pvc
  namespace: registry
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 50Gi
  storageClassName: local-path

local-path is the k3s default storage class. For production, consider NFS or object storage (S3-compatible):

yamlenv:
  - name: REGISTRY_STORAGE
    value: s3
  - name: REGISTRY_STORAGE_S3_BUCKET
    value: my-registry-bucket
  - name: REGISTRY_STORAGE_S3_REGION
    value: us-east-1

Configuring Docker to trust the registry

The registry runs over HTTP. Docker rejects HTTP registries by default:

json// /etc/docker/daemon.json (on each Docker host or runner)
{
  "insecure-registries": ["192.168.1.10:30500"]
}

Restart Docker after changing this:

bashsudo systemctl restart docker

For GitLab Runner configured via config.toml:

toml[[runners]]
  [runners.docker]
    allowed_pull_policies = ["always", "if-not-present"]
    volumes = ["/var/run/docker.sock:/var/run/docker.sock"]

# Add to /etc/docker/daemon.json on the runner host

Pushing images

bashdocker tag myapp:latest 192.168.1.10:30500/myapp:latest
docker push 192.168.1.10:30500/myapp:latest

# Verify tags
curl -s http://192.168.1.10:30500/v2/myapp/tags/list | jq .

Kubernetes pods pulling from the registry

yamlcontainers:
  - name: app
    image: 192.168.1.10:30500/myapp:abc1234
    imagePullPolicy: Always

If the node can reach 192.168.1.10:30500 directly without auth, no imagePullSecret is needed.

For auth-protected registries:

bashkubectl create secret docker-registry registry-creds \
  --docker-server=192.168.1.10:30500 \
  --docker-username=robot \
  --docker-password=TOKEN \
  --namespace=app

Reference in the pod spec:

yamlspec:
  imagePullSecrets:
    - name: registry-creds

Garbage collection

The registry API doesn't physically delete blobs when you delete a tag — it only removes the manifest reference. Run GC periodically:

bash# Manual GC (registry stays up, goes read-only briefly)
kubectl exec -n registry deploy/registry -- \
  registry garbage-collect \
    --delete-untagged \
    /etc/docker/registry/config.yml

Automate via CronJob:

yamlapiVersion: batch/v1
kind: CronJob
metadata:
  name: registry-gc
  namespace: registry
spec:
  schedule: "0 3 * * 0"   # weekly at 3 AM Sunday
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: gc
              image: registry:2
              command:
                - registry
                - garbage-collect
                - --delete-untagged
                - /etc/docker/registry/config.yml
              volumeMounts:
                - name: registry-data
                  mountPath: /var/lib/registry
          volumes:
            - name: registry-data
              persistentVolumeClaim:
                claimName: registry-pvc

Deleting old tags via API

bash# Get digest for a tag
DIGEST=$(curl -sI \
  -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
  "http://192.168.1.10:30500/v2/myapp/manifests/old-tag" \
  | grep -i Docker-Content-Digest | awk '{print $2}' | tr -d '\r')

# Delete manifest
curl -X DELETE "http://192.168.1.10:30500/v2/myapp/manifests/${DIGEST}"

Then run GC to actually free the disk space.


Proxying external registries

Use the registry as a pull-through cache for Docker Hub:

yamlenv:
  - name: REGISTRY_PROXY_REMOTEURL
    value: "https://registry-1.docker.io"
  - name: REGISTRY_PROXY_USERNAME
    value: "${DOCKERHUB_USER}"
  - name: REGISTRY_PROXY_PASSWORD
    value: "${DOCKERHUB_TOKEN}"

Configure Docker nodes to pull from 192.168.1.10:30500 — images are cached locally after the first pull. Subsequent pulls are fast and don't count against DockerHub rate limits.