Kubernetes for Laravel: From Docker to Production K8s

· 8 min read

You've containerized your Laravel app with Docker. Now you need to run it at scale — multiple instances, auto-scaling, zero-downtime deploys, and self-healing. That's Kubernetes.

This guide takes you from a Docker container to a production-ready Kubernetes deployment. Each component includes explanations of why it's configured that way, not just how.

Architecture Overview

                    ┌─────────────┐
                    │   Ingress   │  (TLS termination, routing)
                    └──────┬──────┘
                           │
                    ┌──────┴──────┐
                    │   Service   │  (Load balancing)
                    └──────┬──────┘
            ┌──────────────┼──────────────┐
      ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
      │  Pod (web) │ │  Pod (web) │ │  Pod (web) │
      └───────────┘ └───────────┘ └───────────┘
                    ┌──────────────┐
                    │ Queue Workers │  (Separate Deployment)
                    └──────────────┘
                    ┌──────────────┐
                    │  Scheduler   │  (CronJob, every minute)
                    └──────────────┘

Prerequisites

  • Docker image of your Laravel app
  • A Kubernetes cluster (minikube for local, EKS/GKE/AKS for production)
  • kubectl configured
  • Basic understanding of Docker

Step 1: The Docker Image

# Dockerfile

FROM php:8.3-fpm-alpine AS base

RUN apk add --no-cache \
    nginx \
    supervisor \
    && docker-php-ext-install pdo_mysql opcache

COPY docker/php.ini /usr/local/etc/php/conf.d/custom.ini
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf

# ---

FROM base AS composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist

# ---

FROM base AS production
WORKDIR /var/www/html

COPY --from=composer /var/www/html/vendor ./vendor
COPY . .

RUN php artisan config:cache \
    && php artisan route:cache \
    && php artisan view:cache \
    && chown -R www-data:www-data storage bootstrap/cache

EXPOSE 80

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
# Build and push
docker build -t your-registry/laravel-app:1.0.0 .
docker push your-registry/laravel-app:1.0.0

Step 2: Kubernetes Namespace

# k8s/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: laravel-app

Step 3: ConfigMap & Secrets

# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: laravel-config
  namespace: laravel-app
data:
  APP_NAME: "My Laravel App"
  APP_ENV: "production"
  APP_DEBUG: "false"
  APP_URL: "https://myapp.com"
  LOG_CHANNEL: "stderr"
  DB_CONNECTION: "mysql"
  DB_HOST: "mysql-service"
  DB_PORT: "3306"
  DB_DATABASE: "laravel"
  CACHE_DRIVER: "redis"
  SESSION_DRIVER: "redis"
  QUEUE_CONNECTION: "redis"
  REDIS_HOST: "redis-service"
# k8s/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
  name: laravel-secrets
  namespace: laravel-app
type: Opaque
stringData:
  APP_KEY: "base64:your-app-key-here"
  DB_USERNAME: "laravel"
  DB_PASSWORD: "your-secure-password"
  REDIS_PASSWORD: "your-redis-password"

Step 4: Deployment

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: laravel-app
  namespace: laravel-app
  labels:
    app: laravel
spec:
  replicas: 3
  selector:
    matchLabels:
      app: laravel
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0  # Zero downtime
  template:
    metadata:
      labels:
        app: laravel
    spec:
      containers:
        - name: laravel
          image: your-registry/laravel-app:1.0.0
          ports:
            - containerPort: 80
          envFrom:
            - configMapRef:
                name: laravel-config
            - secretRef:
                name: laravel-secrets
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          readinessProbe:
            httpGet:
              path: /health
              port: 80
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 80
            initialDelaySeconds: 15
            periodSeconds: 20
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 10"]  # Grace period
      initContainers:
        - name: migrate
          image: your-registry/laravel-app:1.0.0
          command: ["php", "artisan", "migrate", "--force"]
          envFrom:
            - configMapRef:
                name: laravel-config
            - secretRef:
                name: laravel-secrets

Health Check Endpoint

// routes/web.php
Route::get('/health', function () {
    try {
        DB::connection()->getPdo();
        Redis::ping();

        return response()->json([
            'status' => 'healthy',
            'database' => true,
            'redis' => true,
        ], 200);
    } catch (\Exception $e) {
        return response()->json([
            'status' => 'unhealthy',
            'error' => $e->getMessage(),
        ], 503);
    }
});

Step 5: Service

# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: laravel-service
  namespace: laravel-app
spec:
  selector:
    app: laravel
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: ClusterIP

Step 6: Ingress (External Access)

# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: laravel-ingress
  namespace: laravel-app
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/proxy-body-size: "50m"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - myapp.com
      secretName: laravel-tls
  rules:
    - host: myapp.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: laravel-service
                port:
                  number: 80

Step 7: Queue Worker Deployment

# k8s/queue-worker.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: laravel-queue-worker
  namespace: laravel-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: laravel-queue
  template:
    metadata:
      labels:
        app: laravel-queue
    spec:
      containers:
        - name: queue-worker
          image: your-registry/laravel-app:1.0.0
          command: ["php", "artisan", "queue:work", "--sleep=3", "--tries=3", "--max-time=3600"]
          envFrom:
            - configMapRef:
                name: laravel-config
            - secretRef:
                name: laravel-secrets
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"

Step 8: Cron Job (Scheduler)

# k8s/scheduler.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: laravel-scheduler
  namespace: laravel-app
