Skip to content
Go back

Compose-to-Helm Migration That Doesn't Break

By SumGuy 9 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 → Helm (saves about 40% of the typing)
  2. Manual cleanup: because kompose gets ~60% of the way there
  3. Hybrid CI: separate pipelines for Compose and Helm, controlled by config

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
version: "3.8"
services:
postgres:
image: postgres:16-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:7-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/

Kompose spits out YAML files into helm-output/. Here’s what you get:

Terminal window
ls -la helm-output/
# api-service.yaml
# cache-deployment.yaml
# cache-service.yaml
# docker-compose.yaml (copy of original)
# kustomization.yaml
# postgres-deployment.yaml
# postgres-service.yaml
# postgres-pvc.yaml

This is great starting material. It’s not production-ready, but it’s 80% of the way there. The problem is: kompose makes individual manifests, not a Helm chart. If you want a proper chart structure (values, templates, chart.yaml), you need to restructure it.

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. Delete the default templates and keep the structure:

Terminal window
rm myapp-helm/templates/*

Now copy the kompose-generated manifests into templates/:

Terminal window
cp helm-output/*.yaml myapp-helm/templates/

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: "16-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: "7-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

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.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.fullname" . }}-postgres
labels:
{{- include "myapp.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
replicas: 1
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: database
template:
metadata:
labels:
{{- include "myapp.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.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 (or similar)
stages:
- build
- deploy-compose
- deploy-helm
- verify
variables:
COMPOSE_ENABLED: "false"
HELM_ENABLED: "true"
# Set COMPOSE_ENABLED: "true" for legacy deployments
# Set HELM_ENABLED: "true" for Kubernetes deployments
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-api -n production --timeout=5m
kubectl get pods -n production
fi

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:

The Gotchas Kompose Doesn’t Handle

Kompose output is a rough draft. Here’s what you’ll fix manually:

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 treats everything as an env var. Use ConfigMaps for files or large configs:

templates/api-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "myapp.fullname" . }}-api-config
data:
LOG_LEVEL: {{ .Values.api.env.LOG_LEVEL | quote }}
API_TIMEOUT: "30s"

3. Resource Requests/Limits

Kompose ignores these. Add them manually 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.fullname" . }}-default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
{{- end }}

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.


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.


Next Post
MetalLB for Bare-Metal LoadBalancer

Discussion

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

Related Posts