Contents
32 Chapter

From docker-compose to Kubernetes

Appendix A. It collects the seven differences that trip up readers who have reached Docker / docker-compose and are moving to Kubernetes. It maps each `docker-compose.yml` key to the corresponding Kubernetes resource, walks through one migration cycle from a small web + db compose file to Kubernetes manifests, and explains the limits of kompose and what comes next. It is the book's last chapter, but for readers who started from Docker, it also becomes a starting point.

This is Appendix A. For readers who have followed this book through Chapters 1 ~ 31, it is an appendix. For readers who came as far as Docker / docker-compose and opened this book for the first time, it is a starting point. The mapping table introduced at the end of Chapter 1, What Kubernetes Is as something you will return to often is the center of this appendix.

The goals of this appendix are two.

  • Which Kubernetes resource each key of docker-compose.yml corresponds to — the big picture in one table.
  • The seven cases where that mapping is not a simple 1 : 1 — different shapes of the same intent.

Once these two are clear, Chapters 1 ~ 6 of the Kubernetes main text become much easier to read.

The difference in mental model — one host, one network vs multiple nodes, multiple controllers #

In one line, docker-compose up does the following.

the mental model of docker-compose
On one host:
- one network is created (compose-default)
- and N containers are up inside it
- services on the same network can call each other by service name
- data is mounted to the host's disk
- restart: always auto-restarts on a downtime

Kubernetes breaks this model into multiple nodes, multiple controllers, and multiple network layers. The same idea looks like this in Kubernetes.

the mental model of K8s
On multiple nodes:
- multiple namespaces (the isolation unit)
- Pods inside them are scattered across multiple nodes
- a Service provides the Pod's virtual IP and DNS name
- a PersistentVolume is a persistent disk separated from the node
- a Deployment controller handles restarts and rolling updates
- the control plane (API Server, scheduler, controller-manager, etcd) orchestrates all of the above

The difference between one host’s simplicity and many nodes’ distribution is the core of the mental model. In compose, every container writes logs to the same host’s stdout and lives on the same network. In Kubernetes, by contrast, the node a Pod lands on depends on the manifest, and logs are distributed across nodes as well.

This difference is the starting point of all seven cases.

Resource mapping — the big picture #

The table below shows where the commonly used keys of docker-compose.yml map in Kubernetes.

docker-composeKubernetesChapter of this book
servicesDeployment + ServiceChapter 4, Chapter 5
imagethe Pod spec’s containers[].imageChapter 3
command / entrypointthe Pod spec’s command / argsChapter 3
ports: "8080:8080"Service (ClusterIP / NodePort / LoadBalancer) + IngressChapter 5, Chapter 10
volumes (named)PersistentVolumeClaim + StorageClassChapter 9
volumes (bind mount)hostPath (for development) or ConfigMap (config files)Chapter 9, Chapter 6
environment / env_fileConfigMap + Secret + envFromChapter 6
restart: alwaysthe default of a Deployment (ReplicaSet)Chapter 4
depends_oninitContainer / Job / Helm hook (no exact 1 : 1)Chapter 8, Chapter 23
healthcheckreadinessProbe + livenessProbe + startupProbeChapter 12
networksNetworkPolicy + Service DNS + CNIChapter 5, Chapter 14, Chapter 15
deploy.replicasthe Deployment’s replicasChapter 4
deploy.resourcesthe Pod spec’s resources.requests/limitsChapter 11
deploy.update_configthe Deployment’s strategy.rollingUpdateChapter 4
deploy.placement.constraintsnodeSelector / affinity / taintsChapter 22
secrets: (Compose)Secret + (sealed-secrets / external-secrets)Chapter 6, Chapter 29
configs: (Compose)ConfigMap (volumeMount or envFrom)Chapter 6

The table alone already shows the key difference. One services entry in compose becomes two objects in Kubernetes: Deployment + Service. The workload itself (Deployment) and its entry point (Service) are separate concerns. Compose bundled those two jobs into one concept; Kubernetes splits them into two objects and manages each lifecycle independently.

