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 configand 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_ondoes 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
localhostfor dependencies. In Kubernetes,localhostmeans the same pod. Use Service DNS names such asredis.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
OOMKilledwhen it exceeds its memory limit. Checkkubectl describe podand 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-prodand confirm every expected object exists. - Run
kubectl rollout status deployment/web -n app-prod --timeout=5mand confirm the rollout completes without manual intervention. - Delete one web pod with
kubectl delete podand 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 undobefore 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.




