If you’ve spent any time searching for Docker or Kubernetes help, you’ve probably noticed the questions repeat. Thousands of developers hit the same handful of walls: containers that can’t talk to the host, services that never get an external IP, pods stuck in CrashLoopBackOff, and Deployments that silently ignore a freshly pushed image. This tutorial walks through building a small two-container app locally with Docker, then migrating it to Kubernetes step by step, pausing at each of those classic failure points to explain what’s actually going on.
1. The starting point: a two-container app with Docker Compose
Suppose we have a small API service backed by a database. Here’s a minimal Dockerfile for the API:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
ENTRYPOINT ["node"]
CMD ["server.js"]
Notice ENTRYPOINT and CMD are both present. This split matters more than it looks: ENTRYPOINT defines the fixed command that always runs, while CMD supplies default arguments you can override at runtime, for example docker run myimage server.js –debug. If you only ever use CMD, someone can accidentally replace your whole startup command by passing arguments to docker run. Splitting the two gives you a stable entrypoint with a flexible default.
Now the Compose file that wires the API to a Postgres database:
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:app@db:5432/appdb
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Two details here resolve two of the most-searched Docker questions. First, the API reaches the database at the hostname db, not localhost. Compose creates an internal network where each service is reachable by its service name. Trying to connect to localhost from inside a container is the single most common source of “why can’t my container reach the database” confusion, because localhost inside a container always refers to that container itself, never the host machine or a sibling container. Second, ports: [“3000:3000”] publishes the port to your machine, while EXPOSE 3000 in the Dockerfile only documents that the container listens on it. Exposing alone doesn’t make anything reachable from outside Docker’s network.
Run it with docker compose up –build and you have a working local stack.
2. Translating Compose services into Kubernetes objects
A Kubernetes Deployment is the rough equivalent of a Compose service definition, describing how many replicas of a pod should run and how to roll out changes. Here’s the API as a Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 2
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: yourrepo/api:1.0.0
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
value: postgres://app:app@db:5432/appdb
Pods are ephemeral and get new IPs every time they restart, so nothing should ever talk to a pod directly. That’s what a Service is for, a stable network identity in front of a set of pods:
apiVersion: v1
kind: Service
metadata:
name: api
spec:
selector:
app: api
ports:
- port: 80
targetPort: 3000
type: ClusterIP
This is where the ClusterIP, NodePort, and LoadBalancer question comes in, and it’s genuinely one of the most searched Kubernetes topics. ClusterIP, the default, only makes the service reachable from inside the cluster, which is fine for the database or an internal API. NodePort opens a static port on every cluster node so you can reach the service from outside using any node’s IP with that port, which is mostly useful for local clusters like Minikube or kind. LoadBalancer asks whatever cloud you’re running on to provision an actual external load balancer with a public IP in front of the service. It’s what you’d use in production on a cloud provider, and it’s also why that type sits pending forever on a bare-metal or local cluster: there’s no cloud controller listening to fulfill the request.
3. Giving the database persistent storage
The database needs a PersistentVolumeClaim so its data survives pod restarts:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: db-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
Reference it from the database pod’s spec with a volumes entry pointing at claimName: db-data, then mount it into the container. A claim staying stuck in Pending, with pods reporting they have unbound PersistentVolumeClaims, is almost always one of three things: no StorageClass is configured to dynamically provision a volume, the requested accessModes don’t match anything available, or, on local setups, the cluster simply has no provisioner running at all and needs a manually created PersistentVolume to bind against.
4. Getting a Deployment to actually pick up a new image
This one trips up nearly everyone the first time: you build yourrepo/api:latest, push it, and re-run kubectl apply, but the running pods don’t change. Kubernetes only rolls out a new pod when it detects a change in the Deployment spec, and if the tag is identical, it sees no difference. Two reliable fixes: tag images with something unique per build, such as a git SHA or version number, so the spec genuinely changes, or explicitly force a rollout with:
kubectl set image deployment/api api=yourrepo/api:1.0.1
You can then watch it happen with kubectl rollout status deployment/api, and roll back instantly if the new version misbehaves with kubectl rollout undo deployment/api.
5. Debugging a pod that won’t stay up
CrashLoopBackOff means Kubernetes started your container, it exited, and Kubernetes is now waiting increasingly long intervals before retrying. The frustrating part is that kubectl logs often comes back empty, usually because the container crashed before it wrote anything, or because you’re looking at the current attempt instead of the one that just failed. The fix is to check the previous attempt specifically:
kubectl logs <pod-name> --previous
kubectl describe pod <pod-name>
describe is worth running even when logs look empty, since the Events section at the bottom often shows the real story: an OOM kill, a failed readiness probe, or a missing ConfigMap or Secret the container needed at startup. A container that’s technically healthy but keeps getting killed because a liveness probe is too aggressive is another common variant of this same symptom, so it’s worth checking probe configuration alongside the logs.
Wrapping up
None of these issues are exotic, they’re the everyday friction of moving from a single-machine mental model in Compose to a distributed one in Kubernetes, where networking, storage, and process lifecycle all become explicit instead of implicit. Once you’ve internalized why localhost doesn’t work across containers, what each Service type actually promises, and why image tags need to change for rollouts to trigger, most of the rest of Kubernetes stops feeling like guesswork.