Kubernetes Private Registry: Why, Setup, and imagePullSecrets

Learn why Kubernetes needs a container registry, set up public and private registries, pull images directly or with imagePullSecrets, and choose between docker-registry Secrets, auth files, and tokens.

Published

Updated

Read time 12 min read

Reviewed byDeepak Prasad

Kubernetes private registry authentication with imagePullSecrets and imagePullPolicy
Tested on Rocky Linux 10.2 (Red Quartz) workstation
Package podman 5.8.2
kubectl 1.36.3
registry:3 (container image)
Applies to Ubuntu, Debian, Kali Linux, Linux Mint, Pop!_OS, Raspberry Pi OS, elementary OS, Zorin OS, Parrot OS, MX Linux, RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream, Fedora
Kubernetes cluster
Cert prep CKAD · CKS
Lab environment Multi-node kubeadm cluster with containerd — install Kubernetes with kubeadm
Privilege Normal user for Podman and kubectl; sudo on cluster nodes to configure containerd registry trust for HTTP registries
Scope Registry purpose, internet versus internal registry placement, optional registry authentication, direct versus authenticated cluster pulls, imagePullSecret creation methods, ServiceAccount attachment, imagePullPolicy, and first-line pull troubleshooting. Does not cover Harbor, cloud IAM, image signing, or full ImagePullBackOff remediation.
Related guides Kubernetes secrets
Deployments and rolling updates
Kubernetes architecture

Worker nodes pull images from a registry when Pods start. This guide covers registry access, cluster pull credentials, and referencing images from Pod or Deployment manifests.


Quick Reference

Reader path What you will do Section
End-to-end lab Run an internal registry, push an image, configure nodes, deploy with imagePullSecrets Part 1 through Part 3
imagePullSecrets only Registry already exists — create a pull Secret and attach it to a Pod or ServiceAccount Part 3
Pull without a Secret Registry allows anonymous access (internet public repo or internal host without auth) Part 2 — internal anonymous pull
Concepts first Why a registry sits between your build and the cluster; placement vs authentication Why registries and Understand placement and authentication
Troubleshooting ImagePullBackOff, 401 unauthorized, HTTP trust on nodes Troubleshooting

Lab defaults: authenticated registry at 192.168.56.108:5000 (labuser / labpass) and, for comparison, open registry at 192.168.56.108:5001. Substitute your own hostnames and credentials in production.


Why Kubernetes needs a container registry

Three facts explain why a registry sits in the path:

  • A container image is a packaged filesystem and metadata
  • Kubernetes schedules Pods; it does not host image storage
  • The registry is the handoff between build tools on your laptop and pulls on worker nodes

Staged registry publish path: workstation push to registry hub, image reference in Pod manifest, kubelet pull on worker node, then running Pod.

Skipping the push step is the most common beginner mistake. Local Podman or Docker storage is not visible to remote nodes.

For the full build-and-tag workflow, see Build and push container images. This article focuses on registry setup and how the cluster authenticates when it pulls.

Two independent choices are often lumped together as “public vs private”:

Term people use What it usually means
Public registry host On the internet and reachable broadly (registry.k8s.io, docker.io)
Internal registry host On your LAN or VPC only (192.168.56.108:5000, registry.corp.local)
Anonymous pull Registry serves layers without login
Authenticated pull Registry returns 401 until username and password or token are supplied

Kubernetes cares about the second pair when deciding whether imagePullSecrets are required:

  • An internal registry can allow anonymous pulls on a trusted network
  • A registry on the internet can still require credentials for each repository
Registry example Host placement Anonymous pull? imagePullSecrets on Kubernetes
registry.k8s.io/pause Internet Yes No
docker.io/library/nginx (public repo) Internet Yes No
192.168.56.108:5001/web (no auth configured) Internal lab Yes No — but nodes must reach the host and trust HTTP/TLS
192.168.56.108:5000/web (htpasswd enabled) Internal lab No Yes
quay.io/myorg/private-app Internet No Yes

Public registries already run on the internet. You do not install them for normal Kubernetes work — you reference a public image name in a manifest:

text
registry.k8s.io/pause:3.10
docker.io/library/nginx:alpine

