This guide documents a hands-on approach to migrating Docker Compose projects to Kubernetes. It outlines everything from fundamental concepts to practical examples—ideal for homelabbers, DevOps engineers, or anyone scaling from local dev to production-grade clusters.


Introduction

Understanding Kubernetes Prerequisites

Kubernetes is built with a “stateless-first” mindset, and this changes how we deploy applications compared to Docker Compose.

  1. Stateless-First Deployment — Pods can be killed and rescheduled at any time.
  2. Stateful Apps → StatefulSet — Use StatefulSet for apps needing persistent storage like databases or queues.
  3. Secrets — Use Secret objects to store sensitive data (e.g., credentials, tokens).
  4. Redundancy — Always set replicas > 1 for HA deployments.
  5. Health Probes — Use livenessProbe and readinessProbe for production workloads.
  6. Namespaces — Use one namespace per project/repository.
Secrets in Kubernetes doesn't mean that it's encrypted. In a production-level cluster, you should enable encryption at rest (KMS) and integrate a credential manager like Vault or AWS Secrets Manager.
Docker to Kubernetes Concept Mapping
ConceptDocker ComposeKubernetes
Service Discoverydepends_on + container nameDNS: svc-name.namespace.svc.cluster.local
NetworkingShared bridge networkCNI (Container Network Interface)
Volumesvolumes:PVC, PV, emptyDir
Env & Secretsenvironment:ConfigMap & Secret
Port Exposureports: "8080:80"Service & optionally Ingress
Healthcheckshealthcheck:livenessProbe, readinessProbe
Kompose (Optional)

Kompose is a tool that converts docker-compose.yml to Kubernetes manifests. Use it for fast prototyping, but refactor manually for production:

  • Move sensitive values to Secret
  • App configs to ConfigMap
  • Add resources.limits to containers
  • Define PersistentVolumeClaim sizes for volume paths

Conversion Examples

Simple Docker Compose

Docker Compose

# simple-docker-compose.yml
version: '3.8'

services:
  web:
    image: nginx:latest
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html
    restart: unless-stopped
  • image: nginx:latest → Can be changed in the Kubernetes manifest
  • ports: 8080:80 → Exposes container port 80 to host port 8080
  • volumes: Mounts local ./html directory to /usr/share/nginx/html inside the container
  • restart: unless-stopped → Not applicable in Kubernetes, use restartPolicy: Always instead

Migrate to Kubernetes

If in doubt, always refer to Kubernetes Documentation for objects and specifications

Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: nginx
          image: ghcr.io/your-org/nginx-html:latest
          ports:
            - containerPort: 80
Service
apiVersion: v1
kind: Service
metadata:
  name: web-service
spec:
  selector:
    app: web
  ports:
    - protocol: TCP
      port: 80        # Service Port
      targetPort: 80  # Container Port
  type: ClusterIP     # Options: ClusterIP | NodePort | LoadBalancer

Volume Strategy

“How should we manage volumes? Should we use them or bake content into the image? Here’s how to decide:”

Option 1 : Skip Mounting – Bake Static Files into the Image A better option for my use case (which is infrequent file changes) is to Bake the static files into the image due to these following reasons:

  1. Even though the container grows in size, using PVC and InitContainer introduces complexity, making it harder to manage, scale, and monitor.
  2. It is better for GitOps, because we do not need to touch any infra-related stuff at deployment

“Build content into container images for Immutable Artifacts, PVC is unnecessary for static content. Keep infra and deployment declarative via GitOps tools like ArgoCD or Flux.”


Option 2 : Use PVC + InitContainer (Good for frequent files changes)

PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: html-pvc
spec:
  accessModes:
    - ReadWriteOnce
  # storageClassName: # if storageClass is available (kubectl get storageclass)
  resources:
    requests:
      storage: 1Gi
