MeteorOps
Docker Compose to Kubernetes migration guide

Docker Compose to Kubernetes migration guide

Move Docker Compose workloads to Kubernetes with a step-by-step plan for services, secrets, probes, rollouts, rollback, and failure checks.

Arthur Azrieli

0 min read

Who this migration is for

Teams usually move a Docker Compose workload to Kubernetes when a single host has become a release bottleneck, a restart takes every service down, or a service now needs 2 to 5 replicas during normal traffic. As of August 2026, the practical trigger is usually clear: Compose still runs the app, but the team needs safer rollouts, pod-level health checks, autoscaling, and a deployment model that does not depend on SSH into one VM.

When this migration is and is not worth doing

The move is worth doing when you need scheduling across multiple nodes, self-healing after process failure, controlled rollouts, service discovery, pod-level resource limits, and a standard way to run web services, workers, and cron jobs. It also makes sense when your company already runs Kubernetes for other workloads, or when SOC 2, customer commitments, or uptime targets require clearer deployment history and failure recovery.

The move is usually not worth it for a small internal tool with one or two containers, low traffic, and no high-availability requirement. If your current Docker Compose stack runs on one VM, deploys once per week, and a 10-minute maintenance window is acceptable, Kubernetes may add more operational cost than value. A managed VM, systemd, Docker Compose, and automated backups can be a better fit until the workload needs replication, isolation, or a shared platform.

The examples below assume Kubernetes 1.30 or newer, kubectl 1.30 or newer, Docker Compose v2.27 or newer, and stable Kubernetes APIs such as apps/v1, policy/v1, autoscaling/v2, and networking.k8s.io/v1. Ingress moved to networking.k8s.io/v1 in Kubernetes 1.19, and Kubernetes removed Dockershim in 1.24, so do not depend on Docker-specific node behavior unless you control the runtime.

Prerequisites and pre-migration checklist

Before you write Kubernetes manifests, freeze the current behavior of the Compose stack. The goal is to migrate the workload, not debug unknown application behavior during cutover.

  • Export the resolved Compose file with docker compose config and commit it to the migration branch for reference.
  • List every service, port, volume, environment variable, secret, health check, and startup dependency.
  • Confirm each image can be built in CI and pushed to a registry such as Amazon ECR, Google Artifact Registry, Azure Container Registry, Docker Hub, or GitHub Container Registry.
  • Decide which stateful services will move. For production, keep PostgreSQL, MySQL, Redis, and queues on managed services unless you already have Kubernetes storage, backup, and restore procedures.
  • Create a target namespace, DNS plan, TLS plan, and rollback path before traffic moves.
  • Set initial resource requests and limits. For a typical small web API, start with a 100 millicore CPU request, 500 millicore CPU limit, 256 MiB memory request, and 512 MiB memory limit, then tune from real metrics.
  • Confirm your team has access to logs, metrics, alerts, and Kubernetes events before production traffic hits the cluster.

If you need help with container packaging details before the move, MeteorOps maintains a practical page on Docker and container workflows. If Azure is your target platform, plan the cluster baseline against Azure Kubernetes Service requirements before translating manifests.

Step-by-step migration path

1. Capture the Compose source of truth

Start by rendering the Compose file exactly as Docker sees it. This resolves anchors, variable substitution, and overrides.

docker compose config > compose.resolved.yaml
docker compose ps
docker compose images

Use the resolved file to map Compose concepts to Kubernetes resources. A Compose service usually becomes a Deployment. A Compose port mapping usually becomes a Kubernetes Service and, for external traffic, an Ingress. A Compose healthcheck usually becomes readiness and liveness probes. A Compose named volume either becomes a PersistentVolumeClaim or moves to a managed external service.

For example, this Compose service has the key details you must carry over:

services:
  web:
    image: ghcr.io/acme/app-web:1.8.3
    command: ./bin/server
    ports:
      - "8080:8080"
    env_file:
      - .env.production
    depends_on:
      - redis
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3

2. Build and push immutable images

Kubernetes should deploy immutable image tags, not latest. Use the Git SHA, release number, or both. This example builds and pushes an image with Docker Compose when the Compose file already contains an image field.

export IMAGE_TAG=1.8.3
docker compose build web
docker compose push web

If your Compose file uses local build contexts without registry image names, change it before migration. Kubernetes nodes must pull the image from a registry. Do not depend on images that exist only on a developer laptop or one old VM.

3. Create the namespace and base configuration

Create a namespace for the app. Use one namespace per environment unless you already run a different tenancy model.

kubectl create namespace app-prod
kubectl config set-context --current --namespace=app-prod