For this path you do not need:

  • A registry container on your lab host
  • An imagePullSecret when the repository allows anonymous pulls

The lab sections below run internal registries on your LAN so you can compare anonymous and authenticated pulls on the same host.


Part 1 — Set up and publish to an internal registry

Open registry without authentication (port 5001)

On a trusted lab network, you can run registry:3 without REGISTRY_AUTH:

  • Anyone who can reach the host may push and pull
  • That is convenient for learning
  • It is unsafe on open networks — use authentication when the host is broadly reachable
bash
sudo mkdir -p /opt/lab-registry-open/data
sudo podman run \
  -d \
  --name lab-registry-open \
  -p 5001:5000 \
  -v /opt/lab-registry-open/data:/var/lib/registry:Z docker.io/library/registry:3

Anonymous catalog access should succeed. Registry health checks use the curl command with -w to print only the HTTP status code:

bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5001/v2/_catalog

Sample output:

output
200

200 means no login is required.

Authenticated registry with htpasswd (port 5000)

When the internal registry must enforce access control, enable htpasswd basic authentication. The rest of this article uses port 5000 with credentials.

Install htpasswd if needed. On Rocky or RHEL nodes, install the tools package with dnf command:

bash
sudo dnf install -y httpd-tools

Create storage, auth file, and a lab user:

bash
sudo mkdir -p /opt/lab-registry/{data,auth}
sudo htpasswd -Bbn labuser labpass | sudo tee /opt/lab-registry/auth/htpasswd > /dev/null

Start the registry with Podman:

bash
sudo podman run \
  -d \
  --name lab-registry \
  -p 5000:5000 \
  -v /opt/lab-registry/data:/var/lib/registry:Z \
  -v /opt/lab-registry/auth:/auth:Z \
  -e REGISTRY_AUTH=htpasswd \
  -e REGISTRY_AUTH_HTPASSWD_REALM="Lab Registry" \
  -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd docker.io/library/registry:3

On SELinux-enforcing RHEL-family hosts, append :Z to bind mounts so Podman can relabel host directories for container access.

Confirm the container is running:

bash
sudo podman ps --filter name=lab-registry --format '{{.Names}} {{.Status}}'

Sample output:

output
lab-registry Up 5 seconds

Anonymous catalog access should fail; authenticated access should succeed:

bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5000/v2/_catalog

Sample output:

output
401
bash
curl -s -u labuser:labpass http://127.0.0.1:5000/v2/_catalog

Sample output:

output
{"repositories":[]}

401 without credentials confirms authentication is enabled. The empty list is normal before the first push.

Build and push the test image

Allow insecure pushes to the lab registries from your workstation:

bash
sudo tee /etc/containers/registries.conf.d/999-lab-registry.conf <<'EOF'
[[registry]]
location = "192.168.56.108:5000"
insecure = true

[[registry]]
location = "192.168.56.108:5001"
insecure = true
EOF

Build a small image and publish it to both internal registries:

bash
mkdir kubernetes-private-registry-demo && cd kubernetes-private-registry-demo
html
<!-- index.html -->
<!DOCTYPE html>
<html><body><h1>Private registry demo v1.0.0</h1></body></html>
dockerfile
# Containerfile
FROM docker.io/library/nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
bash
podman build -t localhost/web:v1.0.0 .
podman tag localhost/web:v1.0.0 192.168.56.108:5001/web:v1.0.0
podman push --tls-verify=false 192.168.56.108:5001/web:v1.0.0

Confirm the open-registry tag exists:

bash
curl -s http://192.168.56.108:5001/v2/web/tags/list

Sample output:

output
{"name":"web","tags":["v1.0.0"]}

Push the same image to the authenticated registry on port 5000:

bash
podman tag localhost/web:v1.0.0 192.168.56.108:5000/web:v1.0.0
podman login 192.168.56.108:5000 -u labuser -p labpass --tls-verify=false
podman push 192.168.56.108:5000/web:v1.0.0 --tls-verify=false

Confirm the authenticated-registry tag exists:

bash
curl -s -u labuser:labpass http://192.168.56.108:5000/v2/web/tags/list

Sample output:

output
{"name":"web","tags":["v1.0.0"]}

Configure nodes for HTTP registries

