Document

SUBSCRIBE TO GET FULL ACCESS TO THE E-BOOKS FOR FREE 🎁SUBSCRIBE NOW

Professional Dropdown with Icon

SUBSCRIBE NOW TO GET FREE ACCESS TO EBOOKS

“Should we use Docker Compose or Kubernetes?” is one of the most common questions from teams that have just containerised an application. The honest answer is that they solve different problems: Docker Compose describes and runs a multi-container application on one machine; Kubernetes runs containers across a cluster of machines with self-healing, scaling and rolling updates. Many teams use both, Compose for development and Kubernetes for production. This tutorial compares them concretely by writing the same application for each, maps every Compose concept to its Kubernetes equivalent, shows how to migrate, and ends with a decision guide.

Prerequisites: Docker Engine 27+ with Compose v2 (docker compose version) and, for the Kubernetes part, a local cluster such as kind or minikube plus kubectl. See Docker Compose and the Kubernetes learning path for the basics.

At a glance

Docker ComposeKubernetes
ScopeOne host (Docker Engine)Cluster of nodes
Definition filecompose.yaml (one file, ~50 lines for a small app)Many manifests (Deployment, Service, ConfigMap, Secret, Ingress, PVC…) or a Helm chart
Self-healingrestart: always restarts a container on the same host; nothing if the host diesReschedules pods on healthy nodes; replaces unhealthy pods automatically
Scalingdocker compose up --scale web=3 on one machineHorizontal Pod Autoscaler across nodes; Cluster Autoscaler adds nodes
Rolling updatesRecreate containers (brief downtime unless you script it)Built-in RollingUpdate with readiness probes, zero downtime
NetworkingBridge network, service name = DNS nameCluster-wide pod network (CNI), Services, Ingress/Gateway
Configuration and secretsenv files, configs, secrets (files)ConfigMaps, Secrets, external secret operators
StorageNamed volumes, bind mountsPersistentVolumeClaims with StorageClasses (EBS, EFS, Ceph…)
Learning curveHoursWeeks
Operations burdenAlmost noneCluster upgrades, add-ons, RBAC, observability stack (or pay for a managed service)
Typical useLocal development, CI test environments, small single-server deploymentsProduction platforms, microservices, multi-team organisations

The sample application

Three services: an API (ghcr.io/example/api, port 8000), a PostgreSQL database with persistent storage, and a Redis cache. The API reads its database password from a secret and a feature flag from configuration. The API image is a placeholder; any HTTP service that exposes /healthz will do.

Version 1 – Docker Compose

