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.