Registry transport on nodes depends on how the host is exposed:

  • Production — use HTTPS with a trusted certificate
  • Lab — plain HTTP on a private network is common
  • containerd default — HTTPS only; each node needs an explicit HTTP endpoint for port 5000, 5001, or any internal registry hostname

On every node that pulls from an internal HTTP registry, ensure /etc/containerd/config.toml exposes the per-host certificate directory. In the containerd 2.x plugin tree, add or confirm:

toml
[plugins."io.containerd.cri.v1.images".registry]
  config_path = "/etc/containerd/certs.d"

Do not duplicate this table if your file already defines config_path. Official containerd documentation places config_path in the main configuration.

Create a per-host directory under /etc/containerd/certs.d/ for each registry endpoint. Example for the authenticated lab registry on port 5000:

Create /etc/containerd/certs.d/192.168.56.108:5000/hosts.toml:

toml
server = "http://192.168.56.108:5000"

[host."http://192.168.56.108:5000"]
  capabilities = ["pull", "resolve", "push"]
  skip_verify = true

Repeat the same pattern for 192.168.56.108:5001/hosts.toml when you run the no-authentication registry on port 5001.

Restart containerd and kubelet. On systemd nodes, use systemctl command to reload both daemons after registry trust changes:

bash
sudo systemctl restart containerd kubelet

Confirm containerd loaded the registry path:

bash
sudo containerd config dump | grep -A2 'registry'

Sample output:

output
[plugins."io.containerd.cri.v1.images".registry]
      config_path = "/etc/containerd/certs.d"

Without the node configuration, pulls fail with http: server gave HTTP response to HTTPS client even when credentials are correct. Part 2 tests cluster pulls against the images you just published.


Part 2 — Test direct and authenticated image pulls

Pull mode When it applies Kubernetes configuration
Direct pull Registry allows anonymous access image: reference only — no imagePullSecrets
Authenticated pull Registry denies anonymous pulls and requires credentials imagePullSecrets or ServiceAccount with pull Secrets

Public image without imagePullSecrets

yaml
apiVersion: v1
kind: Pod
metadata:
  name: public-pull
  namespace: registry-access-demo
spec:
  containers:
    - name: pause
      image: registry.k8s.io/pause:3.10
bash
kubectl create namespace registry-access-demo
kubectl apply -f public-pull.yaml
bash
kubectl get pod public-pull -n registry-access-demo

Sample output:

output
NAME          READY   STATUS    RESTARTS   AGE
public-pull   1/1     Running   0          8s

kubelet pulled registry.k8s.io/pause:3.10 without a pull Secret.

Internal anonymous pull (port 5001)

Deploy a Pod that references the image you pushed to port 5001:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: internal-open-pull
  namespace: registry-access-demo
spec:
  containers:
    - name: web
      image: 192.168.56.108:5001/web:v1.0.0
bash
kubectl apply -f internal-open-pull.yaml
bash
kubectl get pod internal-open-pull -n registry-access-demo

Sample output:

output
NAME                 READY   STATUS    RESTARTS   AGE
internal-open-pull   1/1     Running   0          8s

No imagePullSecrets were required.

Authenticated pull without credentials (expected failure)

Deploy a Pod that references the image on port 5000 without pull credentials:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: private-pull-fail
  namespace: registry-access-demo
spec:
  containers:
    - name: web
      image: 192.168.56.108:5000/web:v1.0.0
bash
kubectl apply -f private-pull-fail.yaml

Sample events from kubectl describe pod private-pull-fail -n registry-access-demo:

output
Warning  Failed  kubelet  Failed to pull image "192.168.56.108:5000/web:v1.0.0": ... authorization failed: no basic auth credentials
Warning  Failed  kubelet  Error: ErrImagePull
Warning  Failed  kubelet  Error: ImagePullBackOff

The registry is reachable, but kubelet has no username or password. Remove the failed Pod before Part 3:

bash
kubectl delete pod private-pull-fail -n registry-access-demo

Part 3 — Create and use imagePullSecrets

imagePullSecrets is a Pod-spec field that lists Secret names. Each pull Secret must satisfy:

  • Typekubernetes.io/dockerconfigjson (recommended) or legacy kubernetes.io/dockercfg
  • Data key.dockerconfigjson, a JSON document in the same shape as Docker or Podman auth files
  • Scope — namespace-scoped; kubelet reads it when pulling for Pods in that namespace