Move non-secret configuration into a ConfigMap. Keep secrets out of Git. For production secrets, prefer your cloud secret manager with External Secrets Operator, Sealed Secrets, or your existing secret delivery system. For a first controlled migration, this command creates a Kubernetes Secret from an env file without storing the generated YAML in the repository.

kubectl create secret generic app-secrets \
  --from-env-file=.env.production \
  --namespace app-prod \
  --dry-run=client \
  -o yaml | kubectl apply -f -

A small ConfigMap might look like this:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  namespace: app-prod
data:
  RACK_ENV: "production"
  LOG_LEVEL: "info"
  REDIS_HOST: "redis.default.svc.cluster.local"

4. Translate the web service into a Deployment and Service

Start with one stateless service. The Deployment below runs 3 replicas, waits for readiness before sending traffic, gives each pod 30 seconds to terminate, and keeps enough rollout history for quick rollback.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: app-prod
spec:
  replicas: 3
  revisionHistoryLimit: 5
  progressDeadlineSeconds: 300
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: web
          image: ghcr.io/acme/app-web:1.8.3
          imagePullPolicy: IfNotPresent
          command: ["./bin/server"]
          ports:
            - containerPort: 8080
          envFrom:
            - configMapRef:
                name: app-config
            - secretRef:
                name: app-secrets
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"

Create an internal Service for stable discovery. Kubernetes Services replace Compose service names for in-cluster traffic.

apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: app-prod
spec:
  type: ClusterIP
  selector:
    app: web
  ports:
    - name: http
      port: 80
      targetPort: 8080

Apply the manifests and wait for the rollout.

kubectl apply -f k8s/web-deployment.yaml
kubectl apply -f k8s/web-service.yaml
kubectl rollout status deployment/web --timeout=5m
kubectl get pods -l app=web -o wide

5. Translate workers and scheduled jobs

A long-running Compose worker usually becomes a Deployment with no Service. Start with 2 replicas if the worker can process jobs concurrently. If it cannot, set replicas: 1 until you fix idempotency and locking.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: worker
  namespace: app-prod
spec:
  replicas: 2
  selector:
    matchLabels:
      app: worker
  template:
    metadata:
      labels:
        app: worker
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: worker
          image: ghcr.io/acme/app-web:1.8.3
          command: ["./bin/worker"]
          envFrom:
            - configMapRef:
                name: app-config
            - secretRef:
                name: app-secrets
          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              cpu: "1000m"
              memory: "1Gi"

A Compose cron container usually becomes a CronJob. Set concurrencyPolicy: Forbid when the job must not overlap.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-cleanup
  namespace: app-prod
spec:
  schedule: "15 2 * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: cleanup
              image: ghcr.io/acme/app-web:1.8.3
              command: ["./bin/cleanup"]
              envFrom:
                - configMapRef:
                    name: app-config
                - secretRef:
                    name: app-secrets

6. Add ingress, TLS, and DNS

Use an Ingress when HTTP traffic should enter the cluster through an ingress controller such as NGINX Ingress Controller, AWS Load Balancer Controller, GKE Ingress, or Azure Application Gateway Ingress Controller. The exact annotations depend on the controller, so keep them in an environment overlay.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
  namespace: app-prod
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - app.example.com
      secretName: app-example-com-tls
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 80

Before DNS cutover, test with a temporary host name such as app-k8s.example.com. Keep the old Compose endpoint live until Kubernetes has served real traffic for at least one full business cycle, or longer if your traffic has weekly peaks.

7. Add rollout protection and autoscaling

A PodDisruptionBudget protects voluntary disruptions such as node drains. With 3 replicas, require at least 2 available pods.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: web
  namespace: app-prod
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: web

If the app has stable CPU behavior, add an HPA. Do not add autoscaling until requests are set, because CPU percentages use requests as the baseline.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: web
  namespace: app-prod
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: web
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

8. Put deployment in CI

Do not make production depend on a laptop running kubectl apply. This GitHub Actions example builds the image, pushes it to GitHub Container Registry, updates the Deployment image, and waits for rollout. Replace authentication with your cloud provider鈥檚 approved method.

name: deploy-prod

on:
  push:
    tags:
      - "v*"

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
    steps:
      - uses: actions/checkout@v4

      - name: Log in to GHCR
        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

      - name: Build and push image
        run: |
          IMAGE=ghcr.io/acme/app-web:${GITHUB_REF_NAME}
          docker build -t "$IMAGE" .
          docker push "$IMAGE"

      - name: Configure kubectl
        run: |
          mkdir -p "$HOME/.kube"
          echo "${{ secrets.KUBECONFIG_PROD }}" > "$HOME/.kube/config"

      - name: Deploy
        run: |
          IMAGE=ghcr.io/acme/app-web:${GITHUB_REF_NAME}
          kubectl -n app-prod set image deployment/web web="$IMAGE"
          kubectl -n app-prod set image deployment/worker worker="$IMAGE"
          kubectl -n app-prod rollout status deployment/web --timeout=5m
          kubectl -n app-prod rollout status deployment/worker --timeout=5m

