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.
containerPorton 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: →
envdirectly for literal values, or aConfigMap/Secretreference (see config, secrets & storage) for anything that should be managed, versioned, or kept out of the Pod spec itself. - Named volumes (
db-data:) → aPersistentVolumeClaim, provisioned from aStorageClass. 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
dbby 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'snetworks: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: →
livenessProbeandreadinessProbe, but the semantics shift. Compose's healthcheck mainly feedsdepends_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 alwaysAlwaysfor 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:
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: 3000Postgres 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:
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: 5432Redis 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:
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: 6379Notice 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.