DevOps ·

Migrating from Docker Compose to Kubernetes: Service Translation, Persistent Volumes, and Incremental Adoption Strategies

A practical guide for teams outgrowing Docker Compose: translating Compose services to Kubernetes manifests, handling persistent storage, navigating networking differences, and running an incremental migration without a big-bang cutover.

Migrating from Docker Compose to Kubernetes: Service Translation, Persistent Volumes, and Incremental Adoption Strategies

Docker Compose is a reasonable choice for small teams, local development, and single-server production deployments. Then something changes: traffic grows, you need zero-downtime deploys, your on-call rotation gets tired of SSH-ing into one machine, or a new hire asks why the staging environment diverges from production every two weeks. Compose has a ceiling, and most teams hit it between five and fifteen services.

The transition to Kubernetes is not hard because Kubernetes is conceptually difficult. It is hard because the mental model is different enough that a direct translation produces working but fragile manifests. This guide covers the translation layer, the storage changes, the networking differences, and how to run the migration without a single risky cutover.

When Compose Is No Longer Enough

These are the signals that reliably precede migration decisions:

  • You are running docker compose up on a single VM and calling it production
  • Rolling deploys require manual container restarts with visible downtime
  • You have more than one environment (staging, QA, preview) and keeping them consistent is manual work
  • Health checks exist in your Compose file but nothing respects them for traffic routing
  • You want horizontal scaling but Compose’s --scale flag breaks anything with local state

None of these are problems Compose cannot patch around. They are problems that get progressively more expensive to patch. Kubernetes solves them structurally, not through workarounds.

Translating a Compose Service to Kubernetes Manifests

A Compose service maps to multiple Kubernetes resources. There is no one-to-one translation.

Take a typical service definition:

# docker-compose.yml
services:
  api:
    image: myapp/api:1.4.2
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/myapp
      LOG_LEVEL: info
    depends_on:
      - db
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

This becomes three Kubernetes resources.

Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: production
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: myapp/api:1.4.2
          ports:
            - containerPort: 3000
          env:
            - name: LOG_LEVEL
              value: "info"
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: api-secrets
                  key: database-url
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 15
            periodSeconds: 30

Service:

apiVersion: v1
kind: Service
metadata:
  name: api
  namespace: production
spec:
  selector:
    app: api
  ports:
    - port: 3000
      targetPort: 3000
  type: ClusterIP

Secret (for the database URL):

apiVersion: v1
kind: Secret
metadata:
  name: api-secrets
  namespace: production
type: Opaque
stringData:
  database-url: "postgres://user:pass@db:5432/myapp"

Three things in the Compose file become first-class concerns in Kubernetes: resource limits (ignored in Compose), health check separation (readiness vs liveness), and secret handling (not inline environment variables).

ConfigMaps vs Secrets

Compose treats all environment variables identically. Kubernetes separates non-sensitive configuration (ConfigMap) from sensitive values (Secret). The practical rule: anything you would not check into a public repository goes in a Secret. Everything else can be a ConfigMap.

apiVersion: v1
kind: ConfigMap
metadata:
  name: api-config
  namespace: production
data:
  LOG_LEVEL: "info"
  PORT: "3000"
  NODE_ENV: "production"

Reference both in the Deployment’s envFrom:

envFrom:
  - configMapRef:
      name: api-config
  - secretRef:
      name: api-secrets

Persistent Storage: PVCs vs Bind Mounts

This is where most migrations stall. Compose bind mounts (mapping a host path to a container path) do not translate to Kubernetes, because pods can run on any node. A bind mount to /data on node-1 means nothing when the pod reschedules to node-3.

Kubernetes uses PersistentVolumeClaims (PVCs). The claim describes what you need; the cluster satisfies it from a PersistentVolume (PV), either pre-provisioned or dynamically provisioned via a StorageClass.

# Original Compose bind mount
volumes:
  - ./data/postgres:/var/lib/postgresql/data

Becomes:

# PersistentVolumeClaim
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data
  namespace: production
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: standard
  resources:
    requests:
      storage: 20Gi
