A docker-compose file that has been running in production for two years is not technical debt. It is a specification. It tells you exactly which services exist, how they talk, what state they hold and which environment variables matter. Treat it as the source of truth and the migration gets much less dramatic.
What follows is the staged path, plus the four things that break every single time.
Go through the compose file and put every service in one of three buckets.
The single biggest cause of miserable migrations is running the database in the cluster on day one because compose did. Compose ran it on the same box as everything else, which was fine when the box was pets. In a scheduler that can evict and reschedule your pod at any time, a database on ephemeral storage is a loaded gun.
Resist the urge to adopt service meshes, GitOps, operators and Helm chart libraries in the same change. Get the workload running as boringly as possible first.
Each compose service becomes a Deployment plus a Service. Each ports: entry becomes a container port and a service port. Each environment: block becomes a ConfigMap, with anything secret pulled out into a Secret. depends_on becomes nothing at all — Kubernetes has no ordering, so services must tolerate their dependencies being briefly absent, which is a property you wanted anyway.
kubectl create configmap api-env --from-env-file=.env.production --dry-run=client -o yaml > api-config.yaml
kubectl create secret generic api-secrets --from-env-file=.env.secrets --dry-run=client -o yaml > api-secrets.yaml
Tools that auto-convert compose files will get you a first draft. Read every line of the output — they are consistently wrong about volumes, health checks and anything involving networking.
In compose, a container that starts is a container that works. In Kubernetes, a container that starts gets traffic immediately, which means a service that takes eight seconds to warm its connection pool will serve eight seconds of 502s on every deploy.
Three probes, three different jobs:
startupProbe: # "has it finished booting?" — generous
httpGet: { path: /healthz, port: 8080 }
failureThreshold: 30
periodSeconds: 2
readinessProbe: # "should it receive traffic right now?"
httpGet: { path: /ready, port: 8080 }
periodSeconds: 5
livenessProbe: # "is it wedged and in need of a kill?" — conservative
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
failureThreshold: 6
The readiness endpoint should check the things the service needs to serve a request — database reachable, cache reachable. The liveness endpoint should check almost nothing. A liveness probe that fails when the database is down will restart every pod you have during a database blip, turning a degradation into an outage.
Localhost. In compose, services reach each other by service name over a shared network, and containers in the same service share nothing. In Kubernetes, containers in the same pod share localhost, and everything else goes through a Service DNS name. Any hard-coded 127.0.0.1:5432 that worked because of a host network mode will now fail quietly.
File uploads. Something in your stack writes to a local directory and something else reads it. On one box that worked. Across pods it does not, and a shared ReadWriteMany volume is not the fix you want. Move uploads to object storage; it is a smaller change than it looks and removes the volume from the migration entirely.
Cron. Compose stacks grow a container that runs cron internally. Two replicas means every job runs twice. Convert them to CronJob objects, one per job, and add a concurrency policy.
apiVersion: batch/v1
kind: CronJob
spec:
schedule: "*/15 * * * *"
concurrencyPolicy: Forbid
Graceful shutdown. Kubernetes sends SIGTERM and waits 30 seconds. Applications that ignore SIGTERM get SIGKILL mid-request on every single rollout. If your framework does not handle it, this is the one code change worth making before you migrate.
Point DNS at the cluster for one low-risk service, leave the rest on the old host, and run both for a week. Kubernetes and compose can happily talk to the same database while you do this. Move the next service when the first one has survived a deploy, a restart and a traffic peak.
The temptation is a single big-bang cutover on a Friday, because that is how the last migration went. The staged version takes three weeks of low-stress evenings instead of one catastrophic weekend, and at every point you have a working system to roll back to.
Some things in the compose file exist only because it was compose: the reverse proxy container that terminates TLS (an Ingress does this), the container that waits for the database before starting the app (readiness probes do this), the entrypoint script that renders config from environment variables at boot (a ConfigMap does this). Deleting them is part of the migration, not a follow-up task.