Docker Compose to Kubernetes Migration: Complete Mapping Guide with YAML Examples

18 min read
docker composekubernetesmigrationcontainersk8s manifestspersistent volumes

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 ComposeKubernetesPurpose

`service`Deployment + ServiceApplication definition + networking
`image``containers[].image`Container image
`ports`Service + IngressPort exposure and routing
`volumes` (named)PersistentVolumeClaimPersistent storage
`volumes` (bind mount)ConfigMap or hostPathConfiguration files
`environment`ConfigMap + SecretEnvironment variables
`depends_on`Init containers or readiness probesStartup ordering
`restart: always``restartPolicy: Always` (default)Restart behavior
`deploy.replicas``spec.replicas`Instance count
`deploy.resources``resources.requests/limits`CPU/memory constraints
`networks`NetworkPolicyNetwork isolation
`healthcheck`readinessProbe + livenessProbeHealth 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

StepActionVerification

1Create namespace`kubectl get namespace myapp`
2Apply secrets`kubectl get secrets -n myapp`
3Apply ConfigMaps`kubectl get configmaps -n myapp`
4Apply PVCs`kubectl get pvc -n myapp` (Bound status)
5Deploy database`kubectl get pods -l app=db -n myapp` (Running)
6Deploy cache`kubectl get pods -l app=cache -n myapp` (Running)
7Deploy application`kubectl get pods -l app=my-app -n myapp` (Running, Ready)
8Apply Service`kubectl get svc -n myapp`
9Apply Ingress`curl -f https://app.yourdomain.com/health`
10Verify end-to-endFull 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.