What Actually Happens When You Convert docker-compose to Kubernetes

kubernetesdocker

docker-compose and Kubernetes both run containers, but compose describes a single machine’s setup while Kubernetes describes a distributed system — that gap is exactly where a mechanical conversion needs to be honest about what it can’t do, rather than papering over it with something that looks right but silently doesn’t work the same way.

services:
  web:
    image: nginx:latest
    ports:
      - "8080:80"
    environment:
      - NODE_ENV=production
    volumes:
      - ./html:/usr/share/nginx/html
    depends_on:
      - db

  db:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"
    volumes:
      - db-data:/var/lib/postgresql/data

The straightforward part: Deployment + Service

Each service becomes a Deployment (running the container) and a Service (exposing its ports to other pods and, if needed, outside the cluster):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: db
spec:
  replicas: 1
  selector:
    matchLabels:
      app: db
  template:
    metadata:
      labels:
        app: db
    spec:
      containers:
        - name: db
          image: postgres:15
          ports:
            - containerPort: 5432
              protocol: TCP
          env:
            - name: POSTGRES_PASSWORD
              value: secret
---
apiVersion: v1
kind: Service
metadata:
  name: db
spec:
  selector:
    app: db
  ports:
    - port: 5432
      targetPort: 5432
      protocol: TCP

Environment variables, port mappings, and deploy.replicas all translate directly — this part of the conversion is genuinely mechanical and safe to trust.

Named volumes get a real PersistentVolumeClaim

A named volume (db-data:/var/lib/postgresql/data) generates its own PersistentVolumeClaim alongside the Deployment:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: db-data
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi

The 1Gi is a placeholder — resize it to what the volume actually needs. This is the portable, cluster-friendly equivalent of a compose named volume, and it’s the one storage conversion here that’s safe to use as-is beyond local testing.

Three things that don’t have a direct equivalent

Bind mounts (./html:/usr/share/nginx/html) become a hostPath volume — but hostPath only works if that exact path exists on whichever node the pod happens to land on. On a single-node setup (minikube, a local kind cluster) that’s fine. On any real multi-node cluster, it silently breaks the moment the scheduler picks a different node. The generated output flags this with a comment rather than pretending hostPath is a safe general-purpose answer — if you need this beyond local testing, that data belongs in a PersistentVolumeClaim instead.

Build contexts. A service with build: ./app and no image gets skipped entirely, with a comment explaining why: Kubernetes only runs pre-built images pulled from a registry — it has no mechanism to build an image from a Dockerfile the way docker-compose up --build does. You need a separate build-and-push step (a CI pipeline, docker build + docker push) before that service can have a real manifest at all.

depends_on. Compose uses this to control startup order; Kubernetes has no direct equivalent; Deployments start independently of each other with no ordering guarantee. The generated output leaves a comment naming the dependency rather than fabricating an initContainer block that might not actually implement the wait condition you need — the real fix is a readiness probe on the dependency and/or an initContainer that polls for it, both of which require knowing specifically what “ready” means for that service (a TCP port open? a health endpoint returning 200?), which the compose file doesn’t say.

The pattern

Everything with a direct, unambiguous Kubernetes equivalent (ports, env vars, replicas, named-volume storage) converts automatically. Everything without one — build steps, host-specific paths, startup ordering — gets a comment naming the gap instead of a manifest that looks complete but quietly doesn’t do what compose did. Runs entirely in your browser; nothing you paste is uploaded anywhere.