The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
DraftDeployment

Kubernetes & Helm

Deploying the CWA stack to Kubernetes using Helm — values configuration, secrets management, migration Jobs, and rolling updates.

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

ResourceTypeDescription
{{fullname}}DeploymentFrankenPHP — Symfony API, Caddy front door, and the embedded Mercure hub
{{fullname}}-pwaDeploymentNuxt SSR server, port 3000
{{fullname}}, {{fullname}}-pwaServiceOne per Deployment, both ClusterIP
{{fullname}}IngressA single Ingress, routing everything to the php Service
{{fullname}}HorizontalPodAutoscalerEnabled by default (autoscaling.enabled)
postgresqlSubchartBundled Bitnami PostgreSQL, enabled by default

There is no Mercure Deployment — the hub runs as a Caddy module inside the php container. FrankenPHP serves /_api/* itself and reverse-proxies everything else to the pwa Service via APP_UPSTREAM, so the Nuxt Service is cluster-internal and should never be exposed directly.

The chart bundles the Bitnami PostgreSQL subchart and enables it by default, with persistence switched off — fine for review apps, not for production. For production set postgresql.enabled: false (CI variable POSTGRESQL_ENABLED=false) and supply postgresql.url pointing at your managed instance (Cloud SQL, RDS, etc.).

Minimum values.yaml

The chart does not accept arbitrary env or envFrom blocks. Every environment variable on both pods is templated from the chart's own ConfigMap and Secret, driven by structured values. helm/cwa/values.yaml is the authoritative list — the keys below are the ones you almost always need to set.

php:
    image:
        repository: ghcr.io/your-org/app-php
        tag: v1.2.3
    appSecret: ""                 # generated if left empty
    corsAllowOrigin: "^https://www\\.example\\.com$"
    trustedHosts: "^www\\.example\\.com$"
    jwt:
        secret: ""                # private key contents
        public: ""                # public key contents
        passphrase: ""
        samesite: lax
    admin:
        username: admin
        password: ""
        email: hello@example.com

pwa:
    image:
        repository: ghcr.io/your-org/app-nuxt
        tag: v1.2.3
    apiUrl: ~                      # defaults to http://<fullname> (the php Service)
    apiUrlBrowser: ~               # defaults to https://<fullname>

mercure:
    publicUrl: https://www.example.com/.well-known/mercure
    corsOrigin: https://www.example.com
    jwtKey:
        publisher:
            key: "at-least-256-bits"
        subscriber:
            key: "at-least-256-bits"

postgresql:
    enabled: false
    url: "pgsql://user:pass@cloud-sql/app?serverVersion=16&charset=utf8"

ingress:
    enabled: true
    annotations:
        cert-manager.io/cluster-issuer: letsencrypt
    hosts:
        - host: www.example.com
          paths:
              - path: /
                pathType: Prefix
    tls:
        - secretName: www-tls
          hosts: [www.example.com]
ingress.hosts is a list of { host, paths } objects, not a map of roles to hostnames. The chart iterates it, and the first entry also becomes BROWSER_SERVER_NAME on the php pod — so put your primary host first.

There are no per-role hosts. One host serves both the API and the front-end: /_api/* is handled by Symfony, /.well-known/mercure by the embedded hub, and every other path is proxied to the Nuxt pod.

Managing Secrets

Never hardcode secrets in a committed values.yaml. The chart renders its own Kubernetes Secret from the values above (php.appSecret, php.jwt.*, php.admin.*, mercure.jwtKey.*, postgresql.url, php.mailer.dsn, php.gcloud.jsonKey, php.databaseSSL.*), so supply them at deploy time instead:

helm upgrade --install cwa ./helm/cwa \
    -f values.production.yaml \
    --set php.appSecret="$APP_SECRET" \
    --set php.jwt.passphrase="$JWT_PASSPHRASE" \
    --set mercure.jwtKey.publisher.key="$MERCURE_JWT_SECRET" \
    --set mercure.jwtKey.subscriber.key="$MERCURE_JWT_SECRET"

The template's GitLab pipeline does exactly this — bin/devops/k8s.sh generates a values file from CI variables. For production, External Secrets Operator can sync from AWS Secrets Manager, GCP Secret Manager or HashiCorp Vault into the values you pass to Helm.

JWT Keys

Generate the key pair once, then pass the contents as values — the chart puts the private key and passphrase in its Secret and the public key in its ConfigMap, and Symfony reads them from JWT_SECRET_KEY / JWT_PUBLIC_KEY. There is no volume mount to configure:

helm upgrade --install cwa ./helm/cwa \
    --set-file php.jwt.secret=config/jwt/private.pem \
    --set-file php.jwt.public=config/jwt/public.pem \
    --set php.jwt.passphrase="$JWT_PASSPHRASE"

Running Migrations

The chart has no migration Job. The php container's entrypoint runs doctrine:migrations:migrate --no-interaction --all-or-nothing on every start, before the readiness probe can pass.

That is safe at one replica but races once you scale php out or let the HPA add pods. For a multi-replica deployment, disable the entrypoint migration and add a pre-upgrade,pre-install Helm hook Job running the same command against {{ .Values.php.image }}, so migrations complete before any new pod receives traffic.

Ingress with TLS

The chart's Ingress has a single backend — the php Service. Do not add a rule pointing at the Nuxt Service: it bypasses the API, the Mercure hub and the Souin cache, all of which live behind Caddy.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
    annotations:
        cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
    tls:
        - hosts: [www.example.com]
          secretName: www-tls
    rules:
        - host: www.example.com
          http:
              paths:
                  - path: /
                    pathType: Prefix
                    backend:
                        service:
                            name: cwa          # the php Service — {{ fullname }}
                            port:
                                number: 80

Resource Requests and Limits (Starting Point)

php:
    resources:
        requests:
            cpu: 100m
            memory: 256Mi
        limits:
            memory: 512Mi

pwa:
    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.

Both sets of probes are hard-coded in the chart's Deployment templates rather than exposed through values.yaml — this is what they render as, on the php container:

startupProbe:
    tcpSocket:
        port: http
    failureThreshold: 30
    periodSeconds: 10
livenessProbe:
    tcpSocket:
        port: http
    initialDelaySeconds: 5
    periodSeconds: 5
readinessProbe:
    httpGet:
        path: /_api/_/site_config_parameters.jsonld
        port: http
        httpHeaders:
            - name: Accept
              value: application/ld+json,application/json
    initialDelaySeconds: 30
    periodSeconds: 10
    failureThreshold: 3

The pwa container gets the same TCP startup and liveness probes on port 3000, with /_cwa/healthcheck for readiness.

Rolling Updates

Both Deployments use the RollingUpdate strategy, with these values hard-coded in templates/deployment.yaml and templates/pwa-deployment.yaml:

strategy:
    type: RollingUpdate
    rollingUpdate:
        maxSurge: 2
        maxUnavailable: "25%"

They are not exposed through values.yaml. Old pods keep serving while new ones start, but maxUnavailable: "25%" means a rollout can briefly reduce capacity — edit the chart templates directly if you need maxUnavailable: 0 for strict zero downtime.

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. Rolling back the release restarts the php pods on the older image — and because migrations run from the entrypoint, that older image will run its own migrations on start. A schema rollback still needs a deliberate down-migration; Helm won't do it for you.