You’ve got a Docker Compose setup that’s working. Ships are sailing, lights are green, everyone’s happy. Then someone whispers “Kubernetes” and suddenly you’re facing a rewrite that feels like it’ll take until 2028.
You don’t have to torch your Compose files. Not immediately, anyway. You can run both in parallel, keep the Compose setup as a safety net, and migrate piece by piece. It’s not the “pure” way, but it’s the way that lets you sleep at night.
The Bridge Strategy
Most migrations fail because they’re binary. You go all-in on Helm, something breaks in production, and you’re scrambling to debug a totally new deployment model while your services are down. Stupid.
Instead: run Helm and Compose in parallel for as long as you need. Move services to Helm one at a time. Keep Compose around as a rollback plan. When you’re confident everything works in the cluster, then you delete the Compose files. Maybe that’s next week, maybe that’s six months. Doesn’t matter.
This approach uses three tools:
- Kompose: auto-converts Compose to Kubernetes objects and gets you roughly half way
- Manual cleanup: for the other half, which is the half that matters
- Hybrid CI: separate pipelines for Compose and Helm, controlled by config
One warning before anything else, because it shapes the whole plan: the parallel period applies to stateless services only. Two copies of Postgres, one in Compose and one in the cluster, each with its own volume, are two different databases. Serve traffic to both and you get two diverging datasets and no way to reconcile them. Move the stateless tier first and keep exactly one writable copy of every stateful service for the entire migration.
Starting with Kompose
Kompose is a standalone CLI tool that reads Docker Compose and barfs out Kubernetes manifests. It’s not magic. The output is rough. But it’s a foundation.
Install kompose:
# macOSbrew install kompose
# Linuxcurl -L https://github.com/kubernetes/kompose/releases/download/v1.38.0/kompose-linux-amd64 -o komposechmod +x komposesudo mv kompose /usr/local/bin/Verify:
kompose versionNow let’s say you’ve got a Compose file like this:
services: postgres: image: postgres:18-alpine environment: POSTGRES_DB: myapp POSTGRES_PASSWORD_FILE: /run/secrets/db_password volumes: - db_data:/var/lib/postgresql/data secrets: - db_password ports: - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 5s retries: 5
api: build: ./api environment: DATABASE_URL: postgres://postgres:${DB_PASSWORD}@postgres:5432/myapp LOG_LEVEL: info depends_on: postgres: condition: service_healthy ports: - "3000:3000" restart: unless-stopped
cache: image: redis:8-alpine ports: - "6379:6379" volumes: - cache_data:/data
volumes: db_data: cache_data:
secrets: db_password: file: .secrets/db_passwordRun kompose:
kompose convert -f docker-compose.yml -o helm-output/Read the warnings it prints, they are the interesting part:
WARN The "DB_PASSWORD" variable is not set. Defaulting to a blank string.WARN the attribute `version` is obsolete, it will be ignoredWARN Restart policy 'unless-stopped' in service api is not supported, convert it to 'always'Then look at what landed in helm-output/:
ls helm-output/# api-deployment.yaml# api-service.yaml# cache-data-persistentvolumeclaim.yaml# cache-deployment.yaml# cache-service.yaml# db-data-persistentvolumeclaim.yaml# db-password-secret.yaml# postgres-deployment.yaml# postgres-service.yamlKompose did more than people give it credit for. It turned each named volume into a PVC, and it converted the Compose secrets: block into a real Kubernetes Secret, mounted it at /run/secrets, and rewrote POSTGRES_PASSWORD_FILE to the hyphenated key name it generated (/run/secrets/db-password). That last rewrite is easy to miss and easy to break later.
Kompose can also emit a chart directly:
kompose convert -f docker-compose.yml -c -o myapp-helm/That -c produces Chart.yaml, a README.md, and every manifest under templates/. What it does not produce is a values.yaml or a single {{ }} anywhere. It is a chart in directory layout only. Parameterizing it is still your job, which is the work the rest of this post is about.
Converting to a Proper Helm Chart
Kompose gives you flat manifests. Helm wants a chart directory with templates. Here’s the layout you’re building toward:
myapp-helm/ Chart.yaml values.yaml templates/ postgres-pvc.yaml postgres-deployment.yaml postgres-service.yaml postgres-secret.yaml api-deployment.yaml api-service.yaml redis-deployment.yaml redis-service.yaml _helpers.tplStart by scaffolding a chart:
helm create myapp-helmThis gives you boilerplate. Clear out the sample templates, but do not run rm myapp-helm/templates/*. That command does two things you will regret. It deletes _helpers.tpl, which defines every include your templates are about to call, and it cannot touch templates/tests/, so test-connection.yaml survives, still referencing the helpers you just deleted. Your first helm template dies with no template "myapp-helm.fullname" associated with template "gotpl".
Keep the helpers, drop everything else:
find myapp-helm/templates -type f ! -name '_helpers.tpl' -deletermdir myapp-helm/templates/testsNow copy the kompose-generated manifests into templates/:
cp helm-output/*.yaml myapp-helm/templates/One more naming detail that trips people up: helm create myapp-helm names its helpers after the chart, so they are myapp-helm.fullname, myapp-helm.labels, and myapp-helm.selectorLabels. Not myapp.*. Use the chart name in every include or nothing renders.
Building values.yaml
Next step: parameterize everything. Create a values.yaml:
# Globalnamespace: defaultimagePullPolicy: IfNotPresent
# PostgreSQLpostgres: enabled: true image: repository: postgres tag: "18-alpine" database: myapp username: postgres # In real life: use a secret or external vault passwordSecretName: postgres-secret passwordSecretKey: password persistence: enabled: true size: 10Gi storageClassName: standard service: type: ClusterIP port: 5432
# Redisredis: enabled: true image: repository: redis tag: "8-alpine" service: type: ClusterIP port: 6379 persistence: enabled: true size: 5Gi
# APIapi: enabled: true replicaCount: 2 image: repository: myregistry/myapp-api tag: "latest" pullPolicy: IfNotPresent service: type: LoadBalancer port: 80 targetPort: 3000 env: LOG_LEVEL: info # Database URL is built from postgres values in templates resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi
# Optional extras. Declare them even when off.networkPolicy: enabled: falseDeclare every key a template guards on, including the ones you leave false. Helm does not treat a missing key as false. A template opening with {{- if .Values.networkPolicy.enabled }} when networkPolicy is absent from values.yaml fails the whole render with nil pointer evaluating interface {}.enabled. If you would rather not carry the stub, parenthesize the parent instead: {{- if (.Values.networkPolicy).enabled }} returns nil safely.
Templating the Manifests
Convert each kompose-output YAML into a template by:
- Replace hardcoded values with
{{ .Values.path.to.value }} - Replace service names with
{{ include "myapp-helm.fullname" . }} - Add conditional blocks for optional services
Example: postgres-deployment.yaml becomes a template:
{{- if .Values.postgres.enabled }}apiVersion: apps/v1kind: Deploymentmetadata: name: {{ include "myapp-helm.fullname" . }}-postgres labels: {{- include "myapp-helm.labels" . | nindent 4 }} app.kubernetes.io/component: databasespec: replicas: 1 selector: matchLabels: {{- include "myapp-helm.selectorLabels" . | nindent 6 }} app.kubernetes.io/component: database template: metadata: labels: {{- include "myapp-helm.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: database spec: containers: - name: postgres image: "{{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}" imagePullPolicy: {{ .Values.imagePullPolicy }} ports: - containerPort: {{ .Values.postgres.service.port }} name: postgres env: - name: POSTGRES_DB value: {{ .Values.postgres.database | quote }} - name: POSTGRES_USER value: {{ .Values.postgres.username | quote }} - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: {{ .Values.postgres.passwordSecretName }} key: {{ .Values.postgres.passwordSecretKey }} livenessProbe: exec: command: - /bin/sh - -c - pg_isready -U {{ .Values.postgres.username }} initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: exec: command: - /bin/sh - -c - pg_isready -U {{ .Values.postgres.username }} initialDelaySeconds: 5 periodSeconds: 5 volumeMounts: - name: postgres-storage mountPath: /var/lib/postgresql/data subPath: postgres resources: requests: cpu: 250m memory: 256Mi limits: cpu: 1000m memory: 1Gi volumes: - name: postgres-storage persistentVolumeClaim: claimName: {{ include "myapp-helm.fullname" . }}-postgres-pvc{{- end }}The {{- if .Values.postgres.enabled }} block means: only deploy Postgres if values.yaml says postgres.enabled: true. This is your safety valve: disable services in Helm while Compose still runs them.
Create similar templates for API and Redis. The pattern is: take kompose output, wrap conditionals, inject {{ .Values.* }}.
The Hybrid CI Pipeline
This is where your CI actually needs to support both:
stages: - build - deploy-compose - deploy-helm - verify
variables: # Both true during the parallel period. Flip COMPOSE_ENABLED to # "false" once the cluster has earned it. COMPOSE_ENABLED: "true" HELM_ENABLED: "true"
build-image: stage: build script: - docker build -t myregistry/myapp-api:${CI_COMMIT_SHA} ./api - docker push myregistry/myapp-api:${CI_COMMIT_SHA}
deploy-compose: stage: deploy-compose only: - main when: manual # Only run if someone clicks it script: - | if [ "$COMPOSE_ENABLED" = "true" ]; then docker compose -f docker-compose.yml up -d else echo "Compose deployment disabled. Using Helm." fi
deploy-helm: stage: deploy-helm only: - main script: - | if [ "$HELM_ENABLED" = "true" ]; then helm repo add myrepo https://charts.example.com helm repo update helm upgrade --install myapp ./myapp-helm \ --namespace production \ --create-namespace \ --values values-prod.yaml else echo "Helm deployment disabled. Using Compose." fi
verify-deployment: stage: verify script: - | if [ "$HELM_ENABLED" = "true" ]; then kubectl rollout status deployment/myapp-myapp-helm-api \ -n production --timeout=5m kubectl get pods -n production fiCheck that Deployment name against your own chart before you copy it. Helm’s fullname helper is <release>-<chart>, truncated at 63 characters, and it collapses to just <release> when the release name already contains the chart name. Release myapp with chart myapp-helm and a -api suffix gives you myapp-myapp-helm-api, not myapp-api. Run helm template myapp ./myapp-helm | grep -A1 "kind: Deployment" and read the real names off the output rather than guessing, or the verify job passes by never finding anything to wait on.
The key insight: each CI job checks the environment variable. During migration, you can run both:
- Deploy Helm to the cluster
- Keep Compose running on a separate host (or VM)
- Both serve traffic (via DNS failover, load balancer, whatever)
- When Helm is stable, kill Compose
Why Keep Both Alive?
Sounds redundant. But consider:
- Helm breaks in a subtle way (RBAC, networking, storage class). You cut traffic back to Compose while you fix it.
- Your team isn’t Kubernetes experts yet. Compose is familiar. Helm is the “learning” system.
- You catch issues early without risking prod. Split traffic between the two stacks, say 20% to the cluster at first, and walk it up as the cluster proves itself.
- No “big bang”. You migrate services one by one, not everything at once.
Now the part that decides whether this works or ruins your weekend: which services can actually run in both places.
Stateless ones can. Your API is a request handler with no local disk, so two copies behind a load balancer is just horizontal scaling with a weird deployment story.
Stateful ones cannot. The Compose Postgres writes to a Docker volume, the Helm Postgres writes to a PVC, and nothing connects them. Serve traffic to both and you get two databases drifting apart from the first write, with no merge path back. Same for Redis if you use it for sessions or anything you would miss.
So the order is the reverse of what feels natural. Do not migrate Postgres first. Instead:
- Leave Postgres and Redis in Compose, running exactly once, and expose them to the cluster (an ExternalName Service, or a headless Service plus a manual Endpoints object).
- Move the API to Helm. Point it at the Compose-hosted database. Split traffic and watch it.
- When the stateless tier is boring, move the data tier in a single scheduled cutover: stop writes, dump, restore into the PVC, repoint, start writes.
Step 3 is the only step with downtime, and it should be the only one. “Roll Compose back in 30 seconds” holds for the API. It never holds for a database that has been taking writes in the cluster.
The Gotchas Kompose Doesn’t Handle
Kompose output is a rough draft. Here’s what you’ll fix manually.
0. The Three That Bite Immediately
Fix these before anything else, because they are silent.
build: becomes a fake image reference. Kompose cannot build anything, so build: ./api turns into image: api in the Deployment. That resolves to docker.io/library/api:latest, which is not your code. Point it at your registry and a real tag:
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"Shell interpolation gets baked in as plaintext. ${DB_PASSWORD} in the Compose file is resolved at convert time, against whatever environment the conversion ran in. Unset means empty, and kompose writes value: postgres://postgres:@postgres:5432/myapp straight into the Deployment. Set means your real password is now a literal string in a YAML file headed for git. Replace every interpolated value with a secretKeyRef before you commit.
Healthcheck commands convert into something unrunnable. Compose test: ["CMD-SHELL", "pg_isready -U postgres"] becomes an exec probe whose command array holds a single element: pg_isready -U postgres. Exec probes do not go through a shell, so kubelet looks for a binary with a space and two flags in its name, never finds it, and the probe fails forever. Split it yourself:
livenessProbe: exec: command: ["/bin/sh", "-c", "pg_isready -U postgres"]Also note kompose downgrades restart: unless-stopped to restartPolicy: Always and tells you so in a warning. That is the closest Kubernetes equivalent, so it is fine, but it means a container you intended to stay stopped will come back.
1. Init Containers (Database Migrations)
Kompose won’t auto-create these. Add them in your template:
initContainers:- name: db-migrate image: myregistry/myapp-api:{{ .Values.api.image.tag }} command: ["python", "manage.py", "migrate"] env: - name: DATABASE_URL valueFrom: secretKeyRef: name: api-db-url key: url2. ConfigMaps for Non-Secret Config
Kompose inlines every environment: entry as a literal env var on the container. It does generate a Secret from a Compose secrets: block, but plain config never becomes a ConfigMap. Pull the non-secret values out into one:
apiVersion: v1kind: ConfigMapmetadata: name: {{ include "myapp-helm.fullname" . }}-api-configdata: LOG_LEVEL: {{ .Values.api.env.LOG_LEVEL | quote }} API_TIMEOUT: "30s"3. Resource Requests/Limits
Kompose emits containers with no resources: block at all unless your Compose file carries a deploy.resources section. Unbounded pods are how one runaway service evicts everything else on a node. Add them to match your actual workload:
resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi4. Network Policies
Kompose won’t create these. If you need them (and you probably do in prod), define them:
{{- if .Values.networkPolicy.enabled }}apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: {{ include "myapp-helm.fullname" . }}-default-denyspec: podSelector: {} policyTypes: - Ingress - Egress egress: # Without this, DNS dies and every service name stops resolving. - to: - namespaceSelector: {} podSelector: matchLabels: k8s-app: kube-dns ports: - protocol: UDP port: 53 - protocol: TCP port: 53{{- end }}That DNS exception is not optional. A default-deny policy with podSelector: {} and no rules blocks every packet in and out of every pod in the namespace, CoreDNS lookups included. The pods stay Running, the probes fail, and the logs fill with name resolution errors that look nothing like a firewall problem. Add the DNS egress rule in the same commit as the deny, then open the specific paths you need (API to Postgres on 5432, API to Redis on 6379) one policy at a time.
Also confirm something is actually enforcing the policy, because a NetworkPolicy nobody implements applies cleanly and does nothing. A stock k3s enforces it: the server runs a network policy controller unless you started it with --disable-network-policy. If you swapped Flannel out for a custom CNI, though, the k3s docs tell you to pass --disable-network-policy and let the new CNI handle it, so check that you actually enabled policy enforcement in Calico or Cilium when you did.
Decision Time: When to Fully Flip
You can run this hybrid setup indefinitely, but eventually you want to retire Compose. Here’s a rough checklist:
- Helm deploys without manual intervention: CI runs, everything comes up
- All health checks pass: readiness, liveness probes are green
- One week of stable Helm in production: no rollbacks, no firefighting
- Team is confident: developers can troubleshoot Helm issues without senior help
- Backups and disaster recovery tested: you can restore from Helm charts + PVCs
- Monitoring and alerting work: you know what’s broken before users do
Once those are done, set HELM_ENABLED: true and COMPOSE_ENABLED: false in CI. Keep the Compose files in git (in case you need them), but stop deploying them.
The Real Win
You didn’t sacrifice stability for modernization. You didn’t rewrite everything at once. You didn’t spend three months porting Compose to Helm and then discover it’s all broken in production.
You moved at the pace your team could handle. Your database is still up. Your API is still running. Your cache is still caching. And one service at a time, you’re sliding into Kubernetes without the crash landing.
That’s the migration that doesn’t break.
Common Questions
Can kompose generate a Helm chart directly?
Yes. kompose convert -c writes Chart.yaml, a README.md, and every manifest under templates/. It stops there: no values.yaml, no template expressions, no conditionals. You get a chart-shaped directory of static YAML. Parameterizing it is manual work either way, so the flag mainly saves you the mkdir.
How long should I run Compose and Helm in parallel?
Long enough to see a full traffic cycle in the cluster, which usually means one to four weeks. Cost is duplicated compute plus the discipline to change both stacks together. The real deadline is drift: once someone patches Compose and forgets Helm, the safety net becomes a liability. Set an end date when you start.
Do I need a container registry for this?
Yes, if any Compose service uses build:. Kubernetes never builds images, so kompose converts build: ./api into a bare image: api that resolves to Docker Hub and fails to pull. Your CI has to build, tag, and push to a registry the cluster can reach before Helm can deploy anything.
What happens to my Compose named volumes?
Kompose turns each one into a ReadWriteOnce PersistentVolumeClaim requesting 100Mi, and it copies no data. Resize every claim before you apply, because 100Mi fills fast and many storage classes will not expand in place. Moving the bytes is a separate step: dump from the Compose volume, restore into a pod mounting the new PVC, then verify before you cut traffic over.
Is Helm 4 different enough to matter here?
Not for this workflow. helm create, helm template, and helm upgrade --install behave the same, and the helper names in the generated _helpers.tpl are unchanged. Pin whichever major your CI image already ships and stay on it for the migration rather than changing two variables at once.