# compose.yaml
services:
  api:
    image: ghcr.io/example/api:1.4.0
    ports:
      - "8080:8000"
    environment:
      DATABASE_URL: postgres://app@db:5432/app
      REDIS_URL: redis://cache:6379
      FEATURE_NEW_CHECKOUT: "true"
    secrets:
      - db_password
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 256M
    restart: unless-stopped

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 10

  cache:
    image: redis:7.4-alpine
    command: ["redis-server", "--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]

volumes:
  pgdata:

secrets:
  db_password:
    file: ./secrets/db_password.txt      # never committed; .gitignore it
mkdir -p secrets && openssl rand -base64 24 > secrets/db_password.txt
docker compose up -d
docker compose ps
docker compose logs -f api
curl -s localhost:8080/healthz

docker compose up -d --scale api=3      # 3 API containers, but the host port can only map to one
docker compose pull && docker compose up -d   # "update": recreate changed containers
docker compose down                      # keep volumes; add -v to delete data

Fifty lines, one command, and everything runs on your laptop. Notice the limits: scaling the API requires a reverse proxy in front (Traefik, nginx) because only one container can own port 8080, and an update is a stop/start.

Version 2 – Kubernetes

The same application needs several objects. The mapping is systematic:

ComposeKubernetes
services.api (stateless)Deployment + Service
services.db (stateful, one instance)StatefulSet + headless Service + PersistentVolumeClaim (or a managed database)
ports: "8080:8000"Service (ClusterIP) + Ingress/Gateway for external access
environmentenv, or ConfigMap referenced with envFrom
secretsSecret mounted as file or env var
volumes: pgdataPersistentVolumeClaim + StorageClass
healthchecklivenessProbe and readinessProbe
deploy.resourcesresources.requests / limits
depends_onNo direct equivalent: readiness probes + init containers + retries in the app
restartAlways, by the controller
Service name as DNS<service>.<namespace>.svc.cluster.local
# k8s/api.yaml
apiVersion: v1
kind: ConfigMap
metadata: { name: api-config }
data:
  DATABASE_URL: postgres://app@db:5432/app
  REDIS_URL: redis://cache:6379
  FEATURE_NEW_CHECKOUT: "true"
---
apiVersion: v1
kind: Secret
metadata: { name: db-credentials }
type: Opaque
stringData:
  password: REPLACE_ME_AT_DEPLOY_TIME      # in practice: sealed-secrets, External Secrets, or kubectl create secret
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: api }
spec:
  replicas: 3
  selector: { matchLabels: { app: api } }
  strategy:
    rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
  template:
    metadata: { labels: { app: api } }
    spec:
      initContainers:
        - name: wait-for-db
          image: postgres:17-alpine
          command: ["sh", "-c", "until pg_isready -h db -U app; do sleep 2; done"]
      containers:
        - name: api
          image: ghcr.io/example/api:1.4.0
          ports: [{ containerPort: 8000 }]
          envFrom:
            - configMapRef: { name: api-config }
          env:
            - name: DB_PASSWORD
              valueFrom: { secretKeyRef: { name: db-credentials, key: password } }
          readinessProbe:
            httpGet: { path: /healthz, port: 8000 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /healthz, port: 8000 }
            periodSeconds: 10
          resources:
            requests: { cpu: 100m, memory: 128Mi }
            limits:   { memory: 256Mi }
---
apiVersion: v1
kind: Service
metadata: { name: api }
spec:
  selector: { app: api }
  ports: [{ port: 80, targetPort: 8000 }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api
spec:
  ingressClassName: nginx
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend: { service: { name: api, port: { number: 80 } } }
# k8s/db.yaml
apiVersion: v1
kind: Service
metadata: { name: db }
spec:
  clusterIP: None                      # headless: stable DNS for the StatefulSet pod
  selector: { app: db }
  ports: [{ port: 5432 }]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: db }
spec:
  serviceName: db
  replicas: 1
  selector: { matchLabels: { app: db } }
  template:
    metadata: { labels: { app: db } }
    spec:
      containers:
        - name: postgres
          image: postgres:17-alpine
          env:
            - { name: POSTGRES_USER, value: app }
            - { name: POSTGRES_DB, value: app }
            - name: POSTGRES_PASSWORD
              valueFrom: { secretKeyRef: { name: db-credentials, key: password } }
          ports: [{ containerPort: 5432 }]
          volumeMounts:
            - { name: pgdata, mountPath: /var/lib/postgresql/data }
          readinessProbe:
            exec: { command: ["pg_isready", "-U", "app"] }
  volumeClaimTemplates:
    - metadata: { name: pgdata }
      spec:
        accessModes: [ReadWriteOnce]
        resources: { requests: { storage: 10Gi } }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: cache }
spec:
  replicas: 1
  selector: { matchLabels: { app: cache } }
  template:
    metadata: { labels: { app: cache } }
    spec:
      containers:
        - name: redis
          image: redis:7.4-alpine
          args: ["--maxmemory", "64mb", "--maxmemory-policy", "allkeys-lru"]
          ports: [{ containerPort: 6379 }]
---
apiVersion: v1
kind: Service
metadata: { name: cache }
spec:
  selector: { app: cache }
  ports: [{ port: 6379 }]
kubectl create namespace shop
kubectl -n shop create secret generic db-credentials --from-literal=password="$(openssl rand -base64 24)"
kubectl -n shop apply -f k8s/db.yaml -f k8s/api.yaml     # remove the placeholder Secret from api.yaml first
kubectl -n shop get pods -w
kubectl -n shop port-forward svc/api 8080:80 &
curl -s localhost:8080/healthz

