Migrating from Docker Compose to Kubernetes: Service Translation, Persistent Volumes, and Incremental Adoption Strategies
A practical guide for teams outgrowing Docker Compose: translating Compose services to Kubernetes manifests, handling persistent storage, navigating networking differences, and running an incremental migration without a big-bang cutover.
Docker Compose is a reasonable choice for small teams, local development, and single-server production deployments. Then something changes: traffic grows, you need zero-downtime deploys, your on-call rotation gets tired of SSH-ing into one machine, or a new hire asks why the staging environment diverges from production every two weeks. Compose has a ceiling, and most teams hit it between five and fifteen services.
The transition to Kubernetes is not hard because Kubernetes is conceptually difficult. It is hard because the mental model is different enough that a direct translation produces working but fragile manifests. This guide covers the translation layer, the storage changes, the networking differences, and how to run the migration without a single risky cutover.
When Compose Is No Longer Enough
These are the signals that reliably precede migration decisions:
- You are running
docker compose upon a single VM and calling it production - Rolling deploys require manual container restarts with visible downtime
- You have more than one environment (staging, QA, preview) and keeping them consistent is manual work
- Health checks exist in your Compose file but nothing respects them for traffic routing
- You want horizontal scaling but Compose’s
--scaleflag breaks anything with local state
None of these are problems Compose cannot patch around. They are problems that get progressively more expensive to patch. Kubernetes solves them structurally, not through workarounds.
Translating a Compose Service to Kubernetes Manifests
A Compose service maps to multiple Kubernetes resources. There is no one-to-one translation.
Take a typical service definition:
# docker-compose.yml
services:
api:
image: myapp/api:1.4.2
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://user:pass@db:5432/myapp
LOG_LEVEL: info
depends_on:
- db
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
This becomes three Kubernetes resources.
Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: production
spec:
replicas: 2
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: myapp/api:1.4.2
ports:
- containerPort: 3000
env:
- name: LOG_LEVEL
value: "info"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: api-secrets
key: database-url
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
Service:
apiVersion: v1
kind: Service
metadata:
name: api
namespace: production
spec:
selector:
app: api
ports:
- port: 3000
targetPort: 3000
type: ClusterIP
Secret (for the database URL):
apiVersion: v1
kind: Secret
metadata:
name: api-secrets
namespace: production
type: Opaque
stringData:
database-url: "postgres://user:pass@db:5432/myapp"
Three things in the Compose file become first-class concerns in Kubernetes: resource limits (ignored in Compose), health check separation (readiness vs liveness), and secret handling (not inline environment variables).
ConfigMaps vs Secrets
Compose treats all environment variables identically. Kubernetes separates non-sensitive configuration (ConfigMap) from sensitive values (Secret). The practical rule: anything you would not check into a public repository goes in a Secret. Everything else can be a ConfigMap.
apiVersion: v1
kind: ConfigMap
metadata:
name: api-config
namespace: production
data:
LOG_LEVEL: "info"
PORT: "3000"
NODE_ENV: "production"
Reference both in the Deployment’s envFrom:
envFrom:
- configMapRef:
name: api-config
- secretRef:
name: api-secrets
Persistent Storage: PVCs vs Bind Mounts
This is where most migrations stall. Compose bind mounts (mapping a host path to a container path) do not translate to Kubernetes, because pods can run on any node. A bind mount to /data on node-1 means nothing when the pod reschedules to node-3.
Kubernetes uses PersistentVolumeClaims (PVCs). The claim describes what you need; the cluster satisfies it from a PersistentVolume (PV), either pre-provisioned or dynamically provisioned via a StorageClass.
# Original Compose bind mount
volumes:
- ./data/postgres:/var/lib/postgresql/data
Becomes:
# PersistentVolumeClaim
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
namespace: production
spec:
accessModes:
- ReadWriteOnce
storageClassName: standard
resources:
requests:
storage: 20Gi
# Referenced in a StatefulSet (not a Deployment for stateful services)
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: production
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secrets
key: password
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard
resources:
requests:
storage: 20Gi
Use StatefulSets, not Deployments, for anything with persistent local state (databases, message brokers, anything with a data directory). StatefulSets give stable pod names, stable network identifiers, and ordered scaling and termination.
The access mode matters. ReadWriteOnce means one node can mount the volume for read-write at a time. ReadWriteMany allows multiple nodes to mount the same volume, which requires a shared filesystem like EFS or NFS. Most cloud block storage (EBS, GCE Persistent Disk, Azure Disk) only supports ReadWriteOnce.
Networking Differences
Compose networks are flat. Any service can reach any other service by its service name. Kubernetes networking is more explicit.
| Compose concept | Kubernetes equivalent |
|---|---|
Service name DNS (db, api) | ClusterIP Service DNS (db.namespace.svc.cluster.local) |
ports mapping to host | NodePort or LoadBalancer Service |
networks for isolation | NetworkPolicies |
| Reverse proxy in docker-compose | Ingress controller |
depends_on | initContainers or readiness probes |
Within a namespace, services are reachable by their short name: a pod can reach the db Service at db:5432. Cross-namespace requires the full DNS name: db.production.svc.cluster.local.
For exposing services externally, Compose maps ports to the host directly. In Kubernetes, you use an Ingress:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress
namespace: production
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: api.myapp.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api
port:
number: 3000
The Ingress controller (nginx-ingress, Traefik, AWS ALB ingress) handles TLS termination, routing rules, and load balancing across pods. This is a substantial operational improvement over a hand-configured Nginx or Caddy in a Compose stack, but it requires the controller to be installed in the cluster.
depends_on in Compose only delays container start, not actual service readiness. In Kubernetes, use readiness probes to prevent traffic routing to pods that are not ready. For startup ordering (running migrations before the API starts), use init containers:
initContainers:
- name: run-migrations
image: myapp/api:1.4.2
command: ["node", "dist/migrate.js"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: api-secrets
key: database-url
The init container must exit with code 0 before the main container starts.
Migration Strategies
| Strategy | Risk | Speed | Rollback | Best for |
|---|---|---|---|---|
| Big bang cutover | High | Fast | Hard | Small stacks, throwaway environments |
| Parallel run | Low | Slow | Easy | Production services with SLA requirements |
| Incremental by service | Medium | Medium | Per-service | Most teams, multiple independent services |
| Read-only first | Low | Slow | Easy | Stateful services with data migration concerns |
The incremental approach works for most teams. The pattern:
- Deploy the Kubernetes cluster alongside the existing Compose stack
- Migrate stateless services first (workers, API servers with no local state)
- Point DNS at the Kubernetes Ingress for one service at a time
- Migrate stateful services last, with a planned maintenance window for data migration
- Decommission the Compose stack after all traffic is verified on Kubernetes
Running both in parallel is straightforward for stateless services. For stateful services (databases), you have two options: run the database on Kubernetes from day one (using the existing Compose database as a replica or backup source) or keep the database outside Kubernetes and only migrate the application layer. Many teams keep managed databases (RDS, Cloud SQL) outside Kubernetes permanently, which is a valid long-term architecture.
Helm for Templating
Raw Kubernetes YAML becomes repetitive across environments. The same Deployment needs different image tags, replica counts, and resource limits in staging vs production. Helm solves this with templating.
A minimal Helm chart structure:
myapp/
Chart.yaml
values.yaml
values-staging.yaml
values-production.yaml
templates/
deployment.yaml
service.yaml
ingress.yaml
configmap.yaml
secret.yaml
_helpers.tpl
values.yaml holds defaults; environment-specific files override them:
# values.yaml
image:
repository: myapp/api
tag: latest
replicaCount: 1
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
ingress:
host: api.staging.myapp.com
# values-production.yaml
replicaCount: 3
image:
tag: "1.4.2"
ingress:
host: api.myapp.com
resources:
requests:
cpu: "500m"
memory: "256Mi"
limits:
cpu: "2000m"
memory: "1Gi"
Deploy with:
helm upgrade --install myapp ./myapp \
-f values.yaml \
-f values-production.yaml \
--namespace production
Helm is not required. Kustomize (built into kubectl) handles the same problem with overlays instead of templates. Helm has a larger ecosystem of pre-built charts for common dependencies (PostgreSQL, Redis, cert-manager). Kustomize has less complexity and fewer foot-guns. Either is better than maintaining separate YAML files per environment.
Production Considerations
Resource limits are not optional. Compose ignores resource constraints by default. Kubernetes schedules pods based on requests and enforces limits. A pod without limits on a noisy-neighbor node will be OOM-killed. Set requests based on measured baseline consumption and limits at 2-4x requests. Use Vertical Pod Autoscaler in recommendation mode for the first two weeks to get empirical data.
Readiness vs liveness probes have different semantics. A failing readiness probe removes the pod from the Service endpoints (no traffic). A failing liveness probe restarts the pod. A misconfigured liveness probe that fires during normal startup causes restart loops. Set initialDelaySeconds generously (at least 2x your median cold start time). Use separate endpoints: /ready for readiness (checks database connectivity, cache warmup), /healthz for liveness (checks the process is alive and not deadlocked).
Health check endpoints need to be cheap. A health check that runs a database query on every probe interval adds measurable load at scale. Return a cached status or a static response for liveness. Only run real connectivity checks for readiness, and keep them sub-100ms.
Namespace isolation matters. Start with a single namespace per environment (staging, production). Do not put staging and production workloads in the same namespace. NetworkPolicies are easier to reason about and audit when environments are namespace-isolated.
ConfigMap and Secret changes do not automatically reload running pods. Updating a ConfigMap does not restart the Deployment. Use a hash annotation on the Deployment spec to force a rollout when config changes:
spec:
template:
metadata:
annotations:
checksum/config: "{{ include (print $.Template.BasePath \"/configmap.yaml\") . | sha256sum }}"
In Helm this annotation is recalculated on each upgrade. Without it, a config change silently does nothing until the next deploy.
RBAC from day one. In Compose, every container runs with the same access to host resources. In Kubernetes, create dedicated ServiceAccounts for each workload and only grant the RBAC permissions each service actually needs. This is faster to get right at the start than to retrofit.
Log shipping changes. Compose containers write to stdout; you typically tail logs with docker compose logs. In Kubernetes, stdout/stderr per container is available via kubectl logs, but for production you want a DaemonSet-based log shipper (Fluent Bit, Vector) collecting from all nodes and forwarding to your log aggregator. Plan this before migration, not after.
Realistic Migration Timeline
For a team with ten services, three engineers, and no prior Kubernetes experience:
- Week 1-2: Cluster setup, networking, ingress controller, cert-manager. Build one staging service end-to-end including CI/CD delivery. Learn the operational model before committing to a full migration.
- Week 3-4: Migrate all stateless services to staging Kubernetes. Parallel-run production on Compose.
- Week 5-6: Cut DNS for stateless services to Kubernetes production. Monitor for two weeks.
- Week 7-8: Plan stateful service migration. Decide whether the database moves to Kubernetes (StatefulSet) or stays managed. Run migration with a maintenance window.
- Week 9-10: Decommission the Compose stack. Document the runbook for on-call.
Two to three months is realistic for a clean migration. Teams that try to do it in two weeks typically end up with Kubernetes manifests that are copies of their Compose file with pod restarts at 3am.
The goal is not to be running Kubernetes. The goal is reliable, scalable service delivery. The migration succeeds when on-call incidents drop, not when the last Compose file is deleted.
More in DevOps
How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.
How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.
How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.
How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.