For larger setups, use Kustomize, Helm, Argo CD, or Flux instead of direct image mutation. The same infrastructure-as-code discipline applies when clusters and workloads grow, as shown in MeteorOps鈥檚 work importing high-scale Kubernetes clusters into Pulumi.

Common failure modes and how to avoid or recover from them

  • Compose depends_on does not map to Kubernetes readiness. Kubernetes may start pods in any order. Use readiness probes, retries in the application, and init containers only when a hard startup gate is required.
  • The app still uses localhost for dependencies. In Kubernetes, localhost means the same pod. Use Service DNS names such as redis.app-prod.svc.cluster.local.
  • Health checks are too weak. A liveness probe should answer whether the process should be restarted. A readiness probe should answer whether the pod should receive traffic. Do not use a database-heavy check every 5 seconds.
  • Memory limits are too low. Kubernetes kills a container with OOMKilled when it exceeds its memory limit. Check kubectl describe pod and raise the limit or reduce app memory use.
  • File writes assume a local disk. Pods move between nodes and can be replaced at any time. Store uploads in object storage and use PersistentVolumeClaims only when the workload truly needs a mounted filesystem.
  • Image tags are mutable. If the same tag points to different builds, rollback becomes unreliable. Use release tags or digests for production.
  • Shutdown drops requests or jobs. Add signal handling in the app, set terminationGracePeriodSeconds, and use a pre-stop hook only when the app needs a short drain delay.

Rollback plan

Keep the Docker Compose deployment running until Kubernetes passes production validation. The safest rollback is usually traffic rollback, not manifest rollback. Point DNS, the load balancer, or the upstream router back to the Compose endpoint while you investigate the cluster.

For a bad Kubernetes release after cutover, use Deployment history first:

kubectl -n app-prod rollout history deployment/web
kubectl -n app-prod rollout undo deployment/web
kubectl -n app-prod rollout status deployment/web --timeout=5m

If the issue affects the whole Kubernetes path, move traffic back to the Compose host and scale Kubernetes down only after requests drain.

kubectl -n app-prod scale deployment/web --replicas=0
kubectl -n app-prod scale deployment/worker --replicas=0

Database migrations need their own rollback rule. Use backward-compatible schema changes during the migration window. A good pattern is expand, deploy, backfill, switch reads, then contract later. Do not cut over with an irreversible schema change unless the business accepts a restore-based rollback.

How to validate the migration succeeded

The migration is done when the Kubernetes deployment handles normal traffic, survives routine pod and node disruption, and gives the team better recovery behavior than the Compose host.

  • Run kubectl get deploy,po,svc,ingress -n app-prod and confirm every expected object exists.
  • Run kubectl rollout status deployment/web -n app-prod --timeout=5m and confirm the rollout completes without manual intervention.
  • Delete one web pod with kubectl delete pod and confirm the Deployment replaces it and traffic stays healthy.
  • Check application latency, error rate, saturation, and request volume for at least one normal traffic cycle.
  • Confirm logs include all services and can be searched by pod, namespace, deployment, and request ID.
  • Check resource use after real traffic. If p95 CPU is below 40 percent of requests for several days, reduce requests. If memory regularly exceeds 80 percent of the limit, raise the limit or tune the app.
  • Run a rollback test in staging with kubectl rollout undo before you rely on it in production.
  • Confirm alerts cover failed rollouts, CrashLoopBackOff, high 5xx rate, high latency, pending pods, and HPA max replica saturation.

A successful cutover should remove the single-host failure point, make deploys repeatable through CI, and give the team a clear path to inspect and reverse a bad release. If the migration leaves you with unclear ownership, missing metrics, or manual kubectl steps for every deploy, keep tightening the platform before calling it complete.

How MeteorOps can help

MeteorOps helps teams move Compose workloads into production Kubernetes without turning the migration into a long platform rewrite. A senior DevOps or platform engineer can work inside your repo, CI system, cloud account, and Slack to build the manifests, set rollout guardrails, tune resources, and plan rollback with your team. For a related example of production Kubernetes cleanup, see this case study on improving AWS and Kubernetes infrastructure management.

Want a senior engineer on this?

We put vetted senior DevOps engineers in your Slack within a week, billed by the hour. No retainer, no lock-in.