# Referenced in a StatefulSet (not a Deployment for stateful services)
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: production
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secrets
                  key: password
          volumeMounts:
            - name: postgres-data
              mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
    - metadata:
        name: postgres-data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: standard
        resources:
          requests:
            storage: 20Gi

Use StatefulSets, not Deployments, for anything with persistent local state (databases, message brokers, anything with a data directory). StatefulSets give stable pod names, stable network identifiers, and ordered scaling and termination.

The access mode matters. ReadWriteOnce means one node can mount the volume for read-write at a time. ReadWriteMany allows multiple nodes to mount the same volume, which requires a shared filesystem like EFS or NFS. Most cloud block storage (EBS, GCE Persistent Disk, Azure Disk) only supports ReadWriteOnce.

Networking Differences

Compose networks are flat. Any service can reach any other service by its service name. Kubernetes networking is more explicit.

Compose conceptKubernetes equivalent
Service name DNS (db, api)ClusterIP Service DNS (db.namespace.svc.cluster.local)
ports mapping to hostNodePort or LoadBalancer Service
networks for isolationNetworkPolicies
Reverse proxy in docker-composeIngress controller
depends_oninitContainers or readiness probes

Within a namespace, services are reachable by their short name: a pod can reach the db Service at db:5432. Cross-namespace requires the full DNS name: db.production.svc.cluster.local.

For exposing services externally, Compose maps ports to the host directly. In Kubernetes, you use an Ingress:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  namespace: production
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: nginx
  rules:
    - host: api.myapp.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api
                port:
                  number: 3000

The Ingress controller (nginx-ingress, Traefik, AWS ALB ingress) handles TLS termination, routing rules, and load balancing across pods. This is a substantial operational improvement over a hand-configured Nginx or Caddy in a Compose stack, but it requires the controller to be installed in the cluster.

depends_on in Compose only delays container start, not actual service readiness. In Kubernetes, use readiness probes to prevent traffic routing to pods that are not ready. For startup ordering (running migrations before the API starts), use init containers:

initContainers:
  - name: run-migrations
    image: myapp/api:1.4.2
    command: ["node", "dist/migrate.js"]
    env:
      - name: DATABASE_URL
        valueFrom:
          secretKeyRef:
            name: api-secrets
            key: database-url

The init container must exit with code 0 before the main container starts.

Migration Strategies

StrategyRiskSpeedRollbackBest for
Big bang cutoverHighFastHardSmall stacks, throwaway environments
Parallel runLowSlowEasyProduction services with SLA requirements
Incremental by serviceMediumMediumPer-serviceMost teams, multiple independent services
Read-only firstLowSlowEasyStateful services with data migration concerns

The incremental approach works for most teams. The pattern:

  1. Deploy the Kubernetes cluster alongside the existing Compose stack
  2. Migrate stateless services first (workers, API servers with no local state)
  3. Point DNS at the Kubernetes Ingress for one service at a time
  4. Migrate stateful services last, with a planned maintenance window for data migration
  5. Decommission the Compose stack after all traffic is verified on Kubernetes

Running both in parallel is straightforward for stateless services. For stateful services (databases), you have two options: run the database on Kubernetes from day one (using the existing Compose database as a replica or backup source) or keep the database outside Kubernetes and only migrate the application layer. Many teams keep managed databases (RDS, Cloud SQL) outside Kubernetes permanently, which is a valid long-term architecture.

Helm for Templating

Raw Kubernetes YAML becomes repetitive across environments. The same Deployment needs different image tags, replica counts, and resource limits in staging vs production. Helm solves this with templating.

A minimal Helm chart structure:

myapp/
  Chart.yaml
  values.yaml
  values-staging.yaml
  values-production.yaml
  templates/
    deployment.yaml
    service.yaml
    ingress.yaml
    configmap.yaml
    secret.yaml
    _helpers.tpl

values.yaml holds defaults; environment-specific files override them:

# values.yaml
image:
  repository: myapp/api
  tag: latest

replicaCount: 1

