Skip to content

Limitations

We replaced the perfectly sane orchestra conductor with a hideously mutated chimpanzee sprouting tentacle appendices, and you're surprised the audience needs earplugs.

Kubernetes is an orchestrator. Docker Compose is a list of containers. This document covers what gets lost in translation.

What changes behavior

Things you need to know for the output to work correctly.

Network aliases (nerdctl)

nerdctl requires the flatten-internal-urls transform

Without it, nerdctl compose will silently ignore network aliases and services will fail to reach each other. Add flatten-internal-urls to your depends list — see workarounds below.

helmfile2compose generates Docker Compose networks.default.aliases on each service so that Kubernetes FQDNs (Fully Qualified Domain Names — e.g. svc.ns.svc.cluster.local, svc.ns.svc, svc.ns) resolve natively via compose DNS. This is how inter-service communication works without rewriting hostnames — the FQDNs match certificate SANs, Prometheus targets resolve, Grafana datasources work, everything behaves like it did in K8s.

nerdctl compose does not implement network aliases — it silently ignores the aliases key and does not support the --network-alias flag either. If you run containerd without Kubernetes (Rancher Desktop in containerd mode, Lima, etc.), FQDNs will not resolve unless you use the flatten-internal-urls transform.

Workarounds:

  • Switch to Docker Compose (recommended). Rancher Desktop can use dockerd (moby) as its backend instead of containerd. The switch takes one checkbox and a VM restart.
  • Use Podman Compose — supports networks.<name>.aliases since v1.0.6.
  • Use the flatten-internal-urls transform — strips network aliases entirely and rewrites all FQDNs to short compose service names. nerdctl's built-in DNS resolves short names natively, so everything works. Trade-off: you lose FQDN preservation, which means no inter-service TLS with cert-manager SANs (cert-manager declares incompatibility with this transform). If you don't need backend SSL, this is the zero-friction option.
  • Don't use this project. You already run containerd. You already have a container runtime that speaks to Kubernetes natively. You are one kubeadm init away from having a real cluster. Why are you here? What offering did you bring to the altar of Yog Sa'rath that led you to this page? Go home. Deploy your helmfile on a real cluster. Be free.

The disciple forged a world without masters, without wards, without the sovereign's gaze — and found that the names he had given his servants no longer carried across the void. For in a realm stripped of all authority, even the act of calling out is an unanswered prayer.

Unaussprechlichen Kulten, On Worlds Without Shepherds (one assumes)

Startup ordering

Kubernetes init containers block the main container until they complete. In compose, init containers become separate services with restart: on-failure — they retry until they succeed, but nothing prevents the main container from starting concurrently and crash-looping until its dependencies are ready.

This works in practice (everything eventually converges), but expect noisy logs on first boot. Kubernetes solved this elegantly — init containers block, dependencies are declared, the scheduler respects the order. You chose to leave that behind.

Why not depends_on? nerdctl compose ignores it entirely. Docker compose supports condition: service_completed_successfully, but relying on it would break nerdctl compatibility. Brute force retry works everywhere.

Exception: sidecar containers use depends_on because network_mode: container:<name> needs the parent container to exist. nerdctl compose ignores the directive entirely — the sidecar starts concurrently and usually wins the race (the parent is heavier), but there is no ordering guarantee. If the sidecar starts first, it fails and retries. Same brute-force purgatory, different aisle.

Sidecars and pod-level networking

Sidecar containers (containers[1:]) are converted to separate compose services sharing the main service's network namespace via network_mode: container:<name>. Both containers listen on the same hostname, each on its own port — same as a K8s pod.

Other compose services reach both the main container and its sidecars via the main service name, each on its own port.

Limitation: emptyDir volumes are not shared between the main container and its sidecars (same limitation as init containers — see emptyDir volumes).

emptyDir volumes

K8s emptyDir volumes are shared between containers in the same pod. When init containers and the main container both mount the same emptyDir (e.g. to chmod a directory), compose converts them to anonymous volumes (- /path) which are NOT shared between services.

If an init container needs to prepare data for the main container via a shared volume, the emptyDir must be mapped to a named volume in dekube.yaml manually.

PVC (PersistentVolumeClaim) conversion

