“Should we use Docker Compose or Kubernetes?” is one of the most common questions from teams that have just containerised an application. The honest answer is that they solve different problems: Docker Compose describes and runs a multi-container application on one machine; Kubernetes runs containers across a cluster of machines with self-healing, scaling and rolling updates. Many teams use both, Compose for development and Kubernetes for production. This tutorial compares them concretely by writing the same application for each, maps every Compose concept to its Kubernetes equivalent, shows how to migrate, and ends with a decision guide.
Prerequisites: Docker Engine 27+ with Compose v2 (docker compose version) and, for the Kubernetes part, a local cluster such as kind or minikube plus kubectl. See Docker Compose and the Kubernetes learning path for the basics.
At a glance
| Docker Compose | Kubernetes | |
|---|---|---|
| Scope | One host (Docker Engine) | Cluster of nodes |
| Definition file | compose.yaml (one file, ~50 lines for a small app) | Many manifests (Deployment, Service, ConfigMap, Secret, Ingress, PVC…) or a Helm chart |
| Self-healing | restart: always restarts a container on the same host; nothing if the host dies | Reschedules pods on healthy nodes; replaces unhealthy pods automatically |
| Scaling | docker compose up --scale web=3 on one machine | Horizontal Pod Autoscaler across nodes; Cluster Autoscaler adds nodes |
| Rolling updates | Recreate containers (brief downtime unless you script it) | Built-in RollingUpdate with readiness probes, zero downtime |
| Networking | Bridge network, service name = DNS name | Cluster-wide pod network (CNI), Services, Ingress/Gateway |
| Configuration and secrets | env files, configs, secrets (files) | ConfigMaps, Secrets, external secret operators |
| Storage | Named volumes, bind mounts | PersistentVolumeClaims with StorageClasses (EBS, EFS, Ceph…) |
| Learning curve | Hours | Weeks |
| Operations burden | Almost none | Cluster upgrades, add-ons, RBAC, observability stack (or pay for a managed service) |
| Typical use | Local development, CI test environments, small single-server deployments | Production platforms, microservices, multi-team organisations |
The sample application
Three services: an API (ghcr.io/example/api, port 8000), a PostgreSQL database with persistent storage, and a Redis cache. The API reads its database password from a secret and a feature flag from configuration. The API image is a placeholder; any HTTP service that exposes /healthz will do.
Version 1 – Docker Compose
# compose.yaml
services:
api:
image: ghcr.io/example/api:1.4.0
ports:
- "8080:8000"
environment:
DATABASE_URL: postgres://app@db:5432/app
REDIS_URL: redis://cache:6379
FEATURE_NEW_CHECKOUT: "true"
secrets:
- db_password
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
interval: 10s
timeout: 3s
retries: 3
deploy:
resources:
limits:
memory: 256M
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
cache:
image: redis:7.4-alpine
command: ["redis-server", "--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
pgdata:
secrets:
db_password:
file: ./secrets/db_password.txt # never committed; .gitignore itmkdir -p secrets && openssl rand -base64 24 > secrets/db_password.txt
docker compose up -d
docker compose ps
docker compose logs -f api
curl -s localhost:8080/healthz
docker compose up -d --scale api=3 # 3 API containers, but the host port can only map to one
docker compose pull && docker compose up -d # "update": recreate changed containers
docker compose down # keep volumes; add -v to delete dataFifty lines, one command, and everything runs on your laptop. Notice the limits: scaling the API requires a reverse proxy in front (Traefik, nginx) because only one container can own port 8080, and an update is a stop/start.
Version 2 – Kubernetes
The same application needs several objects. The mapping is systematic:
| Compose | Kubernetes |
|---|---|
services.api (stateless) | Deployment + Service |
services.db (stateful, one instance) | StatefulSet + headless Service + PersistentVolumeClaim (or a managed database) |
ports: "8080:8000" | Service (ClusterIP) + Ingress/Gateway for external access |
environment | env, or ConfigMap referenced with envFrom |
secrets | Secret mounted as file or env var |
volumes: pgdata | PersistentVolumeClaim + StorageClass |
healthcheck | livenessProbe and readinessProbe |
deploy.resources | resources.requests / limits |
depends_on | No direct equivalent: readiness probes + init containers + retries in the app |
restart | Always, by the controller |
| Service name as DNS | <service>.<namespace>.svc.cluster.local |
# k8s/api.yaml
apiVersion: v1
kind: ConfigMap
metadata: { name: api-config }
data:
DATABASE_URL: postgres://app@db:5432/app
REDIS_URL: redis://cache:6379
FEATURE_NEW_CHECKOUT: "true"
---
apiVersion: v1
kind: Secret
metadata: { name: db-credentials }
type: Opaque
stringData:
password: REPLACE_ME_AT_DEPLOY_TIME # in practice: sealed-secrets, External Secrets, or kubectl create secret
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: api }
spec:
replicas: 3
selector: { matchLabels: { app: api } }
strategy:
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
template:
metadata: { labels: { app: api } }
spec:
initContainers:
- name: wait-for-db
image: postgres:17-alpine
command: ["sh", "-c", "until pg_isready -h db -U app; do sleep 2; done"]
containers:
- name: api
image: ghcr.io/example/api:1.4.0
ports: [{ containerPort: 8000 }]
envFrom:
- configMapRef: { name: api-config }
env:
- name: DB_PASSWORD
valueFrom: { secretKeyRef: { name: db-credentials, key: password } }
readinessProbe:
httpGet: { path: /healthz, port: 8000 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: 8000 }
periodSeconds: 10
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 256Mi }
---
apiVersion: v1
kind: Service
metadata: { name: api }
spec:
selector: { app: api }
ports: [{ port: 80, targetPort: 8000 }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api
spec:
ingressClassName: nginx
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: api, port: { number: 80 } } }# k8s/db.yaml
apiVersion: v1
kind: Service
metadata: { name: db }
spec:
clusterIP: None # headless: stable DNS for the StatefulSet pod
selector: { app: db }
ports: [{ port: 5432 }]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: db }
spec:
serviceName: db
replicas: 1
selector: { matchLabels: { app: db } }
template:
metadata: { labels: { app: db } }
spec:
containers:
- name: postgres
image: postgres:17-alpine
env:
- { name: POSTGRES_USER, value: app }
- { name: POSTGRES_DB, value: app }
- name: POSTGRES_PASSWORD
valueFrom: { secretKeyRef: { name: db-credentials, key: password } }
ports: [{ containerPort: 5432 }]
volumeMounts:
- { name: pgdata, mountPath: /var/lib/postgresql/data }
readinessProbe:
exec: { command: ["pg_isready", "-U", "app"] }
volumeClaimTemplates:
- metadata: { name: pgdata }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 10Gi } }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: cache }
spec:
replicas: 1
selector: { matchLabels: { app: cache } }
template:
metadata: { labels: { app: cache } }
spec:
containers:
- name: redis
image: redis:7.4-alpine
args: ["--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]
ports: [{ containerPort: 6379 }]
---
apiVersion: v1
kind: Service
metadata: { name: cache }
spec:
selector: { app: cache }
ports: [{ port: 6379 }]kubectl create namespace shop
kubectl -n shop create secret generic db-credentials --from-literal=password="$(openssl rand -base64 24)"
kubectl -n shop apply -f k8s/db.yaml -f k8s/api.yaml # remove the placeholder Secret from api.yaml first
kubectl -n shop get pods -w
kubectl -n shop port-forward svc/api 8080:80 &
curl -s localhost:8080/healthz
# The things Compose cannot do
kubectl -n shop scale deployment/api --replicas=6
kubectl -n shop set image deployment/api api=ghcr.io/example/api:1.5.0 # zero-downtime rolling update
kubectl -n shop rollout undo deployment/api
kubectl -n shop autoscale deployment/api --min=3 --max=20 --cpu-percent=70Roughly 150 lines instead of 50, but in exchange: three API replicas spread across nodes behind one stable address, an update that never drops a request, automatic replacement if a node disappears, and autoscaling on CPU.
Migrating from Compose to Kubernetes
Option A – Kompose for a first draft
kompose convert -f compose.yaml -o k8s/ # one file per object
kompose convert -f compose.yaml --chart -o chart/ # or a Helm chart skeleton
ls k8s/
# api-deployment.yaml api-service.yaml db-deployment.yaml pgdata-persistentvolumeclaim.yaml ...Kompose (see our Kompose tutorial) produces valid manifests fast, but treat them as a starting point: it generates Deployments where you want StatefulSets, ignores depends_on, and cannot invent probes or resource requests you did not specify.
Option B – Helm chart or Kustomize by hand
For anything that will live in production, write the manifests deliberately (as above), then parameterise them with Helm or Kustomize overlays for dev/staging/prod. Use managed services for stateful pieces where possible: RDS instead of a PostgreSQL StatefulSet, ElastiCache instead of Redis in the cluster.
Keep Compose for development
Migrating production does not mean abandoning Compose. Most teams keep compose.yaml for local development and CI integration tests, because it starts in seconds and needs no cluster. Tools like Tilt, Skaffold or DevSpace offer the same inner loop against a local Kubernetes cluster if you want dev/prod parity.
The middle ground
- Docker Swarm runs your Compose file (with a
deploy:section) across several hosts with rolling updates and secrets. Minimal learning curve; small ecosystem. - k3s is a full Kubernetes in a single 70 MB binary; ideal for one to five servers when you want Kubernetes semantics without the setup cost.
- Managed container platforms (AWS ECS/Fargate, Azure Container Apps, Google Cloud Run) accept container images directly and handle scaling and updates without exposing Kubernetes.
Decision guide
- Stay with Compose if: one server is enough, a few minutes of downtime during updates is acceptable, the team is small, and there is no requirement for autoscaling or multi-region.
- Move to Kubernetes if: you need more than one machine, zero-downtime deployments, automatic recovery from node failure, autoscaling, several teams sharing infrastructure, or you already pay for a managed Kubernetes service.
- Consider Swarm, k3s or a managed container service if you are between the two: more than one host but no platform team.
- Whatever you pick for production, keep Compose for local development; the images are identical.
Common pitfalls when moving
- Relying on
depends_onordering: in Kubernetes, containers must retry connections; add init containers only as a convenience. - Forgetting resource requests: without them the scheduler packs pods badly and autoscaling cannot work.
- Putting the database in a Deployment: use a StatefulSet with a PVC or, better, a managed database.
- Baking secrets into ConfigMaps or images; use Secrets with an external secret manager.
- Exposing everything with NodePort: use one Ingress controller or Gateway API instead.
Key takeaways
- Compose = one host, one file, instant start; Kubernetes = cluster, many objects, self-healing and scaling.
- Every Compose concept has a Kubernetes counterpart; the extra lines buy probes, rolling updates and placement.
- Kompose gives a draft; Helm/Kustomize gives a maintainable production setup.
- Use Compose for development and Kubernetes (or a managed platform) for production; the images are the same.
Related: Docker Compose, Docker Swarm vs Kubernetes, Kubernetes Deployments, StatefulSets. Official docs: Docker Compose, Kubernetes workloads.
Retour parcours Docker — hub de la série et leçons sœurs.


