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.

Related tool

K8s Cluster Cost

Calculate the monthly cost of running a self-managed Kubernetes cluster on Hetzner Cloud, Vultr, or DigitalOcean. Choose server types for control planes, workers, and load balancers with HA mode.