Kubernetes Deployment

AutoCom uses Kustomize overlays for environment-specific Kubernetes deployments. One set of base manifests, different configs per environment.

Directory Structure

k8s/
├── base/                    # Shared manifests (all environments)
│   ├── kustomization.yaml
│   ├── api.yaml             # Octane + FrankenPHP
│   ├── horizon.yaml         # Queue worker
│   ├── frontend.yaml        # Next.js
│   ├── nginx.yaml           # Reverse proxy
│   └── redis.yaml           # Cache/sessions/queues
├── overlays/
│   ├── local/               # Docker Desktop / K3s dev
│   │   └── kustomization.yaml
│   ├── staging/             # Staging environment
│   │   ├── kustomization.yaml
│   │   └── ingress.yaml
│   └── production/          # Production
│       ├── kustomization.yaml
│       ├── ingress.yaml
│       └── hpa.yaml
└── local/                   # Standalone manifests (pre-Kustomize)
    ├── deploy.sh
    ├── backup.yaml
    └── ...

Quick Deploy

# Local (Docker Desktop K8s or K3s)
kustomize build k8s/overlays/default | kubectl apply -f -

# Staging
kustomize build k8s/overlays/default | kubectl apply -f -

# Production
kustomize build k8s/overlays/default | kubectl apply -f -

Environment Differences

Config Local Staging Production
API replicas 1 2 3 (HPA 3→10)
Frontend replicas 1 2 2 (HPA 2→5)
API URL localhost:30080 staging.autocom.io autocom.io
Service type NodePort ClusterIP + Ingress ClusterIP + Ingress
TLS None Let's Encrypt staging Let's Encrypt prod
APP_DEBUG true true false
Images Local (imagePullPolicy: Never) Registry Registry

Container Registry

Images are built by GitLab CI and pushed to registry.wexron.io:

Push to main → GitLab CI builds → registry.wexron.io/autocommerce/main/api:latest
                                → registry.wexron.io/autocommerce/main/frontend:latest
                                → registry.wexron.io/autocommerce/main/horizon:latest

Registry Access

K8s needs a pull secret to access the GitLab registry:

bash bin/vps-deploy/02-deploy-k3s-stack.sh <gitlab-token>

This creates a gitlab-registry docker-registry secret. All deployments reference it via imagePullSecrets.

Image Tagging

Tag Purpose
latest Most recent build from main
<commit-sha> Specific version for rollbacks

Runtime Environment Injection

The frontend uses NEXT_PUBLIC_* variables which are normally baked at build time. AutoCom solves this with a runtime entrypoint script:

  1. Build time: Dockerfile uses placeholder strings (__NEXT_PUBLIC_API_URL__)
  2. Startup: entrypoint.sh replaces placeholders with actual env vars via sed
  3. Result: One image works on any deployment — just set the env var
# In K8s deployment
env:
  - name: NEXT_PUBLIC_API_URL
    value: "https://api.autocom.io/api/v1"  # Set per environment

No rebuild needed when changing the API URL.

Database: CloudNativePG

PostgreSQL runs via the CloudNativePG operator. The default overlay deploys a 3-instance cluster (1 primary + 2 streaming replicas).

One-time setup

# 1. Install the CNPG operator (cluster-wide)
helm repo add cnpg https://cloudnative-pg.github.io/charts
helm repo update
helm install cnpg cnpg/cloudnative-pg \
  --namespace cnpg-system --create-namespace --wait

# 2. Create the bootstrap secrets
#    — re-use the same DB password your app already uses (autocom-secrets/DB_PASSWORD)
DB_PASS=$(kubectl -n autocom-k8s get secret autocom-secrets \
  -o jsonpath='{.data.DB_PASSWORD}' | base64 -d)

kubectl -n autocom-k8s create secret generic autocom-db-app \
  --from-literal=username=autocom \
  --from-literal=password="$DB_PASS"

kubectl -n autocom-k8s create secret generic autocom-db-superuser \
  --from-literal=username=postgres \
  --from-literal=password="$DB_PASS"

# 3. Apply the overlay — the postgresql.cnpg.io/v1 Cluster comes up here
kubectl apply -k k8s/overlays/default

CNPG creates three services automatically:

Service Routes to Use for
autocom-db-rw Primary only Writes (DB_HOST)
autocom-db-ro Replicas only Reads (DB_READ_HOST)
autocom-db-r Any instance Backups, ad-hoc queries

