How to Deploy a Docker Container to Kubernetes
Docker and Kubernetes do different jobs. Docker builds images and runs containers on one machine. Kubernetes runs those images across a cluster and keeps the requested number of copies alive. You never deploy a Dockerfile or a container to Kubernetes. You deploy an image built from the Dockerfile, and Kubernetes creates the containers.
Since Kubernetes 1.24, nodes no longer use Docker Engine as their runtime. They run containerd or CRI-O. That changes nothing for you: images built with docker build are standard OCI images and run unchanged.
How Docker concepts map to Kubernetes
| Docker | Kubernetes |
|---|---|
docker run my-app | A Pod, created and managed by a Deployment |
--restart always, several copies | Deployment replicas, rescheduling on failure |
-p 8080:80 | A Service (plus Ingress or Gateway for HTTP) |
-e KEY=value, .env file | ConfigMap and Secret |
| Named volume | PersistentVolumeClaim |
compose.yaml | A set of manifests, or a Helm chart |
Step 1: build and push the image
A cluster pulls images from a registry, so the image must be in one the nodes can reach:
docker build -t ghcr.io/example/my-app:1.0.0 .
docker push ghcr.io/example/my-app:1.0.0
Use a version tag, not latest. With latest, Kubernetes always pulls (imagePullPolicy: Always), you cannot tell which build is running, and rollbacks become guesswork. If your laptop is ARM and the nodes are x86, build with --platform linux/amd64.
Step 2: get a cluster
For local testing:
- kind runs a cluster inside Docker containers:
kind create cluster. - minikube:
minikube start. - Docker Desktop can enable a single-node cluster in its settings.
Local clusters can use images you built locally without pushing them, once you load them in:
kind load docker-image ghcr.io/example/my-app:1.0.0
minikube image load ghcr.io/example/my-app:1.0.0
In the cloud, EKS, GKE, AKS and DOKS give you a managed control plane. kubectl talks to all of them the same way. Check which cluster you are pointed at with kubectl config current-context.
Step 3: the quick way
kubectl create deployment my-app --image=ghcr.io/example/my-app:1.0.0 --replicas=2 --port=8080
kubectl expose deployment my-app --port=80 --target-port=8080
kubectl get pods -l app=my-app
kubectl port-forward service/my-app 8080:80
Open http://localhost:8080. This is fine for experiments. For anything you keep, use manifest files in git, so the cluster state is reviewable and reproducible. Generate a starting point instead of typing YAML from memory:
kubectl create deployment my-app --image=ghcr.io/example/my-app:1.0.0 --port=8080 \
--dry-run=client -o yaml > deployment.yaml
Step 4: manifests for real use
deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
labels:
app: my-app
spec:
replicas: 2
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app
image: ghcr.io/example/my-app:1.0.0
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: my-app-config
- secretRef:
name: my-app-secrets
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 256Mi
readinessProbe:
httpGet:
path: /healthz
port: 8080
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: my-app
spec:
selector:
app: my-app
ports:
- port: 80
targetPort: 8080
What each part buys you:
selectorand labels connect the Deployment to its Pods and the Service to the same Pods. A mismatch is the most common reason a Service has no endpoints.requeststell the scheduler how much room a Pod needs. The memory limit stops one Pod from starving the node.- The readiness probe keeps traffic away from a Pod until it answers, which makes rolling updates seamless. The liveness probe restarts a hung container. Keep it lenient, or it will restart containers that are merely slow.
Create the config and apply:
kubectl create configmap my-app-config --from-literal=LOG_LEVEL=info
kubectl create secret generic my-app-secrets --from-literal=DB_PASSWORD=change-me
kubectl apply -f deployment.yaml
kubectl rollout status deployment/my-app
Kubernetes Secrets are only base64-encoded. In production, feed them from a secrets manager (External Secrets Operator, Sealed Secrets or your cloud's CSI driver) instead of committing them.
Step 5: expose it
- ClusterIP (the default above): reachable only inside the cluster. Use
kubectl port-forwardfor testing. - LoadBalancer: on a cloud cluster,
type: LoadBalancerprovisions a cloud load balancer with a public IP. On minikube, runminikube tunnelorminikube service my-app. - Ingress or Gateway API: one entry point with HTTP routing by host and path, plus TLS. Most production HTTP apps use this.
- NodePort: opens a port on every node. Mostly useful for local clusters.
Private registries
If the image is private, give the cluster credentials:
kubectl create secret docker-registry regcred \
--docker-server=ghcr.io \
--docker-username=my-user \
--docker-password="$GHCR_TOKEN"
Then reference it in the Pod spec:
spec:
imagePullSecrets:
- name: regcred
On managed clusters pulling from the same cloud's registry (EKS from ECR, GKE from Artifact Registry), node IAM permissions usually replace this secret.
Updates and rollbacks
Change the image tag in deployment.yaml and apply it again, or do it imperatively:
kubectl set image deployment/my-app my-app=ghcr.io/example/my-app:1.1.0
kubectl rollout status deployment/my-app
kubectl rollout undo deployment/my-app # back to the previous version
kubectl scale deployment/my-app --replicas=4
The Deployment replaces Pods gradually and waits for each new one to pass its readiness probe. A broken image therefore stalls the rollout while the old Pods keep serving traffic.
Coming from Docker Compose
Kompose converts a Compose file into Kubernetes manifests:
kompose convert -f compose.yaml
Treat the output as a draft. Add probes and resource requests, replace bind mounts with PersistentVolumeClaims or ConfigMaps, and drop depends_on. In Kubernetes, the app has to retry until its dependencies are up.
Troubleshooting
kubectl get pods
kubectl describe pod <pod-name> # events at the bottom explain most failures
kubectl logs <pod-name> --previous # logs of the last crashed container
kubectl get endpointslices -l kubernetes.io/service-name=my-app
ImagePullBackOff/ErrImagePull: wrong image name or tag, missing pull secret, or the node cannot reach the registry. With kind or minikube, the image was not loaded, or it is taggedlatestand Kubernetes tries to pull it.CrashLoopBackOff: the container starts and exits. Checkkubectl logs --previous. Usually missing config or a wrong command.Pending: no node has enough CPU or memory for the requests, or a PersistentVolumeClaim cannot bind.- Service returns nothing: labels do not match the selector, or
targetPortis not the port the app listens on.
Quick reference
docker build -t REGISTRY/my-app:TAG . && docker push REGISTRY/my-app:TAG
kind load docker-image REGISTRY/my-app:TAG # local clusters only
kubectl create deployment my-app --image=REGISTRY/my-app:TAG --port=8080 --dry-run=client -o yaml > deployment.yaml
kubectl apply -f deployment.yaml
kubectl expose deployment my-app --port=80 --target-port=8080
kubectl rollout status deployment/my-app
kubectl set image deployment/my-app my-app=REGISTRY/my-app:NEW_TAG
kubectl rollout undo deployment/my-app