text
Pod spec.imagePullSecrets  →  Secret name (namespace-scoped)
Secret type kubernetes.io/dockerconfigjson
Key .dockerconfigjson holds registry host → auth entry
kubelet passes credentials to containerd during pull

Three common ways to create that Secret:

Method Command or source Best for
Typed docker-registry Secret kubectl create secret docker-registry Username + password or access token
Import auth file kubectl create secret generic --from-file=.dockerconfigjson=… After podman login or docker login on the workstation
Manual manifest kind: Secret with type: kubernetes.io/dockerconfigjson GitOps, operators, automation

All methods produce the same Secret type. Pick based on where credentials already exist.

Option 1 — docker-registry Secret

kubectl create secret docker-registry builds the .dockerconfigjson content for you. Use it when you know the registry hostname, username, and secret string.

For the lab registry:

bash
kubectl create secret docker-registry regcred \
  --namespace=registry-access-demo \
  --docker-server=192.168.56.108:5000 \
  --docker-username=labuser \
  --docker-password='labpass'

Sample output:

output
secret/regcred created

Confirm the type:

bash
kubectl get secret regcred -n registry-access-demo

Sample output:

output
NAME      TYPE                             DATA   AGE
regcred   kubernetes.io/dockerconfigjson   1      1s

The --docker-password flag accepts passwords and tokens. The name is historical from Docker Hub:

Registry --docker-username --docker-password value
Lab registry:3 + htpasswd labuser Password from htpasswd (labpass in this lab)
Docker Hub Account name Personal access token (preferred) or account password
Quay.io Robot account name Robot token
GitHub Container Registry GitHub username PAT with read:packages

Cloud docs may label tokens ACCESS_TOKEN or REGISTRY_TOKEN. Kubernetes does not read those variable names — paste the token string into --docker-password.

Option 2 — import Podman or Docker auth file

If you already ran podman login, copy the auth file into a Secret instead of retyping credentials:

bash
kubectl create secret generic regcred-from-file \
  --namespace=registry-access-demo \
  --from-file=.dockerconfigjson="${XDG_RUNTIME_DIR}/containers/auth.json" \
  --type=kubernetes.io/dockerconfigjson

Docker default path:

bash
kubectl create secret generic regcred-from-file \
  --namespace=registry-access-demo \
  --from-file=.dockerconfigjson="$HOME/.docker/config.json" \
  --type=kubernetes.io/dockerconfigjson

The file must contain an auths entry for the registry hostname in your image: field. It cannot rely on:

  • credsStore alone
  • credHelpers alone

If either is present without a direct auths entry, kubelet cannot use the file. Create the Secret with Option 1 instead.

Option 1 and Option 2 create interchangeable Secrets. Use one pull Secret name in the Pod (regcred or regcred-from-file).

Option 3 — manual Secret manifest

For GitOps pipelines, embed base64-encoded .dockerconfigjson in a manifest:

yaml
apiVersion: v1
kind: Secret
metadata:
  name: regcred-manual
  namespace: registry-access-demo
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: <base64-encoded-docker-config>

Generate the JSON with kubectl create secret docker-registry --dry-run=client -o yaml and copy the data field, or encode an auth file with base64 -w0. Treat the manifest like a password file.

Attach to a Deployment

Reference the Secret from the Pod template:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: private-web
  namespace: registry-access-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: private-web
  template:
    metadata:
      labels:
        app: private-web
    spec:
      imagePullSecrets:
        - name: regcred
      containers:
        - name: web
          image: 192.168.56.108:5000/web:v1.0.0
          imagePullPolicy: IfNotPresent

Apply and verify:

bash
kubectl apply -f private-web.yaml

Sample output:

output
deployment.apps/private-web created
bash
kubectl rollout status deployment/private-web -n registry-access-demo --timeout=90s

Sample output:

output
deployment "private-web" successfully rolled out
bash
kubectl get pods -n registry-access-demo -l app=private-web

Sample output:

output
NAME                           READY   STATUS    RESTARTS   AGE
private-web-5875c679f6-2mn56   1/1     Running   0          15s
private-web-5875c679f6-8p7r2   1/1     Running   0          15s