One more thing: there is no exact Kubernetes equivalent for depends_on. The closest pattern is initContainer (preparatory work before the Pod starts), but a meaning like “web starts after db comes up as another service” does not fit Kubernetes’s asynchronous model. Kubernetes does not force startup order across workloads; it handles that with readiness and retry. The details appear in difference 5 below.

The seven differences you commonly trip on #

1. Networking — service name DNS vs ClusterIP + Service #

The compose example:

docker-compose.yml — auto-discovery on one network
services:
  web:
    image: nginx
  db:
    image: postgres

Inside the web container, psql -h db ... just works. In the default network created by compose, the service name resolves automatically through DNS.

The Kubernetes version:

K8s — a Service object is required
apiVersion: v1
kind: Service
metadata:
  name: db
  namespace: default
spec:
  selector:
    app: db
  ports:
    - port: 5432

In Kubernetes, you must create a Service object explicitly to call db (or db.default.svc.cluster.local) from inside the web Pod. Going through a Service instead of calling Pod to Pod directly is the standard pattern.

The reason is explained in Chapter 5, Service: a Pod’s IP changes as it dies and comes back, but a Service guarantees a stable virtual IP and DNS name. Compose’s automatic service-name resolution comes from Docker’s embedded DNS tracking the containers on one network inside one host, while Kubernetes separates the stable entry point that crosses multiple nodes into its own Service object.

2. Service discovery — same-network auto-discovery vs Service + endpoints #

This is the same point from a different angle. In compose, discovery is implicit. In Kubernetes, it happens only when you define a Service explicitly.

K8s — checking a Service's endpoints
kubectl get endpoints db

Endpoints is the list of IPs of Pods that match the Service’s selector. If the selector and endpoints are empty or out of sync, the call does not go through — exactly the three-stage chain of selector → endpoints → port in Chapter 27, kubectl Debugging Patterns §“When a Service / Ingress won’t reach.”

Compose’s auto-discovery is simple but works only on one host. Kubernetes’s explicit model takes more work, but it is what allows consistent behavior across the whole cluster.

3. Persistent volume — host-path auto-mount vs PVC request + StorageClass #

The compose example:

docker-compose.yml — volumes
services:
  db:
    image: postgres
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

volumes:
  db-data:

A named volume is created automatically, and a bind mount mounts the host file path directly. It is a simple model where one host’s disk is visible directly.

The Kubernetes version:

K8s — PVC request + StorageClass
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: db-data
spec:
  storageClassName: gp3
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 10Gi
---
# the Pod's volumeMount + volumes
spec:
  containers:
    - name: db
      image: postgres
      volumeMounts:
        - name: data
          mountPath: /var/lib/postgresql/data
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: db-data

This is the model from Chapter 9, PV / PVC / StorageClass. It splits into three objects: PVC is the request, PV is the actual disk, and StorageClass is the provisioning policy.

The reason is simple: you cannot know in advance which node a Pod will land on, so you must not depend on the node’s host disk. You request a persistent disk that is independent of the node, such as EBS / EFS / GCP PD, with a PVC, and the StorageClass provisions and mounts it for the Pod.

The pattern corresponding to compose’s bind mount is not part of the book’s standard path — hostPath mounts in an operational environment are not recommended for security or portability reasons. A development-environment hostPath is possible, but it is not covered in the main text.

4. Secrets — env_file vs Secret + operational lifecycle #

The compose example:

docker-compose.yml — env_file
services:
  api:
    image: myapi
    env_file:
      - .env

Write passwords and API keys in a single .env file, add it to .gitignore, and you’re done. It is simple.

The Kubernetes version:

K8s — Secret
apiVersion: v1
kind: Secret
metadata:
  name: api-secrets
type: Opaque
stringData:
  DATABASE_PASSWORD: "..."
---
# the Pod's envFrom
spec:
  containers:
    - name: api
      envFrom:
        - secretRef:
            name: api-secrets

This is the model from Chapter 6, ConfigMap and Secret. A Secret’s data field is only base64-encoded, not encrypted, so committing the manifest to git unchanged would expose the secret.

The tools that solve this problem in practice are the three options from Chapter 29, Secret Operations (sealed-secrets / external-secrets / SOPS). Compose’s env_file + .gitignore model becomes, in Kubernetes, a more elaborate operational lifecycle — storage, rotation, injection, and audit are the real concerns, and simplicity has a cost.

