How the conversion works¶
Don't misunderstand me. Converting Kubernetes manifests to Docker Compose is an act of heresy. I know it, and that makes it worse.
But you're here, and you want to know what happened to your resources when they went through the tentacle machine. Each section shows what goes in, what comes out, and why it looks like that. For the full architectural deep dive, see the engine docs. For what gets lost in translation, see limitations.
If your source is a Helm chart rather than raw manifests, the homelab Helm-chart walkthrough shows the same conversion end-to-end with a real chart, with no cluster in the loop.
"To read the old hymns in the new tongue is not blasphemy — it is archaeology. Every verse that survived the translation reveals what mattered; every verse that was lost reveals what never did."
— Necronomicon, On the Archaeology of Manifests (allegedly)
Workloads¶
Deployments, StatefulSets, DaemonSets, bare Pods — they all become Compose services. The distinction between them only matters when you have a scheduler, multiple nodes, and opinions about pod identity. You have none of these.
# What goes in
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 3
template:
spec:
containers:
- name: myapp
image: myapp:1.2.0
ports:
- containerPort: 8080
env:
- name: DATABASE_URL
value: "postgres://db:5432/myapp"
# What comes out
services:
myapp:
image: myapp:1.2.0
ports:
- "8080:8080"
environment:
DATABASE_URL: "postgres://db:5432/myapp"
Replicas are dropped. You're on one machine. Jobs get restart: on-failure so they run once and stop. Resource limits (cpu, memory) are translated to deploy.resources.limits (a null limit is dropped). Readiness/liveness probes become healthcheck entries (exec, httpGet via wget then curl then a bash /dev/tcp request, tcpSocket via nc -z with a bash /dev/tcp fallback — an image with none of those tools, distroless for one, fails its healthcheck; override it). nerdctl ignores both — Docker Compose enforces them.
When both env and envFrom set a variable, env wins, and among envFrom sources the last one wins — Kubernetes' order. $(VAR) references in command, args and env values are expanded the way kubelet does ($$(VAR) stays a literal $(VAR)), and every other $ in the command is escaped so compose hands it to the container untouched.
Services of type NodePort publish their nodePort, LoadBalancer ones their port; UDP ports keep /udp, and SCTP (which compose can't do) is published as TCP with a warning.
ConfigMaps¶
Two paths depending on how they're consumed.
As env vars (envFrom or configMapKeyRef) — inlined directly into the service. The ConfigMap disappears, its values live in environment:.
# Compose output (if referenced via envFrom)
services:
myapp:
environment:
APP_ENV: "production"
LOG_LEVEL: "info"
As files (volume mount) — written to disk and bind-mounted. This is how config files like nginx.conf or application.yaml typically travel. Both data (text) and binaryData (base64-encoded binary, e.g. keystores, protobuf) are supported. A mount that lists items gets its own configmaps/<name>_<hash>/ directory with just those keys. File modes follow defaultMode / items[].mode (default 0644) — so a 0755 script is executable — but owner read-write and group/other read are always kept: the files belong to your host user, and a 0400 would lock out a container running as anyone else.
Everything is inlined into compose.yml rather than using env_file: — what you see is what you get.
Secrets¶
Same mechanics as ConfigMaps. Mounted Secret files hold the decoded bytes, so a keystore or DER certificate arrives intact; a binary value referenced as an env var can't be expressed in compose and is skipped with a warning. envFrom includes stringData keys. The interesting case is when a Secret doesn't exist in the rendered output — an operator was supposed to create it, or Helm's lookup tried to fetch it from a cluster that isn't there. When this happens, the engine logs a warning to stderr (secretKeyRef '<name>/<key>' on <workload> could not be resolved) and drops the env var entirely — no placeholder, no value. Grep your conversion output for could not be resolved to catch these.
Your secrets are now plain text in a YAML file. This is what you signed up for. See limitations — secrets for the full existential crisis.
Services¶
Kubernetes Services give pods stable DNS names. In Compose there's no kube-proxy, but containers still need to find each other. The solution: network aliases matching the full Kubernetes FQDN hierarchy.
# Compose output
services:
myapp:
networks:
default:
aliases:
- myapp
- myapp.default
- myapp.default.svc
- myapp.default.svc.cluster.local
If a ConfigMap has DATABASE_HOST=postgres.default.svc.cluster.local, it still resolves. No rewriting needed. Compose DNS does the rest. This is an intentional design choice with a long and painful backstory.
Ingress¶
Ingress resources become static reverse proxy rules. Both distributions use Caddy as the default reverse proxy (the ingress provider), though an Nginx provider is also available. Controller-specific annotations (HAProxy, Nginx, Traefik) are read by rewriter extensions that translate them into provider-agnostic ingress entries — see rewriter extensions.
host: myapp.example.com routing to port 8080 → a reverse proxy rule proxying that hostname to the Compose service. TLS via the provider's CA (Caddy internal CA, certbot, self-signed, or user-provided certs). Controller-specific annotations (rate limiting, redirects, custom headers) are translated when they have a Compose equivalent, skipped otherwise.
Your hostnames need to resolve locally — *.localhost works out of the box on most systems, anything else needs /etc/hosts.
PVCs¶
PVCs become bind mounts under ./data/. No CSI driver, no dynamic provisioning — just a directory.
Paths are customizable in dekube.yaml. StatefulSet volumeClaimTemplates get the same treatment, registered under the key <vct>-<sts> (the same naming Kubernetes itself uses, minus the ordinal — compose runs one replica). An existing config with the older bare <vct> key still resolves, with a warning to rename it.
A subPath mount becomes ./data/<pvc>/<subPath>. If an older conversion already put data at the volume root and the subdirectory doesn't exist, the whole volume stays mounted and a warning tells you where to move the data. Volume types with no compose equivalent (hostPath, projected, downwardAPI, csi…) are dropped with a warning — map them yourself in overrides:.
Init containers¶
Each init container becomes a separate Compose service with restart: on-failure. The main service declares depends_on with condition: service_completed_successfully, so Docker Compose starts it only after init containers complete. nerdctl ignores depends_on — there, everything runs concurrently and converges via retries. See limitations — startup ordering for details.
Native sidecars (initContainers with restartPolicy: Always, Kubernetes ≥ 1.28) are the exception: they don't block the main container, so they're converted like a sidecar instead — their own service, network_mode: container:<main>, with depends_on pointing at them rather than the main waiting on them. Known limit: an ordinary one-shot init container can't reach a native sidecar, since the sidecar only joins the main container's network namespace once the main container itself starts.
Sidecars¶
Sidecar containers share a network namespace in Kubernetes — they talk over localhost. In Compose, they use network_mode: container:<main> to achieve the same thing. This mostly works, except when it doesn't — see limitations — sidecars.
CRDs¶
CRDs are resources that only exist because an operator is watching them. In Compose, there are no operators — so extensions emulate what the operator would have done. None of these are bundled in this distribution; grab them via --extensions-dir (see Getting started):
- cert-manager — generates self-signed certificates as files
- Keycloak — converts realm imports into container configuration
- ServiceMonitor — a Prometheus service with a static scrape config generated from the ServiceMonitors
Unknown CRDs are skipped with a warning. Need one handled? That's what third-party extensions are for. The engine's three-tier model explains what can and can't be emulated.
What gets skipped¶
HPA, RBAC, NetworkPolicy, PDB, CronJob — anything that only makes sense with a control plane. These are logged and don't affect the output. The full list explains the rationale for each.
"Not all scripture survives the translation. The rites of horizontal scaling, the prayers of admission control, the litanies of role-based access — these were left behind, for the lesser temple has no use for gods it cannot host."
— De Vermis Mysteriis, On the Acceptable Losses of Conversion (accounts vary)