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:
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.
Top comments (0)