Laravel's config/database.php is already wired for read/write splitting with sticky=true, so writes go to DB_HOST and reads go to DB_READ_HOST. After a write within a request, subsequent reads stick to the primary to avoid replication-lag surprises.

Network policy

The default-deny-ingress policy in the namespace requires an explicit allow rule for the operator. The overlay ships an allow-cnpg NetworkPolicy that:

  • accepts 5432 from in-namespace clients,
  • accepts 8000 from the cnpg-system namespace (operator status API),
  • accepts 9187 from the monitoring namespace (metrics scrape),
  • allows replica streaming between instances,
  • allows egress to 0.0.0.0/0:6443 and :443 (CNPG instances must reach the kube-apiserver, which on k3s runs on the host, not as a pod).

Storage

Each instance gets its own 20 GiB PVC on the cluster's default StorageClass (local-path on k3s). The namespace ResourceQuota must allow at least 60 GiB of requests.storage for the three CNPG PVCs plus your backup PVC.

External database alternative

If you'd rather use a managed Postgres (Neon, Supabase, RDS, Cloud SQL):

  1. Skip the operator install and the CNPG Cluster in the overlay.
  2. Set DB_HOST and DB_READ_HOST in the ConfigMap to your managed endpoints (use the same endpoint twice if there's no read replica).
  3. Make sure the cluster egress NetworkPolicy allows traffic to the managed DB host/port.

Deploy to Edge Device (Pi 5 / K3s)

No builds needed on the device — just pull from registry:

# 1. Install K3s
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable traefik" sh -

# 2. Create namespace + registry secret
k3s kubectl create namespace autocom
k3s kubectl create secret docker-registry gitlab-registry \
  --namespace=autocom \
  --docker-server=registry.wexron.io \
  --docker-username=deploy \
  --docker-password=<gitlab-token>

# 3. Generate secrets
APP_KEY="base64:$(openssl rand -base64 32)"
DB_PASSWORD="autocom_$(openssl rand -hex 8)"
k3s kubectl create secret generic autocom-secrets -n autocom \
  --from-literal=APP_KEY="$APP_KEY" \
  --from-literal=DB_PASSWORD="$DB_PASSWORD" \
  --from-literal=REDIS_PASSWORD="" \
  --from-literal=AGENT_SHARED_SECRET="$(openssl rand -hex 32)"

# 4. Deploy (pulls images from registry)
k3s kubectl apply -k k8s/overlays/default

# 5. Run migrations
k3s kubectl exec -n autocom deployment/api -- php artisan migrate --force
k3s kubectl exec -n autocom deployment/api -- php artisan db:seed --force
k3s kubectl exec -n autocom deployment/api -- php artisan module:sync

Total deploy time: ~5 minutes (image pull + DB setup). Zero compilation on the device.

Secrets Management

Secrets are generated at deploy time, never stored in git:

Secret Contains Generated by
autocom-secrets APP_KEY, DB_PASSWORD, AGENT_SECRET deploy.sh or manually
passport-keys OAuth private/public keys php artisan passport:keys
gitlab-registry Docker registry credentials registry-secret.sh
autocom-db-app App role (autocom) for the CNPG cluster manually (matches autocom-secrets/DB_PASSWORD)
autocom-db-superuser Postgres superuser for CNPG manually (matches autocom-secrets/DB_PASSWORD)

Environment Variables

See k8s/ENV_REFERENCE.md for the complete list of all environment variables per service.

Post-Deploy Commands

# Generate app key (first deploy)
kubectl exec -n autocom deployment/api -- php artisan key:generate --force

# Run migrations
kubectl exec -n autocom deployment/api -- php artisan migrate --force

# Generate Passport keys
kubectl exec -n autocom deployment/api -- php artisan passport:keys --force
kubectl exec -n autocom deployment/api -- php artisan passport:client --personal --name="AutoCom"

# Sync modules
kubectl exec -n autocom deployment/api -- php artisan module:sync

# Seed initial data
kubectl exec -n autocom deployment/api -- php artisan db:seed --force

# Optimize (production)
kubectl exec -n autocom deployment/api -- php artisan optimize

Monitoring

# Pod status
kubectl get pods -n autocom

# Resource usage
kubectl top pods -n autocom

# HPA status
kubectl get hpa -n autocom

# CloudNativePG cluster health
kubectl get cluster -n autocom

# API logs
kubectl logs -n autocom deployment/api --tail=50

# Octane worker status
kubectl exec -n autocom deployment/api -- php artisan octane:status