Skip to main content
K8sCalc
kubernetes12 May 2026

How to Migrate from Docker Compose to Kubernetes: A Practical Guide

A practical, hands-on guide to migrating a real multi-service Docker Compose app to Kubernetes — mapping every concept and translating YAML line by line.

If your app runs on Docker Compose today, Kubernetes is not a rewrite — it's a translation. Every concept in Compose has a direct equivalent in Kubernetes. Once you understand the mapping, the migration becomes mechanical.

This guide walks through migrating a real three-tier app: a Next.js frontend, a PostgreSQL database, and Redis. By the end you'll have production-grade Kubernetes manifests and a clear mental model you can apply to any Compose file.

Concept Mapping

Every Compose primitive has a Kubernetes equivalent. Internalize this table before touching any YAML.

Docker ComposeKubernetes EquivalentNotes
serviceDeployment + ServiceDeployment controls pods; Service provides DNS + routing
imagespec.containers[].imageSame image, same tag
portsService.spec.ports + IngressClusterIP for internal; Ingress for external
environmentenv or envFrom (ConfigMap/Secret)Never hardcode secrets in pod spec
volumes (named)PersistentVolumeClaimStorage class determines provisioner
volumes (bind mount)hostPath or ConfigMapAvoid hostPath in production
networksNetworkPolicyK8s default is allow-all; policies enforce deny
depends_onInit containers or readiness probesK8s doesn't have native service ordering
healthchecklivenessProbe + readinessProbeMore granular than Compose health checks
restart: alwaysrestartPolicy: Always (default)Already the default for Deployments
deploy.replicasspec.replicasSame concept, different location
deploy.resourcesresources.requests + resources.limitsK8s requires both for proper scheduling

The Example App

Here's the docker-compose.yml we're migrating:

version: "3.9"
services:
  web:
    image: myapp/frontend:1.4.2
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://app:secret@db:5432/appdb
      - REDIS_URL=redis://cache:6379
    depends_on:
      - db
      - cache
    deploy:
      replicas: 2
      resources:
        limits:
          cpus: "0.5"
          memory: 512M

  db:
    image: postgres:16
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=secret
      - POSTGRES_DB=appdb
    volumes:
      - pg_data:/var/lib/postgresql/data

  cache:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

volumes:
  pg_data:
  redis_data:

Step 1: Create Namespaces and Secrets

Start with namespace isolation and secrets. Never inline credentials in Deployment specs.

kubectl create namespace myapp
kubectl create secret generic postgres-credentials \
  --namespace myapp \
  --from-literal=POSTGRES_USER=app \
  --from-literal=POSTGRES_PASSWORD=secret \
  --from-literal=POSTGRES_DB=appdb
kubectl create secret generic app-env \
  --namespace myapp \
  --from-literal=DATABASE_URL="postgres://app:secret@db:5432/appdb" \
  --from-literal=REDIS_URL="redis://cache:6379"

Step 2: Persistent Volume Claims

The Compose volumes block becomes PVCs. Each stateful service gets its own claim. Use the Kubernetes PVC Generator to scaffold these quickly.

# postgres-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data
  namespace: myapp
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 20Gi
---
# redis-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis-data
  namespace: myapp
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 5Gi

Step 3: Deployments

Each Compose service becomes a Deployment. Note how depends_on is replaced with a readinessProbe — Kubernetes will restart the pod and hold traffic until the probe passes.

Use the Kubernetes Deployment Generator to scaffold the base manifests, then add the sections below.

# postgres-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: db
  namespace: myapp
spec:
  replicas: 1
  selector:
    matchLabels:
      app: db
  template:
    metadata:
      labels:
        app: db
    spec:
      containers:
        - name: postgres
          image: postgres:16
          envFrom:
            - secretRef:
                name: postgres-credentials
          ports:
            - containerPort: 5432
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "app", "-d", "appdb"]
            initialDelaySeconds: 5
            periodSeconds: 10
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "1"
              memory: "1Gi"
          volumeMounts:
            - name: pg-data
              mountPath: /var/lib/postgresql/data
      volumes:
        - name: pg-data
          persistentVolumeClaim:
            claimName: postgres-data
---
apiVersion: v1
kind: Service
metadata:
  name: db
  namespace: myapp
spec:
  selector:
    app: db
  ports:
    - port: 5432
      targetPort: 5432

The web service gets an HPA-ready Deployment:

# web-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: myapp
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: frontend
          image: myapp/frontend:1.4.2
          envFrom:
            - secretRef:
                name: app-env
          ports:
            - containerPort: 3000
          readinessProbe:
            httpGet:
              path: /healthz
              port: 3000
            initialDelaySeconds: 10
            periodSeconds: 5
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
---
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: myapp
spec:
  selector:
    app: web
  ports:
    - port: 3000
      targetPort: 3000

Step 4: Ingress

In Compose, you expose ports directly. In Kubernetes, external traffic flows through an Ingress controller. Use the Kubernetes Ingress Generator to generate the manifest.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-ingress
  namespace: myapp
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - myapp.example.com
      secretName: myapp-tls
  rules:
    - host: myapp.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 3000

Step 5: Network Policies

Docker Compose networks provide implicit isolation between stacks but allow all traffic within a network. In Kubernetes, the default is allow-all across all pods in a cluster. Use the Kubernetes Network Policy Generator to lock this down.

# Deny all ingress to the myapp namespace by default
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: myapp
spec:
  podSelector: {}
  policyTypes:
    - Ingress
---
# Allow web to reach db on 5432
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-web-to-db
  namespace: myapp
spec:
  podSelector:
    matchLabels:
      app: db
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: web
      ports:
        - port: 5432

Common Migration Pitfalls

StatefulSets for databases: Single-replica databases can use Deployment + PVC (as shown above). For clustered Postgres (Patroni, etc.), use a StatefulSet for stable pod identity.

Config drift: Compose environment blocks often accumulate undocumented variables over time. Use this migration as an opportunity to audit every env var and move it to a properly named ConfigMap or Secret.

Image pull policies: Compose always pulls latest by default. In Kubernetes, imagePullPolicy: IfNotPresent is the default for tagged images. Pin your image tags before migrating.

Resource requests: Kubernetes will refuse to schedule pods that exceed node capacity if limits are set. Start with generous requests and tighten after observing actual usage in production.