kubectl with kustomize (built-in since kubectl 1.14)ReadWriteOnce for postgres/redis PVCsReadWriteMany for user home PVCs (NFS, CephFS, Longhorn RWX, etc.)# Backend
docker build -t registry.example.com/lwp/backend:1.0.0 backend/
docker push registry.example.com/lwp/backend:1.0.0
# Frontend
docker build -t registry.example.com/lwp/frontend:1.0.0 frontend/
docker push registry.example.com/lwp/frontend:1.0.0
cd containers
# Build base first (other images depend on it)
docker build -t registry.example.com/lwp/vnc-base:1.0.0 kasm-base/
docker push registry.example.com/lwp/vnc-base:1.0.0
# Per-app images
docker build --build-arg BASE=registry.example.com/lwp/vnc-base:1.0.0 \
-t registry.example.com/lwp/firefox:1.0.0 firefox/
docker push registry.example.com/lwp/firefox:1.0.0
# Or build all with the Makefile:
make all REGISTRY=registry.example.com TAG=1.0.0
Session containers (
lwp-firefox,lwp-chromium, etc.) are not K8s Deployments — they are ephemeral Pods created per-session by the backend. The backend useskubernetes-asynciowith its ServiceAccount to create/delete them.
cp k8s/overlays/prod/secrets.yaml.example k8s/overlays/prod/secrets.yaml
# Edit secrets.yaml — fill in all CHANGE_ME values
# TLS cert/key (base64-encoded):
echo -n "$(base64 -w0 < your-cert.pem)" # → tls.crt
echo -n "$(base64 -w0 < your-key.pem)" # → tls.key
kubectl apply -f k8s/overlays/prod/secrets.yaml
Required secret keys:
| Key | Description |
|---|---|
POSTGRES_PASSWORD |
PostgreSQL password |
SECRET_KEY |
JWT signing key (≥32 random bytes) |
OIDC_ISSUER |
OIDC provider URL (if using OIDC) |
OIDC_CLIENT_ID |
|
OIDC_CLIENT_SECRET |
|
tls.crt |
TLS certificate (PEM) |
tls.key |
TLS private key (PEM) |
Edit k8s/overlays/prod/kustomization.yaml:
images:
- name: registry.example.com/lwp/backend
newTag: "1.0.0"
- name: registry.example.com/lwp/frontend
newTag: "1.0.0"
Set these in k8s/overlays/prod/configmap.yaml or as Secret values:
# Required
AUTH_METHODS=oidc # or oidc,local,ldap
LWP_BASE_URL=https://lwp.example.com
LWP_ENV=production
BACKEND_INTERNAL_URL=http://backend.lwp.svc.cluster.local:8000
# Session app images
# (register these in the admin panel after deploy — no config file needed)
# Optional
MAX_SESSIONS_PER_USER=2
SESSION_TIMEOUT_HOURS=8
kubectl apply -k k8s/overlays/prod/
# Watch rollout
kubectl rollout status deployment/backend -n lwp
kubectl rollout status deployment/frontend -n lwp
kubectl rollout status deployment/nginx -n lwp
kubectl exec -n lwp deploy/backend -- alembic upgrade head
Run after every update that includes new migration files.
Log in as admin → Apps → Add App for each session container image:
| Field | Firefox example |
|---|---|
| Container image | registry.example.com/lwp/firefox:1.0.0 |
| Proxy port | 8080 |
| App type | stream |
| SHM size | 1Gi |
After adding, click Pull image to pre-pull on all nodes and avoid cold-start delay.
kubectl get pods -n lwp
# backend, frontend, nginx, postgres, redis — all Running
curl -k https://lwp.example.com/healthz
# {"status":"ok","env":"production"}
# 1. Build + push new images
# 2. Bump tags in kustomization.yaml
kubectl apply -k k8s/overlays/prod/
kubectl rollout status deployment/backend -n lwp
# 3. Run migrations if needed
kubectl exec -n lwp deploy/backend -- alembic upgrade head
Backend — HPA scales 2–8 replicas at 70% CPU. Stateless; all state in PostgreSQL + Redis.
Nginx — scale manually if needed:
kubectl scale deployment nginx -n lwp --replicas=3
Session pods — one pod per active session; not managed by a Deployment. Created by backend on demand, deleted on session end. They do not autoscale.
When mount_home=true on an app, LWP automatically creates a PVC named lwp-home-{user_id} on first session launch and mounts it at /home/lwp inside the container. Firefox and Thunderbird profiles, browser data, and the symlinked XDG dirs (Documents, Downloads, etc.) all live here.
K8s PVCs start empty, unlike Docker named volumes. lwp-start-kasm.sh handles this: on first launch it copies the baked-in skeleton (VNC config, PulseAudio config, GTK theme) from /etc/lwp/skel/ into the empty PVC and writes a .lwp-initialized sentinel so it never runs again. Firefox and Thunderbird will create their own profile directories on first run.
By default PVCs use standard (ReadWriteOnce). Configure in .env:
HOME_STORAGE_CLASS=standard # or your RWX class
HOME_PVC_SIZE=5Gi
ReadWriteOnce works for single-node clusters and the common case where each user only has one active session. Most CSI drivers also allow multiple pods on the same node to mount the same RWO PVC.
ReadWriteMany (NFS, CephFS, Longhorn RWX) is needed if:
PVCs are created automatically, but you can pre-create them with a different size or storage class:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: lwp-home-<user-uuid>
namespace: lwp
labels:
lwp.managed: "true"
lwp.user: <user-uuid>
spec:
accessModes: [ReadWriteOnce]
storageClassName: fast-nvme
resources:
requests:
storage: 20Gi
PVCs are not deleted when a user’s sessions end — they persist until you explicitly delete them. To delete a user’s home PVC:
kubectl delete pvc -n lwp lwp-home-<user-uuid>
The backend ServiceAccount needs permission to create/delete Pods and Services in the lwp namespace (for session lifecycle). This is already defined in k8s/base/rbac.yaml.
# Manual dump
kubectl exec -n lwp statefulset/postgres -- \
pg_dump -U lwp lwp | gzip > backup-$(date +%F).sql.gz
# Restore
gunzip < backup.sql.gz | \
kubectl exec -i -n lwp statefulset/postgres -- psql -U lwp lwp
For automated backups, deploy a CronJob that runs pg_dump and uploads to object storage (S3, MinIO, etc.).