Skip to content
docktionary

Kubernetes

Coming from Compose

This isn't a converter -- it's the mental model. A compose.yml and a set of Kubernetes manifests solve the same problem for very different environments, and the gap between them is exactly the part worth understanding before you write a line of YAML.

Why there's no clean 1:1 translation

Compose was built around one assumption Kubernetes doesn't share: everything runs on a single Docker host. A compose service is a container (or a few replicas of one), a network Compose wires up for you, and a volume on that one machine's disk -- three concerns a single YAML block can get away with blurring together because they're all local to the same host anyway.

Kubernetes has to assume the opposite: Pods land on whichever node has room, get rescheduled onto a different node entirely after a crash, and never get to assume local disk survives them. Splitting "a service" into a Deployment and a Service and (maybe) a PersistentVolumeClaim isn't Kubernetes being more verbose for no reason -- it's naming three things that were always separate concerns and just happened to collapse into one in a single-host world.

The mapping, concept by concept

  • A service's container definition → a container inside a Pod template, inside a Deployment. Image, command, env, ports -- this part translates almost directly.
  • build: → nothing. Kubernetes never builds an image; it only ever pulls one that already exists in a registry. Whatever builds that image (buildx, CI) has to run before kubectl apply, not as part of it.
  • ports: → split in two. containerPort on the container documents what it listens on (informational, not enforced); a separate Service is what actually makes that port reachable, from inside or outside the cluster.
  • environment: → env directly for literal values, or a ConfigMap/Secret reference (see config, secrets & storage) for anything that should be managed, versioned, or kept out of the Pod spec itself.
  • Named volumes (db-data:) → a PersistentVolumeClaim, provisioned from a StorageClass. Close conceptually: both outlive the container and get reattached on restart.
  • Bind mounts (.:/app) → no real equivalent, and that's deliberate. A bind mount assumes your source code is sitting on the same machine the container runs on; Kubernetes assumes it isn't. This gap is exactly why "live- reload my laptop's source into a cluster Pod" is its own tooling problem (Skaffold, Tilt, telepresence), not a manifest field.
  • Compose's embedded DNS (a service reaching db by name) → already built in, for free. Every Kubernetes Service gets a stable DNS name cluster-wide the moment it's created -- there's no equivalent of Compose's networks: block to write, because you're not creating an isolated network per project.
  • depends_on (with service_healthy): → nothing directly. Kubernetes doesn't sequence Pod startup across objects at all. The closest tools are a readiness probe (keeps a Pod out of a Service's endpoints until it's actually ready, which stops traffic arriving too early) and an init container (blocks a Pod's own main container from starting until a check passes) -- see troubleshooting for what it looks like when a workload starts before a dependency is ready anyway.
  • healthcheck: → livenessProbe and readinessProbe, but the semantics shift. Compose's healthcheck mainly feeds depends_on; Kubernetes' liveness probe restarts a stuck container, and its readiness probe controls traffic -- two different jobs Compose doesn't distinguish.
  • restart: unless-stopped → a Deployment just always keeps the requested number of replicas running; there's no restart policy to set at that level. The Pod-level restartPolicy (almost always Always for a Deployment) governs the container inside one Pod, not whether the Deployment replaces a dead Pod -- that part is unconditional.
  • profiles: → no direct equivalent. The closest patterns are separate Helm values files or Kustomize overlays per environment, applied as distinct manifests rather than toggled with a flag at deploy time.

A worked example

The web + Postgres + Redis stack from the Compose page, as Kubernetes objects:

web.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: docktionary/app:1.4.0
          ports:
            - containerPort: 3000
          env:
            - name: DATABASE_URL
              value: postgres://app:app@db:5432/app
            - name: REDIS_URL
              value: redis://cache:6379
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
---
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 3000

Postgres becomes a StatefulSet with a headless Service, not a Deployment -- it needs the stable identity and per-replica storage a Deployment doesn't give it:

db.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: db
spec:
  serviceName: db
  replicas: 1
  selector:
    matchLabels:
      app: db
  template:
    metadata:
      labels:
        app: db
    spec:
      containers:
        - name: postgres
          image: postgres:16-alpine
          env:
            - name: POSTGRES_USER
              value: app
            - name: POSTGRES_PASSWORD
              value: app
            - name: POSTGRES_DB
              value: app
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 20Gi
---
apiVersion: v1
kind: Service
metadata:
  name: db
spec:
  clusterIP: None
  selector:
    app: db
  ports:
    - port: 5432

Redis needs neither stable identity nor storage here, so it stays a plain Deployment and Service -- the same shape as web, just without the environment variables:

cache.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cache
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cache
  template:
    metadata:
      labels:
        app: cache
    spec:
      containers:
        - name: redis
          image: redis:7-alpine
          ports:
            - containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
  name: cache
spec:
  selector:
    app: cache
  ports:
    - port: 6379

Notice what didn’t carry over: depends_on with its health condition is gone entirely -- web’s readiness probe is now doing the real work of not taking traffic before it can reach the database, and nothing stops the web Pod from starting before db is ready (it would just fail its readiness checks for a while). One compose file became three objects' worth of YAML, and the credentials that were plain environment: values really shouldn’t stay that way here -- see config, secrets & storage for moving them into a Secret.

On automated converters

Tools like kompose exist and will generate manifests from a compose.yml automatically. They're a reasonable way to get a rough first draft on the screen, but treat the output as a draft, not a destination -- an automated pass has no way to know that your db service wants a StatefulSet instead of a Deployment, that a bind mount needs a completely different dev workflow, or that plaintext credentials belong in a Secret now. Understanding the mapping above is what lets you actually evaluate that output instead of shipping whatever it produced. Either way, run the result through the Kubernetes manifest linter before trusting it.