5. Healthcheck — one stage vs three stages #

The compose experience:

docker-compose.yml — healthcheck
services:
  api:
    image: myapi
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s

It judges the container’s “healthiness” with one healthcheck. When unhealthy accumulates a certain number of times (combined with restart: on-failure), it restarts.

The Kubernetes version:

K8s — three probes
spec:
  containers:
    - name: api
      readinessProbe:
        httpGet: { path: /health/ready, port: http }
        initialDelaySeconds: 5
        periodSeconds: 5
      livenessProbe:
        httpGet: { path: /health/live, port: http }
        initialDelaySeconds: 30
        periodSeconds: 10
      startupProbe:
        httpGet: { path: /health/live, port: http }
        failureThreshold: 30
        periodSeconds: 10

It is the model covered in Chapter 12, Health Checks. It splits into three checks: readiness / liveness / startup.

  • readiness — is it ready to receive traffic? On fail it’s excluded from the Service endpoints.
  • liveness — is the container alive? When fail accumulates, the kubelet restarts the container.
  • startup — is it initializing? A grace period for workloads with long initialization time.

The reason one compose healthcheck becomes three checks in Kubernetes is simple: “can receive traffic,” “is alive,” and “is initializing” are different intents. Even if the same endpoint fails, the meaning differs. Bundling them into one stage causes the incident where liveness fails during initialization and the container restarts forever.

The depends_on equivalent is also resolved here. Compose’s “start api after db becomes healthy” becomes a pattern where api’s readinessProbe checks db’s response — it does not force startup order and instead resolves naturally: if db does not respond, readiness is false, the Pod is not added to the Service endpoints, and traffic does not arrive. If something more explicit is needed, the pattern is to wait for db with an initContainer.

6. Scaling — the one-host limit of --scale vs HPA + Cluster Autoscaler #

The compose experience:

docker-compose's scale
docker-compose up --scale api=3

You can grow up to one host’s limit, but once you reach that host’s CPU / memory limit, that’s the end.

The K8s model:

K8s — HPA
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
spec:
  scaleTargetRef:
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 50
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60

The two-stage combination of the HPA of Chapter 13, Autoscaling and Cluster Autoscaler / Karpenter is Kubernetes’s answer. The shape where nodes are added automatically as Pods increase is one of the essentials of container orchestration, and it is something compose does not have.

7. Logs — one-host stdout vs per-node distribution + agent #

The compose experience:

docker-compose logs
docker-compose logs -f api

All the container stdout from one host appears in one place. Simple.

The K8s model:

K8s — logs per Pod
kubectl logs <pod-name>
kubectl logs -l app=api --tail=100   # per label

Pods are scattered across multiple nodes, so each node’s kubelet holds that node’s container logs. kubectl logs fetches each node’s logs through the API Server.

In an operational environment, a per-node log agent (Promtail of Chapter 19, Observability §“Loki — the lightweight log stack,” or Fluent Bit of Chapter 25, Monitoring · Alerts) is added to gather all logs into a central store (Loki / CloudWatch). Compose’s one-line command turns in Kubernetes into three components: a collection agent + a central store + a search interface.

One migration cycle — the example of a small web + db #

We move the most common docker-compose bundle to K8s manifests.

docker-compose.yml — the starting point
version: "3"

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
    depends_on:
      - api
    environment:
      API_URL: http://api:8000
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/"]
      interval: 30s

  api:
    image: myapi:1.0
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://app:secret@db:5432/myapp
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:

This one file carries over into about 200 lines of K8s manifests. We point only at the core objects.

1. Namespace #

apiVersion: v1
kind: Namespace
metadata:
  name: myapp

A pattern compose does not have. Kubernetes’s isolation unit — the first object of Chapter 7, Namespace and Labels.

2. Secret (the DB password) #

apiVersion: v1
kind: Secret
metadata:
  name: db
  namespace: myapp
type: Opaque
stringData:
  POSTGRES_PASSWORD: secret
  POSTGRES_USER: app
  POSTGRES_DB: myapp

compose’s plaintext environment splits into K8s’s Secret. In operations you seal it with one of the three options of Chapter 29, but in a learning manifest we start with plaintext stringData for now.