# The things Compose cannot do
kubectl -n shop scale deployment/api --replicas=6
kubectl -n shop set image deployment/api api=ghcr.io/example/api:1.5.0   # zero-downtime rolling update
kubectl -n shop rollout undo deployment/api
kubectl -n shop autoscale deployment/api --min=3 --max=20 --cpu-percent=70

Roughly 150 lines instead of 50, but in exchange: three API replicas spread across nodes behind one stable address, an update that never drops a request, automatic replacement if a node disappears, and autoscaling on CPU.

Migrating from Compose to Kubernetes

Option A – Kompose for a first draft

kompose convert -f compose.yaml -o k8s/          # one file per object
kompose convert -f compose.yaml --chart -o chart/ # or a Helm chart skeleton
ls k8s/
# api-deployment.yaml api-service.yaml db-deployment.yaml pgdata-persistentvolumeclaim.yaml ...

Kompose (see our Kompose tutorial) produces valid manifests fast, but treat them as a starting point: it generates Deployments where you want StatefulSets, ignores depends_on, and cannot invent probes or resource requests you did not specify.

Option B – Helm chart or Kustomize by hand

For anything that will live in production, write the manifests deliberately (as above), then parameterise them with Helm or Kustomize overlays for dev/staging/prod. Use managed services for stateful pieces where possible: RDS instead of a PostgreSQL StatefulSet, ElastiCache instead of Redis in the cluster.

Keep Compose for development

Migrating production does not mean abandoning Compose. Most teams keep compose.yaml for local development and CI integration tests, because it starts in seconds and needs no cluster. Tools like Tilt, Skaffold or DevSpace offer the same inner loop against a local Kubernetes cluster if you want dev/prod parity.

The middle ground

  • Docker Swarm runs your Compose file (with a deploy: section) across several hosts with rolling updates and secrets. Minimal learning curve; small ecosystem.
  • k3s is a full Kubernetes in a single 70 MB binary; ideal for one to five servers when you want Kubernetes semantics without the setup cost.
  • Managed container platforms (AWS ECS/Fargate, Azure Container Apps, Google Cloud Run) accept container images directly and handle scaling and updates without exposing Kubernetes.

Decision guide

  • Stay with Compose if: one server is enough, a few minutes of downtime during updates is acceptable, the team is small, and there is no requirement for autoscaling or multi-region.
  • Move to Kubernetes if: you need more than one machine, zero-downtime deployments, automatic recovery from node failure, autoscaling, several teams sharing infrastructure, or you already pay for a managed Kubernetes service.
  • Consider Swarm, k3s or a managed container service if you are between the two: more than one host but no platform team.
  • Whatever you pick for production, keep Compose for local development; the images are identical.

Common pitfalls when moving

  • Relying on depends_on ordering: in Kubernetes, containers must retry connections; add init containers only as a convenience.
  • Forgetting resource requests: without them the scheduler packs pods badly and autoscaling cannot work.
  • Putting the database in a Deployment: use a StatefulSet with a PVC or, better, a managed database.
  • Baking secrets into ConfigMaps or images; use Secrets with an external secret manager.
  • Exposing everything with NodePort: use one Ingress controller or Gateway API instead.

Key takeaways

  • Compose = one host, one file, instant start; Kubernetes = cluster, many objects, self-healing and scaling.
  • Every Compose concept has a Kubernetes counterpart; the extra lines buy probes, rolling updates and placement.
  • Kompose gives a draft; Helm/Kustomize gives a maintainable production setup.
  • Use Compose for development and Kubernetes (or a managed platform) for production; the images are the same.

Related: Docker Compose, Docker Swarm vs Kubernetes, Kubernetes Deployments, StatefulSets. Official docs: Docker Compose, Kubernetes workloads.

← Retour parcours Kubernetes

Retour parcours Docker — hub de la série et leçons sœurs.

Share your love

Leave a Reply

Your email address will not be published. Required fields are marked *