resources:
  requests:
    cpu: "100m"
    memory: "128Mi"
  limits:
    cpu: "500m"
    memory: "512Mi"

ingress:
  host: api.staging.myapp.com
# values-production.yaml
replicaCount: 3
image:
  tag: "1.4.2"
ingress:
  host: api.myapp.com
resources:
  requests:
    cpu: "500m"
    memory: "256Mi"
  limits:
    cpu: "2000m"
    memory: "1Gi"

Deploy with:

helm upgrade --install myapp ./myapp \
  -f values.yaml \
  -f values-production.yaml \
  --namespace production

Helm is not required. Kustomize (built into kubectl) handles the same problem with overlays instead of templates. Helm has a larger ecosystem of pre-built charts for common dependencies (PostgreSQL, Redis, cert-manager). Kustomize has less complexity and fewer foot-guns. Either is better than maintaining separate YAML files per environment.

Production Considerations

Resource limits are not optional. Compose ignores resource constraints by default. Kubernetes schedules pods based on requests and enforces limits. A pod without limits on a noisy-neighbor node will be OOM-killed. Set requests based on measured baseline consumption and limits at 2-4x requests. Use Vertical Pod Autoscaler in recommendation mode for the first two weeks to get empirical data.

Readiness vs liveness probes have different semantics. A failing readiness probe removes the pod from the Service endpoints (no traffic). A failing liveness probe restarts the pod. A misconfigured liveness probe that fires during normal startup causes restart loops. Set initialDelaySeconds generously (at least 2x your median cold start time). Use separate endpoints: /ready for readiness (checks database connectivity, cache warmup), /healthz for liveness (checks the process is alive and not deadlocked).

Health check endpoints need to be cheap. A health check that runs a database query on every probe interval adds measurable load at scale. Return a cached status or a static response for liveness. Only run real connectivity checks for readiness, and keep them sub-100ms.

Namespace isolation matters. Start with a single namespace per environment (staging, production). Do not put staging and production workloads in the same namespace. NetworkPolicies are easier to reason about and audit when environments are namespace-isolated.

ConfigMap and Secret changes do not automatically reload running pods. Updating a ConfigMap does not restart the Deployment. Use a hash annotation on the Deployment spec to force a rollout when config changes:

spec:
  template:
    metadata:
      annotations:
        checksum/config: "{{ include (print $.Template.BasePath \"/configmap.yaml\") . | sha256sum }}"

In Helm this annotation is recalculated on each upgrade. Without it, a config change silently does nothing until the next deploy.

RBAC from day one. In Compose, every container runs with the same access to host resources. In Kubernetes, create dedicated ServiceAccounts for each workload and only grant the RBAC permissions each service actually needs. This is faster to get right at the start than to retrofit.

Log shipping changes. Compose containers write to stdout; you typically tail logs with docker compose logs. In Kubernetes, stdout/stderr per container is available via kubectl logs, but for production you want a DaemonSet-based log shipper (Fluent Bit, Vector) collecting from all nodes and forwarding to your log aggregator. Plan this before migration, not after.

Realistic Migration Timeline

For a team with ten services, three engineers, and no prior Kubernetes experience:

  • Week 1-2: Cluster setup, networking, ingress controller, cert-manager. Build one staging service end-to-end including CI/CD delivery. Learn the operational model before committing to a full migration.
  • Week 3-4: Migrate all stateless services to staging Kubernetes. Parallel-run production on Compose.
  • Week 5-6: Cut DNS for stateless services to Kubernetes production. Monitor for two weeks.
  • Week 7-8: Plan stateful service migration. Decide whether the database moves to Kubernetes (StatefulSet) or stays managed. Run migration with a maintenance window.
  • Week 9-10: Decommission the Compose stack. Document the runbook for on-call.

Two to three months is realistic for a clean migration. Teams that try to do it in two weeks typically end up with Kubernetes manifests that are copies of their Compose file with pod restarts at 3am.

The goal is not to be running Kubernetes. The goal is reliable, scalable service delivery. The migration succeeds when on-call incidents drop, not when the last Compose file is deleted.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.