Both Pods pulled the private image after imagePullSecrets supplied credentials.

Attach to a ServiceAccount

Repeating imagePullSecrets in every Deployment is tedious. Attach the registry Secret to a ServiceAccount instead:

yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: private-registry
  namespace: registry-access-demo
imagePullSecrets:
  - name: regcred
bash
kubectl apply -f private-registry-sa.yaml

Reference the ServiceAccount from the Pod template and remove duplicate imagePullSecrets unless you intentionally override them:

yaml
spec:
  template:
    spec:
      serviceAccountName: private-registry

Kubernetes copies ServiceAccount imagePullSecrets into new Pods that do not already define their own list. See Kubernetes ServiceAccounts for token and automount details.

A Pod can list multiple pull Secrets when it uses images from more than one private registry:

yaml
imagePullSecrets:
  - name: quay-regcred
  - name: internal-regcred

imagePullPolicy, tags, and digests

imagePullPolicy tells kubelet whether to contact the registry when a container starts:

Policy Behaviour
Always Resolve through the registry on every start
IfNotPresent Skip the registry when the node already has the reference cached
Never Never pull; the image must already exist locally

Omitted fields default from the image reference:

  • :latest or no tagAlways
  • Any other explicit tagIfNotPresent
  • Digest referenceIfNotPresent

Kubernetes sets this default when the object is first created. Changing the image reference later does not automatically change an existing imagePullPolicy field.

After the private-web Pods were running, the node recorded this digest:

text
192.168.56.108:5000/web@sha256:79ab0838785cba5ae5a2ac5ca38397c50ab66f3f2f2dce9d2b88648a62a8faa0

Pin by digest when you need immutable rollouts:

yaml
image: 192.168.56.108:5000/web@sha256:79ab0838785cba5ae5a2ac5ca38397c50ab66f3f2f2dce9d2b88648a62a8faa0

Troubleshoot pull failures

Symptom Likely cause First check
authorization failed: no basic auth credentials Authenticated registry but missing imagePullSecrets Secret referenced in Pod namespace
unauthorized or 401 Wrong password or expired token --docker-password value
http: server gave HTTP response to HTTPS client HTTP registry without containerd hosts.toml Node registry config
pull access denied Account lacks repository permission Registry user or robot scope
manifest unknown Tag does not exist Registry tags list
Old image still runs Mutable tag with IfNotPresent cache Digest pinning

Read Pod events first:

bash
kubectl describe pod <pod-name> -n registry-access-demo
bash
kubectl get events -n registry-access-demo --sort-by=.lastTimestamp

What's Next


References


Summary

Separate three concerns when you design registry access:

  • Where images are stored — internet host versus internal host
  • Whether authentication is enforced — anonymous pull versus credentials required
  • How credentials are packagedkubectl create secret docker-registry, auth-file import, or manual Secret

Direct pulls work for any registry that allows anonymous access, including an internal HTTP registry on a trusted network. When anonymous pulls are denied and the registry requires credentials, create a kubernetes.io/dockerconfigjson Secret in the Pod namespace and reference it through imagePullSecrets or a ServiceAccount.


Frequently Asked Questions

1. Why does Kubernetes need a container image registry?

Worker nodes do not receive images from your laptop. They pull OCI images from a registry hostname when Pods start. Without a registry in the path, a custom image you built locally is invisible to the cluster.

2. When can Kubernetes pull an image without imagePullSecrets?

When the registry allows anonymous pulls: public repositories such as registry.k8s.io/pause, or an internal registry on your LAN that was started without authentication. imagePullSecrets are required when anonymous pulls are denied and the registry requires credentials.

3. Does podman login on my workstation authenticate Kubernetes nodes?

No. Workstation login only updates local Podman or Docker auth files. Cluster nodes need a kubernetes.io/dockerconfigjson Secret in the Pod namespace, or you must copy credentials into that Secret from the auth file.

4. What should I put in --docker-password for kubectl create secret docker-registry?

Use the htpasswd password for a lab registry:3 setup. For Docker Hub, Quay, or GitHub Container Registry, paste a personal access token or robot token with pull permission. The flag is named docker-password, but tokens are valid values.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)