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 → Helm (saves about 40% of the typing)
- Manual cleanup: because kompose gets ~60% of the way there
- 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:
# 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:
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_passwordRun kompose:
kompose convert -f docker-compose.yml -o helm-output/Kompose spits out YAML files into helm-output/. Here’s what you get:
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.yamlThis 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.tplStart by scaffolding a chart:
helm create myapp-helmThis gives you boilerplate. Delete the default templates and keep the structure:
rm myapp-helm/templates/*Now copy the kompose-generated manifests into templates/:
cp helm-output/*.yaml myapp-helm/templates/Building values.yaml
Next step: parameterize everything. Create a values.yaml:
# Globalnamespace: defaultimagePullPolicy: IfNotPresent
# PostgreSQLpostgres: 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
# Redisredis: enabled: true image: repository: redis tag: "7-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: 512MiTemplating 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.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.fullname" . }}-postgres labels: {{- include "myapp.labels" . | nindent 4 }} app.kubernetes.io/component: databasespec: 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:
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 fiThe 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 roll Compose back in 30 seconds 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. Helm handles 80% of traffic, Compose handles 20%. If Helm fails, Compose catches the overflow.
- No “big bang”. You migrate services one by one, not everything at once. Postgres first, then Redis, then API.
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: url2. ConfigMaps for Non-Secret Config
Kompose treats everything as an env var. Use ConfigMaps for files or large configs:
apiVersion: v1kind: ConfigMapmetadata: name: {{ include "myapp.fullname" . }}-api-configdata: 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: 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.fullname" . }}-default-denyspec: 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:
- 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.