spec:
  schedule: "* * * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: scheduler
              image: your-registry/laravel-app:1.0.0
              command: ["php", "artisan", "schedule:run"]
              envFrom:
                - configMapRef:
                    name: laravel-config
                - secretRef:
                    name: laravel-secrets
          restartPolicy: OnFailure

Step 9: Horizontal Pod Autoscaler

# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: laravel-hpa
  namespace: laravel-app
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: laravel-app
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80

Step 10: Redis & MySQL

# k8s/redis.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  namespace: laravel-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
        - name: redis
          image: redis:7-alpine
          ports:
            - containerPort: 6379
          resources:
            limits:
              memory: "256Mi"
---
apiVersion: v1
kind: Service
metadata:
  name: redis-service
  namespace: laravel-app
spec:
  selector:
    app: redis
  ports:
    - port: 6379
      targetPort: 6379

Production note: For MySQL and Redis in production, use managed services (RDS, ElastiCache) instead of running them in K8s.

Deploy Everything

kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/secrets.yaml
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/ingress.yaml
kubectl apply -f k8s/queue-worker.yaml
kubectl apply -f k8s/scheduler.yaml
kubectl apply -f k8s/hpa.yaml
kubectl apply -f k8s/redis.yaml

# Check status
kubectl get pods -n laravel-app
kubectl get services -n laravel-app
kubectl get ingress -n laravel-app

Pod Disruption Budget (PDB)

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

PDB protects availability during voluntary disruptions: node drain, cluster upgrade, spot instance reclaim. K8s will NOT evict pods if it would violate the PDB.

Network Policy

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: laravel-network-policy
  namespace: laravel-app
spec:
  podSelector:
    matchLabels:
      app: laravel
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
      ports:
        - port: 80
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: mysql
      ports:
        - port: 3306
    - to:
        - podSelector:
            matchLabels:
              app: redis
      ports:
        - port: 6379
    - to:
        - namespaceSelector: {}
      ports:
        - port: 53
          protocol: TCP
        - port: 53
          protocol: UDP

Network Policy = firewall at pod level. By default K8s allows all traffic between pods — not secure. This policy restricts: web pods only receive traffic from Ingress, only connect to MySQL + Redis.

CI/CD Pipeline (GitHub Actions)

name: Deploy to K8s
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build & push Docker image
        run: |
          docker build -t ${{ secrets.REGISTRY }}/laravel-app:${{ github.sha }} .
          docker push ${{ secrets.REGISTRY }}/laravel-app:${{ github.sha }}

      - name: Deploy to K8s
        run: |
          kubectl set image deployment/laravel-app \
            laravel=${{ secrets.REGISTRY }}/laravel-app:${{ github.sha }} \
            -n laravel-app

          kubectl rollout status deployment/laravel-app \
            -n laravel-app \
            --timeout=300s

Use git SHA as image tag — each commit = unique image. Don't use latest tag — K8s may not pull a new image if the tag hasn't changed.

Useful kubectl Commands

# === Logs & Debugging ===
kubectl logs -f deployment/laravel-app -n laravel-app
kubectl logs deployment/laravel-app -n laravel-app --previous
kubectl logs -l component=queue -n laravel-app --tail=100

# === Interact ===
kubectl exec -it deployment/laravel-app -n laravel-app -- sh
kubectl exec -it deployment/laravel-app -n laravel-app -- php artisan migrate:status
kubectl exec -it deployment/laravel-app -n laravel-app -- php artisan tinker

# === Scaling ===
kubectl scale deployment laravel-app --replicas=5 -n laravel-app
kubectl get hpa -n laravel-app -w

# === Deployments ===
kubectl rollout status deployment/laravel-app -n laravel-app
kubectl rollout undo deployment/laravel-app -n laravel-app
kubectl rollout history deployment/laravel-app -n laravel-app

# === Monitoring ===
kubectl top pods -n laravel-app
kubectl get events -n laravel-app --sort-by='.lastTimestamp'

Production Checklist

✅ Docker
  □ Multi-stage build (small, fast)
  □ Non-root user
  □ OPcache enabled + validate_timestamps=0
  □ Config/route/view cached

✅ Kubernetes
  □ Separate namespace
  □ 3 probes: startup, readiness, liveness
  □ Resource requests + limits
  □ RollingUpdate with maxUnavailable=0
  □ terminationGracePeriodSeconds configured
  □ PDB (Pod Disruption Budget)

✅ Security
  □ Secrets NOT in Git (Sealed Secrets/Vault)
  □ Network Policies
  □ Non-root container
  □ Read-only filesystem (if possible)

✅ Scaling
  □ HPA with behavior config
  □ Queue workers separated
  □ Scheduler with concurrencyPolicy: Forbid

✅ Observability
  □ LOG_CHANNEL=stderr
  □ Health check endpoint
  □ Prometheus metrics (optional)
  □ kubectl top monitoring

Conclusion

Kubernetes for Laravel follows a clear pattern:

  1. Deployment (web) — Laravel app with 3 probes, graceful shutdown
  2. Deployment (queue) — separate workers, --max-time prevents memory leaks
  3. CronJob (scheduler)concurrencyPolicy: Forbid, every minute
  4. Service + Ingress — TLS, rate limiting, routing
  5. HPA — auto-scale with behavior controls, avoid flapping
  6. PDB — protect availability during maintenance
  7. Network Policy — pod-level firewall
  8. Secrets management — Sealed Secrets or Vault, never commit plaintext

K8s has a steep learning curve, but once configured correctly, it handles scaling, healing, and zero-downtime deploys automatically. Start simple (Deployment + Service + Ingress), add complexity as needed.

Comments