3. PVC (the DB data) #

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: db-data
  namespace: myapp
spec:
  storageClassName: gp3   # or minikube's standard
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 10Gi

compose’s named volume is unfolded into PVC + StorageClass.

4. DB — Deployment + Service #

apiVersion: apps/v1
kind: Deployment
metadata:
  name: db
  namespace: myapp
spec:
  replicas: 1
  selector:
    matchLabels:
      app: db
  template:
    metadata:
      labels:
        app: db
    spec:
      containers:
        - name: postgres
          image: postgres:16
          envFrom:
            - secretRef:
                name: db
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "app"]
            periodSeconds: 5
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: db-data
---
apiVersion: v1
kind: Service
metadata:
  name: db
  namespace: myapp
spec:
  selector:
    app: db
  ports:
    - port: 5432
      targetPort: 5432

Operational PostgreSQL usually goes to the StatefulSet of Chapter 8, StatefulSet · DaemonSet · Job, but for a single-instance learning scenario Deployment + a single PVC is enough. The operational standard is the managed RDS of Chapter 23, DB Integration — you don’t bring up a DB directly inside K8s.

5. API — Deployment + Service #

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: myapp
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: myapi:1.0
          env:
            - name: DATABASE_URL
              value: "postgresql://app:$(POSTGRES_PASSWORD)@db.myapp.svc.cluster.local:5432/myapp"
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: db
                  key: POSTGRES_PASSWORD
          readinessProbe:
            httpGet: { path: /health, port: 8000 }
            initialDelaySeconds: 5
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /health, port: 8000 }
            initialDelaySeconds: 30
            periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: api
  namespace: myapp
spec:
  selector:
    app: api
  ports:
    - port: 8000
      targetPort: 8000

Compose’s depends_on: [db] is resolved in Kubernetes through the readinessProbe — while api’s readiness is false, it is excluded from the Service endpoints so web cannot see api. Once db comes up and api can connect to db, readiness becomes true and traffic flows.

6. Web — Deployment + Service + Ingress #

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: myapp
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: nginx
          image: nginx:1.27-alpine
          env:
            - name: API_URL
              value: "http://api.myapp.svc.cluster.local:8000"
          readinessProbe:
            httpGet: { path: /, port: 80 }
---
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: myapp
spec:
  selector:
    app: web
  ports:
    - port: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
  namespace: myapp
spec:
  rules:
    - host: myapp.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 80

compose’s one line ports: "80:80" splits in K8s into Service (the cluster-internal virtual IP) + Ingress (the external entry point). The decision to expose externally is in the Ingress, not the Service — the separation model of Chapter 5, Service + Chapter 10, Ingress.

Applying #

kubectl apply -f namespace.yaml
kubectl apply -f secret.yaml
kubectl apply -f pvc.yaml
kubectl apply -f db.yaml
kubectl apply -f api.yaml
kubectl apply -f web.yaml

Once these 6 steps finish, the same system as compose’s one command (docker-compose up) is up on Kubernetes. The difference is that each object is explicit and its lifecycle is separate. You can update one object, debug one object, and grant permissions for one object independently.

The total line count of the manifests is about 200 lines — more than 6 times compose’s 30 lines. This difference is the starting point of Kubernetes’s learning curve, but it is also the cost of gaining that much expressiveness and real tooling for operational work.

The limits of the kompose tool #

kompose is a tool that auto-converts docker-compose.yml into K8s manifests.

kompose convert
kompose convert -f docker-compose.yml -o k8s-manifests/

This command makes the K8s manifests of the above 6 steps in one go — useful as a starting point. But auto-conversion has limits.

the cases kompose can't solve
1. depends_on -> initContainer mapping is simple, but it's not the intended readiness pattern
2. healthcheck -> probe conversion becomes only one stage (liveness) — the split into three checks is done by hand
3. bind mount -> converted to hostPath — unsuitable in operations
4. networks isolation -> not converted to NetworkPolicy
5. the sealing option of secrets / configs -> converted only to a plaintext Secret manifest
6. Ingress manifest -> kompose doesn't make it. Always added by a human
7. the StorageClass decision of a PersistentVolume -> differs per cluster, so chosen by a human

