Docker Compose works for single-server deployments, but as your application grows past one server, you need Kubernetes. The migration path is predictable: every Compose concept maps directly to a Kubernetes resource. This guide provides the complete mapping with production-ready YAML you can adapt for your own migration.
Docker Compose manages containers on a single host. Kubernetes manages containers across a cluster of hosts with automatic scheduling, self-healing, rolling updates, and service discovery. The trade-off is complexity: a 30-line Compose file becomes 150+ lines of Kubernetes manifests. But that verbosity buys you auto-scaling, zero-downtime deployments, and infrastructure that survives server failures.
---
Concept Mapping: Compose to Kubernetes
| Docker Compose | Kubernetes | Purpose |
| `service` | Deployment + Service | Application definition + networking |
| `image` | `containers[].image` | Container image |
| `ports` | Service + Ingress | Port exposure and routing |
| `volumes` (named) | PersistentVolumeClaim | Persistent storage |
| `volumes` (bind mount) | ConfigMap or hostPath | Configuration files |
| `environment` | ConfigMap + Secret | Environment variables |
| `depends_on` | Init containers or readiness probes | Startup ordering |
| `restart: always` | `restartPolicy: Always` (default) | Restart behavior |
| `deploy.replicas` | `spec.replicas` | Instance count |
| `deploy.resources` | `resources.requests/limits` | CPU/memory constraints |
| `networks` | NetworkPolicy | Network isolation |
| `healthcheck` | readinessProbe + livenessProbe | Health monitoring |
---
Source: Docker Compose File
Here is a typical production Docker Compose file that we will migrate to Kubernetes resource by resource.
<h1>docker-compose.yml</h1>
version: "3.8"
services:
app:
image: ghcr.io/my-org/my-app:v1.0.0
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://appuser:secret@db:5432/myapp
- REDIS_URL=redis://cache:6379
depends_on:
- db
- cache
restart: always
deploy:
replicas: 2
resources:
limits:
cpus: "1.0"
memory: 512M
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
environment:
- POSTGRES_DB=myapp
- POSTGRES_USER=appuser
- POSTGRES_PASSWORD=secret
restart: always
cache:
image: redis:7-alpine
restart: always
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
pgdata:
---
Kubernetes Migration: Application Deployment
The Compose `app` service becomes a Kubernetes Deployment. Every Compose field maps to a specific location in the Deployment spec.
<h1>k8s/app-deployment.yaml</h1>
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
labels:
app: my-app
spec:
replicas: 2
selector:
matchLabels:
app: my-app
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: my-app
spec:
# Init container replaces depends_on
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ["sh", "-c", "until nc -z db-service 5432; do sleep 2; done"]
containers:
- name: app
image: ghcr.io/my-org/my-app:v1.0.0
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secrets
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 15
periodSeconds: 20
Key differences from Compose:
- `depends_on` becomes an init container that waits for the database to accept connections
- `environment` splits into ConfigMap (non-sensitive) and Secret (sensitive)
- `resources` uses Kubernetes format (`250m` = 0.25 CPU, `256Mi` = 256 MiB)
- `healthcheck` splits into readinessProbe (traffic routing) and livenessProbe (restart trigger)
- `restart: always` is the Kubernetes default, so it is not explicitly specified
---
Kubernetes Service (Port Exposure)
The Compose `ports` mapping becomes a Kubernetes Service. The Service provides a stable DNS name and load balancing across pod replicas.
<h1>k8s/app-service.yaml</h1>
apiVersion: v1
kind: Service
metadata:
name: my-app
spec:
selector:
app: my-app
ports:
- port: 80
targetPort: 3000
protocol: TCP
type: ClusterIP
Unlike Compose where `ports: "3000:3000"` exposes directly to the host, Kubernetes uses ClusterIP (internal only) and routes external traffic through an Ingress.
---
Kubernetes Ingress (External Access)
Ingress replaces the Compose port mapping for external access. It provides TLS termination, hostname-based routing, and path-based routing.
<h1>k8s/app-ingress.yaml</h1>
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- app.yourdomain.com
secretName: my-app-tls
rules:
- host: app.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app
port:
number: 80
---
ConfigMap and Secret (Environment Variables)
Compose `environment` variables split into ConfigMap (non-sensitive) and Secret (sensitive). This separation follows the principle of least privilege.
<h1>k8s/app-configmap.yaml</h1>
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
NODE_ENV: production
REDIS_URL: redis://cache-service:6379
<h1>k8s/app-secret.yaml</h1>
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
DATABASE_URL: postgresql://appuser:secret@db-service:5432/myapp
---
PersistentVolumeClaim (Named Volumes)
Compose named volumes (`pgdata:`) become PersistentVolumeClaims in Kubernetes. The PVC requests storage from the cluster storage provider.
<h1>k8s/postgres-pvc.yaml</h1>
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pgdata
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: standard
---
Database Deployment (PostgreSQL)
<h1>k8s/postgres-deployment.yaml</h1>
apiVersion: apps/v1
kind: Deployment
metadata:
name: db
spec:
replicas: 1
selector:
matchLabels:
app: db
strategy:
type: Recreate
template:
metadata:
labels:
app: db
spec:
containers:
- name: postgres
image: postgres:16
ports:
- containerPort: 5432
envFrom:
- secretRef:
name: db-secrets
volumeMounts:
- name: pgdata
mountPath: /var/lib/postgresql/data
- name: init-sql
mountPath: /docker-entrypoint-initdb.d
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
volumes:
- name: pgdata
persistentVolumeClaim:
claimName: pgdata
- name: init-sql
configMap:
name: db-init-sql
---
apiVersion: v1
kind: Service
metadata:
name: db-service
spec:
selector:
app: db
ports:
- port: 5432
targetPort: 5432
---
apiVersion: v1
kind: Secret
metadata:
name: db-secrets
type: Opaque
stringData:
POSTGRES_DB: myapp
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret
The Compose bind mount (`./init.sql`) becomes a ConfigMap. The database uses `strategy: Recreate` instead of `RollingUpdate` because PostgreSQL cannot run two instances against the same data directory.
---
Redis Deployment
<h1>k8s/redis-deployment.yaml</h1>
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-alpine
command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
ports:
- containerPort: 6379
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 250m
memory: 300Mi
---
apiVersion: v1
kind: Service
metadata:
name: cache-service
spec:
selector:
app: cache
ports:
- port: 6379
targetPort: 6379
The Compose `command` field maps directly to the Kubernetes `command` field. Redis does not need persistent storage in this configuration because it is used as a cache with LRU eviction.
---
Migration Checklist
| Step | Action | Verification |
| 1 | Create namespace | `kubectl get namespace myapp` |
| 2 | Apply secrets | `kubectl get secrets -n myapp` |
| 3 | Apply ConfigMaps | `kubectl get configmaps -n myapp` |
| 4 | Apply PVCs | `kubectl get pvc -n myapp` (Bound status) |
| 5 | Deploy database | `kubectl get pods -l app=db -n myapp` (Running) |
| 6 | Deploy cache | `kubectl get pods -l app=cache -n myapp` (Running) |
| 7 | Deploy application | `kubectl get pods -l app=my-app -n myapp` (Running, Ready) |
| 8 | Apply Service | `kubectl get svc -n myapp` |
| 9 | Apply Ingress | `curl -f https://app.yourdomain.com/health` |
| 10 | Verify end-to-end | Full application smoke test |
<h1>Apply all manifests in order</h1>
kubectl create namespace myapp
kubectl apply -f k8s/app-secret.yaml -n myapp
kubectl apply -f k8s/db-secrets.yaml -n myapp
kubectl apply -f k8s/app-configmap.yaml -n myapp
kubectl apply -f k8s/postgres-pvc.yaml -n myapp
kubectl apply -f k8s/postgres-deployment.yaml -n myapp
kubectl apply -f k8s/redis-deployment.yaml -n myapp
<h1>Wait for databases to be ready</h1>
kubectl wait --for=condition=ready pod -l app=db -n myapp --timeout=120s
kubectl wait --for=condition=ready pod -l app=cache -n myapp --timeout=60s
<h1>Deploy application</h1>
kubectl apply -f k8s/app-deployment.yaml -n myapp
kubectl apply -f k8s/app-service.yaml -n myapp
kubectl apply -f k8s/app-ingress.yaml -n myapp
<h1>Verify</h1>
kubectl get all -n myapp
For setting up the CI/CD pipeline that builds and deploys to your new Kubernetes cluster, see our CI/CD pipeline guide for startups. To implement GitOps for managing your Kubernetes manifests, read our GitOps for small teams guide. If you need zero-downtime deployments, check our blue-green deployment checklist.
---
Frequently Asked Questions
Do I need to learn Helm to migrate from Docker Compose?
No. Start with plain Kubernetes YAML manifests (as shown in this guide). Helm adds templating and package management, which are useful for complex applications with many environments, but plain YAML is sufficient for a first migration. Introduce Helm after your Kubernetes deployment is stable and you need to manage multiple environments or share configurations across teams.
How do I handle docker-compose.override.yml for local development?
Use Kustomize overlays or Skaffold for local Kubernetes development. Alternatively, keep Docker Compose for local development and deploy to Kubernetes for staging and production. Many teams run Compose locally and Kubernetes in the cloud without issues. The Docker images are identical in both environments.
What about Docker Compose networks?
Kubernetes provides flat networking by default: every pod can reach every other pod by Service name. The equivalent of Compose network isolation is Kubernetes NetworkPolicy, which restricts pod-to-pod communication. For most applications, you do not need NetworkPolicy during initial migration.
How do I migrate volumes with existing data?
Export data from your Docker Compose volumes, create the PVC in Kubernetes, and import the data into a temporary pod that mounts the PVC. For PostgreSQL: `pg_dump` from Compose, `pg_restore` into the Kubernetes pod. For file-based volumes, use `kubectl cp` to transfer files into the PVC.
What is the minimum Kubernetes cluster for a Compose migration?
A 2-node cluster with 4 GB RAM per node handles most Compose-sized applications. Use managed Kubernetes (EKS, GKE, AKS) to avoid managing the control plane. The monthly cost for a minimal managed cluster starts at approximately $70-100 for the control plane plus compute costs.
Should I use StatefulSets instead of Deployments for databases?
For a single-instance database (which is what Compose provides), a Deployment with `strategy: Recreate` and a PVC works fine. StatefulSets are for clustered databases that need stable network identities and ordered startup (e.g., PostgreSQL with Patroni, MongoDB replica sets). For production databases on Kubernetes, consider operators like CloudNativePG which manage the full lifecycle.