Kubernetes PVCs request dynamic storage from a provisioner (Longhorn, Ceph, etc.). helmfile2compose converts them to bind mounts — host directories mapped into the container. Each PVC claim name is registered in dekube.yaml on first run with a default host path under ./data/. volumeClaimTemplates (StatefulSets) are handled the same way.

Secrets

Kubernetes Secrets exist because serious people built a serious system for serious production workloads. RBAC-gated access. Base64 encoding (yes, it's encoding, not encryption — but at least it's something). Encryption at rest in etcd. Audit logs. Pod-level access control. A whole security model designed by people who think about threat vectors for a living.

We take all of that and dump it as plain-text environment variables into a YAML file on your laptop.

The generated compose.yml contains your database passwords, your API keys, your OAuth client secrets — everything — in clear text, because that's what happens when you devolve a production orchestrator into docker compose up. Do not commit it to version control. Not because the heresy wasn't already committed several abstractions ago, but because your colleagues might see what you've done and ask questions you don't want to answer.

TLS between services

In Kubernetes, services can use mTLS (via service mesh or cert-manager) for internal communication. You had encryption between every pod. You had mutual authentication. You had a trust model. In compose, inter-service traffic is plain HTTP on the shared bridge network by default. Only the Caddy reverse proxy terminates TLS for external access.

The cert-manager extension can generate real certificates at conversion time, enabling TLS between services when needed. Caddy backend SSL annotations (haproxy.org/server-ssl, nginx.ingress.kubernetes.io/backend-protocol: HTTPS) are translated to Caddy TLS transport configuration.

Bind mount permissions (Linux / WSL)

Bitnami images (PostgreSQL, Redis, MongoDB) run as non-root (UID 1001) and expect Unix permissions on their data directories. The host directory is typically owned by your user (UID 1000), so the container can't write to it. This causes mkdir: cannot create directory: Permission denied.

This is handled automatically: the built-in fix-permissions transform detects non-root containers (securityContext.runAsUser) with bind-mounted volumes and generates a fix-permissions service that runs chown -R <uid> as root on first startup. No manual intervention needed.

Hostname length

Linux hostnames are limited to 63 characters. Compose uses the service name as the container hostname. Services with names longer than 63 characters automatically get a truncated hostname: to avoid sethostname: invalid argument failures.

What is ignored

Safe to skip — but only because you already abandoned the cluster that would enforce them. These affect operational behavior, not what the application does.

Scaling and replicas

Compose runs one instance of each service. HPA, replica counts (other than 0, which auto-skips the workload), and PodDisruptionBudgets are ignored. DaemonSets are converted as regular services (one instance, no node affinity or scheduling). This is a single-machine tool.

Resource limits

CPU/memory requests and limits are ignored. Compose supports mem_limit / cpus, but translating K8s resource semantics (requests vs limits, burstable QoS) into compose constraints is more misleading than helpful.

Probes and healthchecks

Liveness, readiness, and startup probes are not converted to compose healthcheck. The semantics differ enough that a blind translation would cause more problems than it solves (compose healthcheck only affects depends_on with condition: service_healthy, which we don't use anyway).

Network isolation

Kubernetes namespaces and NetworkPolicies provide network isolation between services. In compose, all services share a single bridge network. Everything can talk to everything. Your database is one DNS lookup away from your frontend. The NetworkPolicies you carefully crafted? Gone. Welcome to the flat network — it's like 2005 again, but with more YAML.

fieldRef (downward API)

Environment variables using fieldRef are partially supported:

  • status.podIP — resolved to the compose service name (the container's DNS address in compose).
  • Other fieldRef values (metadata.name, metadata.namespace, etc.) are skipped with a warning. There is no compose equivalent for most of these.

What is not converted

Not supported, no workaround.

CronJobs

Not converted. A CronJob would need an external scheduler or a sleep-loop wrapper, neither of which is a good idea.

CRDs (Custom Resource Definitions)

Operator-managed resources (Keycloak, KeycloakRealmImport, Zalando postgresql, Strimzi Kafka, etc.) are skipped with a warning unless a loaded extension handles them.

Extensions can be loaded via --extensions-dir or installed with dekube-manager. See the extension catalogue for available extensions.

Longhorn

Don't even think about it.