Skip to content
Go back

Compose-to-Helm Migration That Doesn't Break

By SumGuy 16 min read
Compose-to-Helm Migration That Doesn't Break
Contents

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:

  1. Kompose: auto-converts Compose to Kubernetes objects and gets you roughly half way
  2. Manual cleanup: for the other half, which is the half that matters
  3. 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:

Terminal window
# macOS
brew install kompose
# Linux
curl -L https://github.com/kubernetes/kompose/releases/download/v1.38.0/kompose-linux-amd64 -o kompose
chmod +x kompose
sudo mv kompose /usr/local/bin/

Verify:

Terminal window
kompose version

Now let’s say you’ve got a Compose file like this:

docker-compose.yml
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_password

Run kompose:

Terminal window
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 ignored
WARN Restart policy 'unless-stopped' in service api is not supported, convert it to 'always'

Then look at what landed in helm-output/:

Terminal window
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.yaml

Kompose 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:

Terminal window
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.tpl

Start by scaffolding a chart:

Terminal window
helm create myapp-helm

This 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:

Terminal window
find myapp-helm/templates -type f ! -name '_helpers.tpl' -delete
rmdir myapp-helm/templates/tests

Now copy the kompose-generated manifests into templates/:

Terminal window
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:

values.yaml
# Global
namespace: default
imagePullPolicy: IfNotPresent
# PostgreSQL
postgres:
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
# Redis
redis:
enabled: true
image:
repository: redis
tag: "8-alpine"
service:
type: ClusterIP
port: 6379
persistence:
enabled: true
size: 5Gi
# API
api:
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: false

Declare 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:

  1. Replace hardcoded values with {{ .Values.path.to.value }}
  2. Replace service names with {{ include "myapp-helm.fullname" . }}
  3. Add conditional blocks for optional services

Example: postgres-deployment.yaml becomes a template:

templates/postgres-deployment.yaml
{{- if .Values.postgres.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp-helm.fullname" . }}-postgres
labels:
{{- include "myapp-helm.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
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:

.gitlab-ci.yml
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
fi

Check 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:

  1. Deploy Helm to the cluster
  2. Keep Compose running on a separate host (or VM)
  3. Both serve traffic (via DNS failover, load balancer, whatever)
  4. When Helm is stable, kill Compose

Why Keep Both Alive?

Sounds redundant. But consider:

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:

  1. 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).
  2. Move the API to Helm. Point it at the Compose-hosted database. Split traffic and watch it.
  3. 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: url

2. 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:

templates/api-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "myapp-helm.fullname" . }}-api-config
data:
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: 512Mi

4. Network Policies

Kompose won’t create these. If you need them (and you probably do in prod), define them:

templates/network-policy.yaml
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "myapp-helm.fullname" . }}-default-deny
spec:
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:

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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Kustomize Without Helm: When Overlays Win
Next Post
Ingress Choices in k3s: Traefik vs ingress-nginx vs HAProxy

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts