Kubernetes & Helm
CWA runs well on Kubernetes. Each service maps cleanly to a Deployment, and the stateless PHP and Nuxt containers make rolling updates straightforward.
The Helm chart lives in the template repository at helm/cwa/ — it is included when you generate a project from the components-web-app template. There is no separate Helm registry; you own the chart and modify it as needed.
What the Helm Chart Deploys
| Resource | Type | Description |
|---|---|---|
php | Deployment | FrankenPHP serving the Symfony API |
nuxt | Deployment | Node SSR server |
mercure | Deployment | Caddy + Mercure hub |
php, nuxt, mercure | Service | Internal cluster services |
php, nuxt, mercure | Ingress | External routing with TLS |
Database is typically a managed external service (Cloud SQL, RDS, etc.) rather than an in-cluster StatefulSet for production.
Minimum values.yaml
php:
image:
repository: ghcr.io/your-org/app-php
tag: latest
env:
DATABASE_URL: "postgresql://user:pass@cloud-sql/app"
MERCURE_URL: "http://mercure/.well-known/mercure"
MERCURE_PUBLIC_URL: "https://mercure.example.com/.well-known/mercure"
envFrom:
- secretRef:
name: cwa-secrets
nuxt:
image:
repository: ghcr.io/your-org/app-nuxt
tag: latest
env:
NUXT_PUBLIC_CWA_API_URL: "http://php-service"
NUXT_PUBLIC_CWA_API_URL_BROWSER: "https://api.example.com"
envFrom:
- secretRef:
name: cwa-secrets
mercure:
image:
repository: dunglas/mercure
tag: latest
envFrom:
- secretRef:
name: cwa-secrets
ingress:
enabled: true
annotations:
cert-manager.io/cluster-issuer: letsencrypt
hosts:
api: api.example.com
www: www.example.com
mercure: mercure.example.com
Managing Secrets
Never hardcode secrets in values.yaml. Create a Kubernetes Secret:
kubectl create secret generic cwa-secrets \
--from-literal=JWT_PASSPHRASE=your_jwt_passphrase \
--from-literal=MERCURE_JWT_SECRET=your_mercure_secret \
--from-literal=DATABASE_URL="postgresql://..." \
--from-literal=APP_SECRET=your_app_secret
For production use External Secrets Operator to sync from AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault.
JWT Keys as Kubernetes Secrets
Generate JWT keys locally, then create a Secret:
kubectl create secret generic jwt-keys \
--from-file=private.pem=config/jwt/private.pem \
--from-file=public.pem=config/jwt/public.pem
Mount into the PHP pod as a volume in values.yaml:
php:
volumes:
- name: jwt-keys
secret:
secretName: jwt-keys
volumeMounts:
- name: jwt-keys
mountPath: /var/www/html/config/jwt
readOnly: true
Running Migrations
Run migrations as a Kubernetes Job before updating the PHP Deployment. Use a Helm pre-upgrade hook:
# templates/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: "{{ .Release.Name }}-migrations-{{ .Release.Revision }}"
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "-1"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrations
image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag }}"
command: ["bin/console", "doctrine:migrations:migrate", "--no-interaction"]
envFrom:
- secretRef:
name: cwa-secrets
This ensures migrations complete before any new pod receives traffic — no race conditions from parallel pod starts.
Ingress with TLS
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts: [api.example.com]
secretName: api-tls
- hosts: [www.example.com]
secretName: www-tls
rules:
- host: api.example.com
http:
paths:
- path: /
backend:
service:
name: php-service
port:
number: 80
- host: www.example.com
http:
paths:
- path: /
backend:
service:
name: nuxt-service
port:
number: 3000
Resource Requests and Limits (Starting Point)
php:
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
nuxt:
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 256Mi
Tune based on your traffic profile. FrankenPHP is efficient; Nuxt SSR memory usage grows with concurrent requests.
Health Checks
The PHP (FrankenPHP) pod uses a TCP socket for liveness — it just checks the port is listening — and /_api/_/site_config_parameters.jsonld for readiness, which confirms Symfony is fully booted and the database is reachable.
The Nuxt pod uses /_cwa/healthcheck for readiness. This endpoint is a server route provided by the @cwa/nuxt module (server/routes/_cwa/cwa-healthcheck.get.ts) — it returns 200 when the Nuxt server is running.
php:
startupProbe:
tcpSocket:
port: 80
failureThreshold: 30
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 80
initialDelaySeconds: 5
periodSeconds: 5
readinessProbe:
httpGet:
path: /_api/_/site_config_parameters.jsonld
port: 80
initialDelaySeconds: 5
periodSeconds: 10
nuxt:
readinessProbe:
httpGet:
path: /_cwa/healthcheck
port: 3000
Rolling Updates
Both PHP and Nuxt use RollingUpdate strategy. Old pods continue serving until new pods pass their readiness probe, ensuring zero downtime.
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
Rollback
If a deploy fails:
helm rollback cwa # revert to previous Helm release
kubectl get pods -w # watch the rollback progress
Helm tracks release history. The migration Job won't re-run on rollback (it only runs on install/upgrade), so a database rollback requires a separate migration reversal if needed.