Docker Compose is where many of us first run multi-container applications on one machine. You declare services, images, ports, environment variables, volumes, and sometimes dependencies. Compose builds a small private network and starts containers in a sensible order. For local development and small deployments, that is often enough.
Kubernetes asks for a different shape, but the mental work is familiar. You are still describing applications, how they talk to each other, and what configuration they need. The nouns change. The habit of thinking in services and dependencies does not.
This post is for the stage where Compose already works and Kubernetes feels like a bigger version of the same idea — with more YAML and more questions.
What Compose gives you for free
A typical docker-compose.yml bundles several concerns:
- Services — named workloads with an image and command.
- Ports — publish host ports or expose container ports.
- Environment — inline env vars or
.envfiles. - Volumes — bind mounts, named volumes, or tmpfs.
- Networks — default bridge or custom networks; service DNS names match service names.
- Depends_on — start order hints (not health-aware in older Compose versions).
Compose runs on one Docker host (or a small Swarm cluster). Everything shares that host’s kernel and network namespace model. Kubernetes spreads work across a cluster: schedulers, nodes, API objects, controllers, and label-based service discovery.
The goal of migration is not to copy every Compose feature into Kubernetes on day one. The goal is to preserve what mattered in Compose — runnable apps, reachable ports, config, storage — while accepting what Kubernetes does differently.
The default mapping: service → Deployment + Service
In Kubernetes, a long-running application is usually a Deployment (desired replicas, rollout strategy, Pod template) backed by Pods (the actual containers). Other workloads reach it through a Service (stable cluster IP and DNS name, load balancing across Pod endpoints).
A rough translation table:
| Compose concept | Kubernetes starting point |
|---|---|
services.web | Deployment web + Service web |
image: | containers[].image in the Pod template |
ports: | containerPort + Service ports |
environment: | env vars, or ConfigMap / Secret refs |
volumes: | volumes in Pod spec + PVC / ConfigMap / Secret |
depends_on: | init containers, probes, or app-level retry (no exact clone) |
| service name as hostname | http://web via Service DNS in the same namespace |
Compose service name api becomes DNS name api inside the cluster when you create a Service named api. That similarity helps beginners. The difference is that Kubernetes DNS is cluster-wide per namespace, not a Docker bridge network you defined in one file.
Here is a minimal Compose service:
services:
web:
image: ghcr.io/example/shop-web:1.2.0
ports:
- "8080:8080"
environment:
API_URL: http://api:3000
LOG_LEVEL: info
depends_on:
- api
A minimal Kubernetes equivalent splits workload and network:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 1
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: ghcr.io/example/shop-web:1.2.0
ports:
- containerPort: 8080
env:
- name: API_URL
value: "http://api:3000"
- name: LOG_LEVEL
value: info
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector:
app: web
ports:
- port: 8080
targetPort: 8080
Notice what moved. Compose folded “run this container” and “expose this port” into one service block. Kubernetes separates how many Pods run (Deployment) from how traffic finds them (Service). Labels (app: web) are the wire between them. If the Service selector does not match Pod labels, you get a running app with no endpoints — a classic first-week bug.
Networks: same idea, different implementation
In Compose, services on the same network resolve each other by name. In Kubernetes, Pods on a cluster network do the same via ClusterIP Services (default type). http://api in namespace shop resolves to the Service fronting Pods labeled app: api.
What Compose does not prepare you for:
- Namespaces partition objects and DNS.
api.shop.svc.cluster.localis explicit; short names work inside the same namespace. - NetworkPolicies can restrict traffic between Pods. Compose has no equivalent unless you add firewall rules yourself.
- Ingress (or Gateway API) usually replaces
ports: "80:8080"for HTTP from outside the cluster. Compose publishes to the host; Kubernetes often routes through a controller and TLS termination layer. - Host networking exists in Kubernetes but is exceptional. Most apps use normal Pod networking.
For a first migration, stay boring: one namespace, ClusterIP Services, and only add Ingress when you need external HTTP. Trying to reproduce every Compose port publish rule on day one adds noise.
Volumes: named volumes do not transfer literally
Compose volumes are convenient because Docker manages storage on that host. Kubernetes is node-oriented but cluster-scoped: a Pod may reschedule to another node, so local bind mounts behave differently.
Common mappings:
| Compose | Kubernetes |
|---|---|
| Named volume | PersistentVolumeClaim + StorageClass |
Bind mount ./data | hostPath (dev only), or sync data another way |
| Config file mount | ConfigMap or Secret volume |
| Anonymous volume | emptyDir (ephemeral) |
A database in Compose often used a named volume on your laptop. In Kubernetes, you want a PersistentVolumeClaim with a StorageClass that matches your cluster (cloud disk, local path provisioner in kind, etc.). ReadWriteOnce volumes attach to one node at a time; that affects scheduling and failover — something Compose rarely forced you to think about.
For stateless apps, prefer no persistent volume at first. For Postgres or similar, plan storage before you migrate production data.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: db-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
Then reference the claim in the Deployment’s Pod template under volumes and volumeMounts. The exact size and StorageClass depend on the cluster; the pattern is what matters.
Environment variables and secrets
Compose environment: and env_file: map cleanly to Kubernetes env vars — until you need rotation, auditing, or different values per environment.
Practices I recommend early:
- Non-sensitive config → ConfigMap, referenced as env vars or files.
- Passwords and tokens → Secret (with realistic expectations: RBAC and encryption at rest matter more than the word “Secret”).
- Do not bake environment-specific URLs into images; keep the same image tag across dev and prod with different ConfigMaps.
env:
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: shop-config
key: LOG_LEVEL
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: shop-secret
key: DATABASE_PASSWORD
Compose let you restart a service and pick up a changed .env depending on how you ran it. Kubernetes Pods do not automatically reload env vars from a changed ConfigMap unless you restart Pods or use mounted files with reload support in the app. Plan for rollout when config changes.
depends_on is not a Kubernetes feature
Compose depends_on suggests startup order. It does not guarantee the dependency is ready — only that the container started in many setups. Kubernetes has no direct depends_on field.
Options:
- Init containers run to completion before app containers start (migrations, wait-for scripts).
- Readiness probes prevent traffic to a Pod until it can serve.
- Application retries when calling dependencies (still good practice).
For a database dependency, the app should tolerate brief connection failures during rollout. Relying only on start order creates fragile deploys in both Compose and Kubernetes.
kompose and other converters (optional)
kompose can translate a Compose file into Kubernetes manifests. That is useful as a sketch, not as finished production config. Generated YAML often uses one replica, bare Deployments, and hostPath volumes that will not survive a real cluster review.
If you use kompose:
- Read every generated file.
- Add resource requests and limits.
- Replace hostPath with PVCs where data must survive.
- Add probes, labels consistently, and Ingress only where needed.
- Split secrets out of plain env in generated files.
Treat kompose output like a first draft from a colleague who has not seen your production standards.
What gets harder (honestly)
Some things that felt easy in Compose become operational work in Kubernetes:
- Local iteration —
docker compose upon one machine is fast. Kubernetes loops need a cluster (kind, minikube, cloud), image push to a registry, andkubectl apply. - Storage and backups — PVCs, snapshots, and restore drills are your responsibility.
- Observability — logs are per-container; you aggregate with
kubectl logs, Loki, or a platform stack. - Configuration drift — many objects; GitOps or disciplined apply workflows help.
- Debugging — more layers (Deployment → ReplicaSet → Pod → container). The upside is consistent status and Events if you learn to read them.
None of that means Kubernetes is the wrong choice. It means the migration should respect a learning curve instead of pretending YAML translation is the whole job.
Incremental migration that actually finishes
Big-bang “convert the whole compose file Friday afternoon” often stalls. A path that works better:
1. Pick one stateless service.
Migrate web or api, not the database and the worker and the cache at once.
2. Create a dedicated namespace.
shop-dev or shop-staging keeps experiments away from other workloads.
3. Run parallel for a while.
Keep Compose for local dev if it still helps. Run the Kubernetes version in staging. Compare behavior, logs, and env.
4. Introduce a real registry.
Kubernetes nodes pull images from a registry the cluster can reach. imagePullPolicy and tags matter.
5. Add Ingress when internal Services work.
Prove curl from another Pod to http://api before exposing HTTP to the world.
6. Migrate stateful services last.
Move Postgres, Redis with persistence, or queues after you understand PVCs, backups, and recovery.
7. Document the mapping.
A short table in your repo (“Compose worker → Deployment worker, queue URL from ConfigMap shop-config”) saves future you during incidents.
kubectl create namespace shop-staging
kubectl apply -f k8s/web/ -n shop-staging
kubectl get deploy,svc,pods -n shop-staging
kubectl run curl --rm -it --image=curlimages/curl --restart=Never -n shop-staging -- \
curl -sS http://api:3000/health
That last command is a simple smoke test: can something in the cluster reach the Service DNS name and port?
Checklist before you call the migration done
- Deployment replicas and rollout strategy match what you need (not always
1). - Service selectors match Pod labels; endpoints exist (
kubectl get endpoints). - Config and secrets are not committed in plain text.
- Probes exist for anything that receives traffic.
- Resource requests (and limits where appropriate) are set so scheduling is predictable.
- Images use pinned tags, not only
latest, for anything beyond a toy cluster. - You know how to view logs and describe Pods when the app fails.
Final thought
Moving from Docker Compose to Kubernetes is less about memorising every API field and more about separating concerns Compose merged together: workload, network, config, and storage. The first win is one service running reliably behind a Service DNS name in a namespace you control. Each service after that reuses the same pattern. Incremental beats heroic — especially when the cluster is new and the Compose file was working fine yesterday.