DEV Community

Tiago Luz
Tiago Luz

Posted on • Originally published at k8scalc.com

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

Originally published at k8scalc.com


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 Compose Kubernetes Equivalent Notes
service Deployment + Service Deployment controls pods; Service provides DNS + routing
image spec.containers[].image Same image, same tag
ports Service.spec.ports + Ingress ClusterIP for internal; Ingress for external
environment env or envFrom (ConfigMap/Secret) Never hardcode secrets in pod spec
volumes (named) PersistentVolumeClaim Storage class determines provisioner
volumes (bind mount) hostPath or ConfigMap Avoid hostPath in production
networks NetworkPolicy K8s default is allow-all; policies enforce deny
depends_on Init containers or readiness probes K8s doesn't have native service ordering
healthcheck livenessProbe + readinessProbe More granular than Compose health checks
restart: always restartPolicy: Always (default) Already the default for Deployments
deploy.replicas spec.replicas Same concept, different location
deploy.resources resources.requests + resources.limits K8s 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:
Enter fullscreen mode Exit fullscreen mode

Step 1: Create Namespaces and Secrets

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

kubectl create namespace myapp
Enter fullscreen mode Exit fullscreen mode
kubectl create secret generic postgres-credentials \
  --namespace myapp \
  --from-literal=POSTGRES_USER=app \
  --from-literal=POSTGRES_PASSWORD=secret \
  --from-literal=POSTGRES_DB=appdb
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)