kompose is a starting point, not the destination. Putting auto-converted manifests straight into operations means ignoring nearly all seven differences in this book. To get to operational manifests, you have to apply the patterns from Chapters 1 ~ 14 of this book and refine them by hand.

As an alternative, writing a Helm chart from scratch is more natural. Starting from one compose bundle with helm create myapp and applying this book’s patterns one manifest at a time is the standard entry path to operational K8s.

The next step of Appendix A — where to go #

At the point of finishing this appendix, the entry path of this book recommended to a reader who had come as far as Docker / docker-compose is the following.

the recommended entry path
[Appendix A finished]
   |
   v
[Chapter 1, What Kubernetes Is] -- at the point of finishing this appendix, the big picture is already in place
[Chapter 2, Local Environments] -- bring up kind or minikube
[Chapter 3, kubectl and Your First Pod] -- the first manifest of the migration cycle above
[Chapter 4, Deployment] -- the Deployment model in practice
[Chapter 5, Service] -- ClusterIP / NodePort / LoadBalancer + DNS
[Chapter 6, ConfigMap · Secret] -- the envFrom pattern in practice
[Chapter 7, Namespace and Labels] -- isolation and classification
   |
   v
   Part 1 complete -- a state where all of compose's concerns are inside one Kubernetes bundle

Chapter 1, What Kubernetes Is is the first chapter of the main text, and if you keep this appendix’s mapping table beside you, the question “which compose key was this?” will connect naturally in every chapter.

If you follow Part 1 (Chapters 1 ~ 7) of this book, this appendix’s migration manifest will look familiar. From there you move on to Part 2 (Chapters 8 ~ 14) and, after covering StatefulSet · PV · Ingress · resource management · health checks · autoscaling · RBAC, you gain the vision to operate a variety of workloads on a small cluster. The depth of Part 3 (Chapters 15 ~ 20), the EKS practice of Part 4 (Chapters 21 ~ 26), the operations · debugging · cost of Part 5 (Chapters 27 ~ 30), and the capstone of Part 6 (Chapter 31) form the big picture of the whole book.

Exercises #

  1. Pick a small docker-compose.yml you operate or are learning with, and move it to K8s manifests following the 6 steps of this appendix’s §“One migration cycle.” Compare the line counts of the manifests before and after conversion, and in one paragraph describe what the added lines are for (isolation · lifecycle separation · explicit objects).
  2. Compare the result of auto-converting the same docker-compose.yml with kompose convert against the result you moved by hand. Classify the differences by which of the 7 items in §“The limits of kompose” they correspond to, and map which chapter of the main text you should use to fill the gaps that auto-conversion could not cover.
  3. Pick the one of §“The seven differences you commonly trip on” that resonates most with your experience, and sketch in advance, on one page, how your workload’s docker-compose model will change in Kubernetes. Keep this memo beside you while you read the book, and when you reach the relevant chapter, verify how that pattern is actually resolved.

In one line: docker-compose’s one-host / one-network / simple service-name DNS model becomes, in Kubernetes, the explicit model of multiple nodes / multiple namespaces / Service + endpoints. One line of services splits into the two objects Deployment + Service, volumes into PVC + StorageClass, env_file into Secret + operational lifecycle, the one-stage healthcheck into the three stages readiness + liveness + startup, and the one-host limit of --scale into the two-stage automatic response of HPA + Cluster Autoscaler. There is no exact equivalent for depends_on; it is handled by the readiness probe pattern. kompose automates the starting point but is not the destination — you have to apply the patterns from Chapters 1 ~ 14 of this book by hand to get operational manifests. If you enter Chapter 1 after finishing this appendix, the mapping table will keep unfolding naturally in each chapter.

The end of the book #

With this appendix, all 32 chapters of this book (Chapters 1 ~ 31 + Appendix A) are complete. The retrospective table of Chapter 31, Deploying a Fullstack App on EKS, the last chapter of the main text, is the one-line summary of this book, and this appendix’s mapping table is the starting point. The structure of this book is that the starting point and destination are bound inside one volume.

Start the main text from Chapter 1, What Kubernetes Is, and we hope Appendix A serves as the reference you return to whenever the mapping gets fuzzy.

X