InitContainer
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-static
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-static
  template:
    metadata:
      labels:
        app: nginx-static
    spec:
      volumes:
        - name: html-volume
          persistentVolumeClaim:
            claimName: html-pvc

      initContainers:
        - name: git-clone
          image: alpine/git
          command: ["/bin/sh", "-c"]
          args:
            - |
              git clone --depth=1 https://github.com/your-org/your-repo.git /tmp/html && \
              cp -r /tmp/html/html/* /data/
          volumeMounts:
            - name: html-volume
              mountPath: /data

      containers:
        - name: nginx
          image: nginx:latest
          ports:
            - containerPort: 80
          volumeMounts:
            - name: html-volume
              mountPath: /usr/share/nginx/html


Option 3 : Content Delivery Network (Best Choice) – If the program made by the developer depends on a CDN, it is the best choice since it eliminates both cons from two methods above, this could mean:

  1. We do not need to manage PVC to place static content.
  2. Static files are fetched from a delivery network, we do not need to store it to the container.
Advanced Docker Compose

Docker Compose Setup

Docker Compose
# advanced-docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: ./app
      dockerfile: Dockerfile
    container_name: myapp
    environment:
      - DB_HOST=db
      - REDIS_HOST=redis
      - APP_ENV=production
    ports:
      - "3000:3000"
    volumes:
      - app-data:/usr/src/app/data
    depends_on:
      - db
      - redis
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 5
    networks:
      - backend
      - frontend
    restart: always

  db:
    image: postgres:15
    container_name: postgres-db
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: mydatabase
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - backend
    restart: unless-stopped

  redis:
    image: redis:7
    container_name: redis-cache
    command: redis-server --appendonly yes
    volumes:
      - redis-data:/data
    networks:
      - backend
    restart: unless-stopped

  nginx:
    image: nginx:latest
    container_name: nginx-proxy
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - app
    networks:
      - frontend
    restart: unless-stopped

volumes:
  db-data:
  redis-data:
  app-data:

networks:
  backend:
  frontend:
Environment Variables
# .env
POSTGRES_USER=admin
POSTGRES_PASSWORD=securepassword
Folder Structure
.
├── docker-compose.yml
├── .env
├── app/
│   ├── Dockerfile
│   └── src/
├── nginx/
│   └── nginx.conf

Kubernetes Conversion

  • All resources are assumed to be namespaced in advanced-app
Secrets- Refer to the Secret in Kubernetes section below - The environment variable will be a stored Secret Object
# .env
POSTGRES_USER=admin
POSTGRES_PASSWORD=securepassword
# /home/advanced-app
kubectl create secret generic postgres-secret \
											-n advanced-app \
											--from-env-file=.env
ConfigMap-> a place to store application configuration, non-sensitive data should be stored here for application setting purposes
  • Look at the app-config.data section, the host uses Kubernetes local DNS to call the respective service
    • DB_HOST: db.advanced-app.svc.cluster.local
    • REDIS_HOST: redis.advanced-app.svc.cluster.local
  • Docker uses a single-network bridge which is why we reference it using the service names, Kubernetes is multi-container orchestrator which uses CNI + CoreDNS
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  namespace: advanced-app
data:
  DB_HOST: db.advanced-app.svc.cluster.local
	REDIS_HOST: redis.advanced-app.svc.cluster.local
  APP_ENV: production
  • Configurations like nginx.conf can also be placed in ConfigMap, eliminating the node-dependent hostPath option and the additional benefit of namespace isolation

apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-config
  namespace: advanced-app
data:
  nginx.conf: |
    user  nginx;
    worker_processes  auto;
    error_log  /var/log/nginx/error.log warn;
    pid        /var/run/nginx.pid;

    events {
        worker_connections  1024;
    }

    http {
        include       /etc/nginx/mime.types;
        default_type  application/octet-stream;

        log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                          '$status $body_bytes_sent "$http_referer" '
                          '"$http_user_agent" "$http_x_forwarded_for"';

        access_log  /var/log/nginx/access.log  main;

        sendfile        on;
        keepalive_timeout  65;

        server {
            listen 80;
            server_name localhost;

            location / {
                proxy_pass http://app-service:3000;
                proxy_set_header Host $host;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto $scheme;
            }
        }
    }
Persistent Volume Claims
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: db-data
  namespace: advanced-app
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis-data
  namespace: advanced-app
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 500Mi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: app-data
  namespace: advanced-app
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 1Gi
Deployment Apps
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
  namespace: advanced-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: app
  template:
    metadata:
      labels:
        app: app
    spec:
      containers:
        - name: app
          image: your-org/your-app:latest
          envFrom:
            - configMapRef:
                name: app-config
            - secretRef:
                name: postgres-secret
          volumeMounts:
            - name: app-data
              mountPath: /usr/src/app/data
          ports:
            - containerPort: 3000
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 10
            periodSeconds: 10
      volumes:
        - name: app-data
          persistentVolumeClaim:
            claimName: app-data
Deployment PostgreSQL
apiVersion: apps/v1
kind: Deployment
metadata:
  name: db
  namespace: advanced-app
spec:
  selector:
    matchLabels:
      app: db
  template:
    metadata:
      labels:
        app: db
    spec:
      containers:
        - name: postgres
          image: postgres:15
          env:
            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_USER
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_PASSWORD
            - name: POSTGRES_DB
              value: mydatabase
          volumeMounts:
            - name: db-storage
              mountPath: /var/lib/postgresql/data
      volumes:
        - name: db-storage
          persistentVolumeClaim:
            claimName: db-data
Deployment Redis
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  namespace: advanced-app
spec:
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
        - name: redis
          image: redis:7
          args: ["redis-server", "--appendonly", "yes"]
          volumeMounts:
            - name: redis-storage
              mountPath: /data
      volumes:
        - name: redis-storage
          persistentVolumeClaim:
            claimName: redis-data
Deployment NGINX
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  namespace: advanced-app
spec:
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx:latest
          ports:
            - containerPort: 80
          volumeMounts:
            - name: nginx-config
              mountPath: /etc/nginx/nginx.conf
              subPath: nginx.conf 
              # Mounts the nginx.conf from ConfigMap as a file inside /etc/nginx/
      volumes:
        - name: nginx-config
          configMap:
            name: nginx-config
Services
apiVersion: v1
kind: Service
metadata:
  name: app-service
spec:
  selector:
    app: app
  ports:
    - port: 3000
      targetPort: 3000
  type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
  name: db
spec:
  selector:
    app: db
  ports:
    - port: 5432
      targetPort: 5432
  type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
  name: redis
spec:
  selector:
    app: redis
  ports:
    - port: 6379
      targetPort: 6379
  type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
  name: nginx
spec:
  selector:
    app: nginx
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30080
  type: NodePort
  • In this application, the database uses Pod and PVC, in production environment usually the database is seperated to another dedicated database instance or VM
    • PostgreSQL as an example, we need to configure pg_hba.conf to allow IPv4 traffic to certain Kubernetes nodes
  • We would need a StatefulSet to deploy PostgreSQL in Kubernetes, but for simplicity’s sake we use Deployment
Quickstart
  • Below is a foundational YAML used to deploy and expose a service
  • Make sure to add the required Secrets before applying the manifest file below
apiVersion: apps/v1
kind: Deployment
metadata:
  name: calendar-app
  namespace: calendar-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: calendar-app
  template:
    metadata:
      labels:
        app: calendar-app
    spec:
      containers:
      - name: calendar-app
        image: registry.lgkentang.com/lgk/calendar-app
        imagePullPolicy: Always
        workingDir: /usr/src/app
        ports:
        - containerPort: 6666
        command: ["npm"]
        args: ["start"]
        envFrom:
          - secretRef:
              name: calendar-app-secret
      imagePullSecrets:
      - name: registry-credential
---
apiVersion: v1
kind: Service
metadata:
  name: calendar-app
spec:
  type: NodePort
  ports:
  - protocol: TCP
    port: 8484
    targetPort: 6666
  selector:
    app: calendar-app

Kubernetes Concepts

Secret in Kubernetes
  • Secrets should not be placed in any repositories or container images as they expose it directly.

  • Types of Secrets:

    • Docker Registry Secret (Useful for Private Docker Registry usage)
    • Generic (common text)
    • TLS
  • Docker Registry

kubectl create secret docker-registry my-registry-secret \
  --docker-server=REGISTRY_URL \
  --docker-username=YOUR_USERNAME \
  --docker-password=YOUR_PASSWORD \
  --docker-email=YOUR_EMAIL \
  -n YOUR_NAMESPACE
  • Environment Variables (generic)
  • Take advantage of the --from-env-file option to easily create a secret in environment variable fashion
# /home/calendar-app
root@master1:/home# vi .env
# .env
POSTGRES_USER=admin
POSTGRES_PASSWORD=securepassword
# /home/calendar-app
root@master1:/home# kubectl create secret \
generic calendar-app-env -n calendar-app \
--from-env-file=.env
  • calendar-app-env -> secret name

  • -n calendar-app -> target namespace

  • --from-env-file= -> all KV pairs in .env are passed as KV as Secrets

  • In deployment, we just need to append it as envFrom

    spec:
      containers:
      - name: calculator-app
        image: registry.lgkentang.com/lgk/calculator-app
        imagePullPolicy: Always
        workingDir: /usr/src/app
        ports:
        - containerPort: 6666
        command: ["npm"]
        args: ["start"]
        envFrom:
          - secretRef:
              name: calculator-app-secret 
      imagePullSecrets:
      - name: registry-credential
Liveness & Readiness Probes

  • Docker uses HEALTHCHECK which only checks the status of the container
  • Kubernetes have Liveness Probe that is used to check and RESTART the container automatically

Docker Compose Example:

# docker-compose.yml
version: "3.9"
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:80"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 5s

Kubernetes Example:

apiVersion: v1
kind: Pod
metadata:
  name: nginx-pod
spec:
  containers:
    - name: nginx
      image: nginx:alpine
      ports:
        - containerPort: 80
      livenessProbe:
        httpGet:
          path: /
          port: 80
        initialDelaySeconds: 5     # start_period
        periodSeconds: 30          # interval
        timeoutSeconds: 5          # timeout
        failureThreshold: 3        # retries
Mounting .env vs Env Injection
  • Sometimes the application we are trying to deploy are developed with two .env placements, which are:
  1. Needs .env file inside the directory The developed application always refer to the .env in the same WORKDIR, so we use volume mount to solve this problem
  • Take the key value from the Secret that we have created
  • Mount it as .env in the Volume
    spec:
      hostname: calculator-app
      imagePullSecrets:
        - name: registry-credential
      containers:
        - name: calculator-app
          image: registry.lgkentang.com/lgk/calculator-app
          ports:
            - containerPort: 80
          volumeMounts:
            - name: env-volume
              mountPath: /app/.env
              subPath: .env
      volumes:
        - name: env-volume
          secret:
            secretName: calculator-app-secret
  1. Inject .env directly into the container Program reads key value data directly from the container, this is a cleaner way and usually the app already knows how to reference the env data directly from the container
    spec:
      hostname: calculator-app
      imagePullSecrets:
        - name: registry-credential
      containers:
        - name: calculator-app
          image: registry.lgkentang.com/lgk/calculator-app
          envFrom:  # ambil env dari secret
          - secretRef:  
              name: calculator-app-secret
          ports:
            - containerPort: 3040
          workingDir: /usr/src/app
          command: ["npm", "run", "start:prod"]
          volumeMounts:
          - name: timezone
            mountPath: /etc/timezone
            readOnly: true
          - name: localtime
            mountPath: /etc/localtime
            readOnly: true
  • To know whether the app env needs to be injected or to be mounted as a file is just to try each method, we never know what the developer is trying to develop
  • Docker’s best practice is not to COPY .env into the Dockerfile, this creates an exposed secret in the image, hence it is better to inject directly into the container since it is destroyed upon container removal