Docker Compose to Kubernetes Migration: A Practical Guide
Your application runs perfectly in Docker Compose on a single server, but now you need high availability, auto-scaling, and zero-downtime deployments. Kubernetes provides these capabilities, but the migration path is full of subtle differences that break applications in production. Therefore, this guide walks through the practical conversion of Docker Compose services to Kubernetes manifests, covering the networking, storage, and configuration changes that trip up most teams. The recurring theme is that Compose assumes a single, stable host, whereas Kubernetes assumes the opposite — pods are cattle, nodes fail, and nothing about an address is permanent.
Why Migrate? Honest Trade-offs
Docker Compose is excellent for single-host deployments, development environments, and small applications. If your app runs on one server and you don’t need auto-scaling or rolling updates, Compose may be the right choice. Moreover, Compose files are simpler to read, faster to deploy, and require less operational knowledge.
Kubernetes makes sense when you need multi-node availability, horizontal auto-scaling, rolling deployments with automatic rollback, or when your organization standardizes on Kubernetes for all workloads. However, Kubernetes adds operational complexity — you need monitoring, RBAC, network policies, and cluster management. Consequently, evaluate whether the benefits justify the complexity for your specific use case. As a rule of thumb, if a single beefy server plus a nightly backup meets your availability target, Kubernetes will mostly add operational overhead rather than value.
# docker-compose.yml — Starting point
version: '3.8'
services:
api:
build: ./api
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/myapp
- REDIS_URL=redis://cache:6379
depends_on:
- db
- cache
restart: unless-stopped
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=myapp
cache:
image: redis:7-alpine
command: redis-server --maxmemory 256mb
volumes:
pgdata:
Using Kompose for Initial Manifest Generation
Kompose translates Docker Compose files to Kubernetes manifests automatically. It handles the basic conversion — services become Deployments, ports become Services, volumes become PersistentVolumeClaims. Additionally, it generates the boilerplate YAML that would take hours to write manually.
However, Kompose output is a starting point, not production-ready configuration. It doesn’t set resource limits, liveness probes, or security contexts. Furthermore, environment variables with passwords end up as plaintext in the manifests. You must refine every generated file before deploying to production. A useful way to think about Kompose is as a transcription tool, not a translator — it converts the syntax faithfully but understands nothing about your reliability or security requirements.
# Install Kompose
curl -L https://github.com/kubernetes/kompose/releases/latest/download/kompose-linux-amd64 -o kompose
chmod +x kompose && sudo mv kompose /usr/local/bin/
# Convert docker-compose.yml to Kubernetes manifests
kompose convert -f docker-compose.yml -o k8s/
# Generated files:
# k8s/api-deployment.yaml
# k8s/api-service.yaml
# k8s/db-deployment.yaml
# k8s/db-service.yaml
# k8s/cache-deployment.yaml
# k8s/cache-service.yaml
# k8s/pgdata-persistentvolumeclaim.yaml
# Review and customize before applying
kubectl apply -f k8s/ --dry-run=client
Networking: The Biggest Difference
In Docker Compose, services communicate by container name on a shared bridge network. In Kubernetes, services communicate through Service objects with DNS names like db.default.svc.cluster.local. For most cases, the short name db works within the same namespace. Specifically, Kubernetes DNS resolves service names to ClusterIP addresses, which load-balance across pods.
Compose’s depends_on has no Kubernetes equivalent because pods can restart independently. Your application must handle database connection retries gracefully — use exponential backoff in your connection code. In contrast to Compose where services start in order, Kubernetes pods may start before their dependencies are ready. This is the single most common cause of “it worked in Compose but crash-loops in Kubernetes,” and the fix lives in your application, not your YAML.
# Production-ready Kubernetes Deployment with health checks
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
labels:
app: api
spec:
replicas: 3
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: registry.example.com/myapp/api:v1.2.3
ports:
- containerPort: 3000
envFrom:
- secretRef:
name: api-secrets
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
Liveness vs Readiness: Getting Probes Right
The two probes above look similar, yet they do fundamentally different jobs, and conflating them causes outages. A readiness probe controls whether a pod receives traffic; when /ready fails, Kubernetes removes the pod from the Service’s endpoints but leaves it running. A liveness probe controls whether a pod is restarted; when /health fails, the kubelet kills and recreates the container. Consequently, your readiness endpoint should check downstream dependencies (can I reach the database?), while your liveness endpoint should check only the process itself (is my event loop responsive?).
A classic anti-pattern is pointing the liveness probe at a dependency-checking endpoint. Imagine the database briefly hiccups: every API pod’s liveness probe fails simultaneously, Kubernetes restarts all of them at once, and the resulting thundering herd of reconnections finishes off the already-struggling database. Therefore, keep liveness shallow and readiness deep. The example below shows a Node-style readiness handler that fails fast without blocking the liveness path.
// health endpoints — keep them on different code paths
app.get('/health', (_req, res) => {
// Liveness: is THIS process alive? No external calls.
res.status(200).json({ status: 'ok' });
});
app.get('/ready', async (_req, res) => {
// Readiness: can I actually serve a request right now?
try {
await Promise.all([
db.query('SELECT 1'),
cache.ping(),
]);
res.status(200).json({ status: 'ready' });
} catch (err) {
// 503 tells Kubernetes to stop routing traffic, but NOT to restart us
res.status(503).json({ status: 'degraded', error: err.message });
}
});
Storage and Secrets Management
Docker Compose volumes are local directories on the host machine. Kubernetes PersistentVolumeClaims (PVCs) abstract storage so pods can move between nodes. For databases, use StatefulSets instead of Deployments — they provide stable network identities and ordered pod management that databases require. The ReadWriteOnce access mode is also worth understanding early: a typical block volume can only attach to one node at a time, so you cannot naively scale a stateful pod to three replicas the way you would a stateless API.
Never put passwords in environment variables within Kubernetes manifests. Use Secrets objects and mount them as environment variables or files. For production, integrate with external secret managers like HashiCorp Vault or AWS Secrets Manager using the External Secrets Operator. As a result, secrets are rotated centrally without redeploying your application. Remember, too, that a Kubernetes Secret is only base64-encoded, not encrypted, unless you explicitly enable encryption at rest in etcd — so treat the raw manifest with the same care as a password file.
# Create secrets from literal values (use External Secrets in production)
apiVersion: v1
kind: Secret
metadata:
name: api-secrets
type: Opaque
stringData:
DATABASE_URL: "postgres://user:pass@db:5432/myapp"
REDIS_URL: "redis://cache:6379"
---
# StatefulSet for PostgreSQL (not Deployment)
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:16
envFrom:
- secretRef:
name: db-secrets
volumeMounts:
- name: pgdata
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: pgdata
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
Should the Database Live in Kubernetes at All?
Just because you can run PostgreSQL as a StatefulSet does not mean you should. Running a stateful database inside Kubernetes means you now own backups, point-in-time recovery, storage performance tuning, major-version upgrades, and failover orchestration — all of which a managed service like Amazon RDS, Google Cloud SQL, or Azure Database handles for you. For many teams migrating off Compose, the pragmatic path is to move the stateless tiers (the API, workers, and cache) into Kubernetes while pointing them at a managed database over the network.
This hybrid approach sidesteps the riskiest part of the migration. Your DATABASE_URL simply points at a managed endpoint instead of an in-cluster Service, and you lose nothing in resilience — arguably you gain it, because cloud-managed databases offer multi-AZ failover out of the box. Conversely, self-hosting in-cluster makes sense when you have strict data-residency rules, an existing database operator you trust, or a strong reason to keep everything portable across clouds. The honest trade-off is operational burden versus control, and most teams underestimate the burden.
Production Considerations and Deployment Strategy
In production, add Horizontal Pod Autoscalers, Pod Disruption Budgets, and Ingress controllers. Set resource requests and limits on every container — without them, a memory-leaking pod can crash the entire node. Additionally, use namespace isolation to separate environments (staging, production) on the same cluster. A Pod Disruption Budget deserves special mention because it is easy to skip and painful to skip: it tells Kubernetes the minimum number of pods that must stay available during voluntary disruptions like node drains, preventing a routine cluster upgrade from taking your whole API offline at once.
Migrate incrementally. Run your Compose stack and Kubernetes deployment in parallel, routing a percentage of traffic to Kubernetes using a load balancer. Increase the percentage gradually while monitoring error rates and latency. Furthermore, keep your Compose setup as a rollback option until Kubernetes is proven stable under your production load. During this overlap, watch the metrics that actually predict user pain — p99 latency, error rate, and saturation — rather than vanity numbers, and only advance the traffic split when each step holds steady.
Related Reading:
- Container Security: Hardening Docker Images
- Supply Chain Security for CI/CD Pipelines
- Kubernetes Operators and Custom Resources
Resources:
In conclusion, migrating from Docker Compose to Kubernetes is justified when you need multi-node resilience, auto-scaling, and rolling deployments. Use Kompose for initial manifest generation but customize every file for production — add health probes, resource limits, secrets management, and security contexts. Migrate incrementally and keep Compose as a fallback until your Kubernetes deployment proves itself under real traffic.