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}}HorizontalPodAutoscalerphp — enabled by default (autoscaling.enabled), capped at one replica
{{fullname}}-pwaHorizontalPodAutoscalerNuxt SSR — enabled by default (pwa.autoscaling.enabled)
{{fullname}}-pwaPodDisruptionBudgetNuxt SSR — maxUnavailable: 1, so a node drain never takes out every SSR pod at once
{{fullname}}-orphan-scanCronJobThe daily orphaned-resource scan. Off unless cronjobs.orphanScan.enabled; the pipeline turns it on for production only (see Daily Orphan Scan)
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$"   # several hosts: "^(?:www\\.example\\.com|php)$", see TRUSTED_HOSTS in CI/CD
    jwt:
        secret: "…"                # private key contents
        public: "…"                # public key contents
        passphrase: "…"
        samesite: lax
    admin:
        username: admin
        password: "…"
        email: hello@example.com
    gcloud:
        bucket: my-media-bucket
        publicUrl: https://cdn.example.com/   # optional, trailing slash required

pwa:
    image:
        repository: ghcr.io/your-org/app-nuxt
        tag: v1.2.3
    apiUrl: ~                      # leave unset: defaults to http://<fullname>/_api (in-cluster)
    apiUrlBrowser: https://www.example.com/_api   # your public URL

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.

apiUrl should stay unset

pwa.apiUrl is the URL the Nuxt server uses to reach the API while rendering. Its default is the in-cluster php Service, http://<fullname>/_api. Leave it unset. Pointing it at your public URL still works, but every server-side render then leaves the cluster and comes back in through the load balancer and ingress over TLS — once per API call, and a page makes dozens of them.

pwa.apiUrlBrowser is the opposite: the browser uses it, so it must be your public URL. The template's pipeline sets it for you.

Links in API responses still carry your public hostname when SSR goes through the internal Service. Caddy rewrites the Host header on in-cluster requests to the first ingress.hosts entry, so the @id values the Nuxt server receives match the ones a browser would.

Media URLs

php.gcloud.publicUrl is the base URL for uploaded media and for cached image URLs, for example https://cdn.example.com/. Set it to your CDN if you have one in front of the bucket. It must end in a slash.

If you leave it empty, it falls back to the bucket's own public URL, https://storage.googleapis.com/<bucket>/. Media links then work without a CDN, and you never serve files from somebody else's domain by mistake.

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.

This is safe at one replica, which is the chart's default (see Autoscaling). It races if you run more than one php pod. In that case, disable the entrypoint migration and add a pre-upgrade,pre-install Helm hook Job that runs the same command against {{ .Values.php.image }}. Migrations then finish before any new pod receives traffic.

Daily Orphan Scan

The chart can run silverback:api-components:scan-orphaned on a schedule, as the CronJob {{fullname}}-orphan-scan (templates/orphan-scan-cronjob.yaml). Each run refreshes the report the admin's Orphaned Resources page reads, and emails MAILER_EMAIL when the orphans have changed since the last alert. It never deletes anything; deleting stays an admin action at /_cwa/orphaned.

cronjobs:
    orphanScan:
        enabled: false             # the pipeline sets true for production only
        schedule: "0 3 * * *"
        timeZone: "Europe/London"
        resources:
            requests:
                cpu: 50m
                memory: 128Mi
            limits:
                memory: 512Mi

The template's pipeline enables it on production only. Staging and canary can share production's database, so their scans would send the same alert again, and review apps are short-lived. Change this with the ORPHAN_SCAN, ORPHAN_SCAN_SCHEDULE and ORPHAN_SCAN_TIMEZONE variables; see CI/CD → Orphan Scan.

The job uses the php image and runs through its entrypoint, like the API pod, so it waits for the database and runs the migrations first. Once the API has migrated, that step does nothing. Only one run happens at a time (concurrencyPolicy: Forbid), a run that can't start within an hour of its time is skipped, and a run is stopped after 15 minutes.

timeZone needs Kubernetes 1.27 or later. On an older cluster the scan runs at 03:00 UTC, not London time.
For chart authors: the php container's environment is defined once, in the cwa.phpEnv helper in templates/_helpers.tpl, and both the API Deployment and the CronJob include it. Add or change php environment variables there, not in deployment.yaml, or the scan runs with a different environment from the API. The job's pods have their own app.kubernetes.io/name label (<name>-orphan-scan), so the API Service never sends traffic to them.
Upgrading: the scan needs the orphaned-resource report table from api-components-bundle 2.0.0-alpha.6. Run that migration before you enable the CronJob; the template's entrypoint does it when the API pod starts. See the template's CHANGELOG.md.

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

These are the chart's defaults:

php:
    resources:
        requests:
            cpu: 200m
            memory: 350Mi
        limits:
            memory: 1Gi            # no CPU limit, on purpose

pwa:
    resources:
        requests:
            cpu: 250m
            memory: 160Mi
        limits:
            cpu: 1000m
            memory: 1Gi

These values are easy to get wrong:

  • The php pod needs a CPU request. An HPA measures CPU as a percentage of the request. If there is no request, the autoscaler reports cpu: <unknown> and never scales.
  • The php pod has no CPU limit. Caddy runs in the same container and serves cached responses. A CPU limit would throttle those cheap requests along with the expensive ones.
  • The Nuxt pod needs a whole core. A page render takes about 150ms of CPU. With a 150m limit, which is what the chart used to ship, a pod can only render about one page per second, however many pods you run.
  • The php memory limit is sized for photo uploads. The template accepts 20 MB images, and PHP's memory_limit is 512M because GD needs about 11.7 MB per megapixel to build a thumbnail inside the upload request. The worst case is about 850Mi: roughly 190Mi of baseline, one upload at 512M, and the other three worker threads on ordinary requests. The request stays at 350Mi, so the scheduler reserves no more. The cost is that a node short of memory during a large upload may evict the php pod first, because it is furthest over its request.
  • FrankenPHP runs a fixed pool of 4 worker threads (FRANKENPHP_WORKER_NUM in worker.Caddyfile). By default it starts 2 per visible CPU, and with no CPU limit the pool, and so the worst-case memory, grew with the node. If you raise it, raise the memory limit too.
  • A memory request doesn't protect against being OOM-killed; the limit does. A request only decides what the scheduler reserves. The Nuxt request is 160Mi because a pod uses about 70–125Mi. The 1Gi limit is the ceiling.

These are the production values. Through the pipeline, staging and review apps request less (100m CPU, and 128Mi for Nuxt and 256Mi for php), because there are many of them and they only need to be correct, not fast. Limits are the same on every track. See CI/CD → Autoscaling.

Autoscaling

The two tiers scale differently.

php stays at one replica

autoscaling.maxReplicas defaults to 1. The php pod keeps state in memory: Souin's cache store and Mercure's bolt transport are both local to the pod. When the API purges its cache after a write, it only purges the cache in its own pod, so a second pod would keep serving the old response. With a long s-maxage that stale response could stay for a very long time.

In practice one pod is enough, because most requests are cache hits and one pod can serve several hundred cached responses per second. Only raise the limit if you also move to a shared cache store and a clustered Mercure hub.

Nuxt scales out

The Nuxt Deployment has its own HPA and its own settings under pwa.autoscaling. It targets 70% CPU, which is lower than php's 90%. Server-side rendering uses CPU for every page, so by the time a pod reaches 90% the requests have already started to queue.

pwa:
    autoscaling:
        enabled: true
        minReplicas: 1
        maxReplicas: 6
        targetCPUUtilizationPercentage: 70
        behavior:
            scaleUp:
                stabilizationWindowSeconds: 0     # react within a minute
            scaleDown:
                stabilizationWindowSeconds: 600   # but shrink slowly

The behavior block matters as much as the threshold. Kubernetes waits five minutes before scaling up by default, and a traffic spike is usually over before then. Scaling down slowly means a short quiet period does not leave the next spike to fewer, cold pods.

When pwa.autoscaling.enabled is false, the Deployment uses pwa.replicaCount.

Surviving node scale-down

A rolling update starts the new pod before it stops the old one. An eviction does the opposite. When the cluster autoscaler removes a node to save money, it evicts the pods on that node first, and they come back only once their replacements have booted and passed readiness. If the only API pod and every SSR pod were on that node, the ingress has nothing to send traffic to, and visitors get a 503.

The chart protects each tier differently:

  • The API pod carries the annotation cluster-autoscaler.kubernetes.io/safe-to-evict: "false", so the autoscaler won't evict it to consolidate nodes. A PodDisruptionBudget would be the wrong tool here: with one replica, any budget blocks every drain of that node. On GKE a blocked drain is force-evicted after an hour anyway.
  • The SSR pods have a PodDisruptionBudget with maxUnavailable: 1, and a topology spread on kubernetes.io/hostname with whenUnsatisfiable: ScheduleAnyway, so replicas prefer separate nodes. The budget uses maxUnavailable, not minAvailable: 1. With a single SSR pod (PWA_AUTOSCALE_MIN=1), minAvailable: 1 would block every node drain.
This only covers the autoscaler's scale-down. A node upgrade still drains the API pod, and because there is only one, the site is briefly unavailable. That is a scheduled outage that one replica can't avoid. Spot or preemptible VMs are reclaimed without a drain, so neither the annotation nor the budget helps there: keep the API pod off Spot nodes.

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 handler registered by the @cwa/nuxt module (runtime/server/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: 60
    periodSeconds: 5
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: 5
    periodSeconds: 10
    failureThreshold: 3
    timeoutSeconds: 5

The startup probe holds readiness back until Caddy is listening, and it allows up to 300 seconds for that, checking every 5. Once it passes, readiness is checked after only 5 seconds. A longer delay just keeps a pod that is already serving out of the load balancer. There is only one API pod, so every second counts: with the old 30-second delay, a pod that served at about 6 seconds didn't go Ready until 30–39 seconds, which is 24–33 seconds of avoidable downtime each time it was evicted or its node upgraded.

Do not remove timeoutSeconds: 5. Without it, Kubernetes uses a one-second timeout. The readiness path is cached by Souin, and Souin combines concurrent requests for the same cache key into a single request to the backend. If the first probe is cancelled while that backend request is still running, it can block that cache key for the life of the pod. Every later probe then times out, and the pod never becomes ready.

The pwa container gets the same TCP liveness probe on port 3000 and a TCP startup probe that also checks every 5 seconds (up to 30 times), with /_cwa/healthcheck for readiness. That path is not cached, so the default timeout is fine there.

The API's health endpoint

The bundle also provides GET /_api/_/health (the /_/health route under your API prefix, imported with routing/all.php). It runs a dummy SELECT on the default database connection and returns:

  • 200 {"status":"ok"}, or
  • 503 {"status":"unavailable","reason":"database"}, and logs a warning.

Only the database is checked, so Mercure or the cache being down doesn't fail it. Every response is Cache-Control: private, no-store, and it's cheap enough to probe every few seconds. You can use it for a readiness probe or a compose healthcheck instead of site_config_parameters.jsonld. The template's chart doesn't use it yet.

Exclude it from Souin first. It's never stored, but it still passes through Souin, which has the stuck-request problem described above. In the template's Caddyfile, add !{path}.startsWith("/_api/_/health") && to the /_api branch of the @use_cache expression. If your access_control requires login for every GET, allow it: { path: ^/_api/_/health, roles: PUBLIC_ACCESS }.

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, as long as each release used its own image tag. The template's pipeline reuses the branch tag with pullPolicy: Always, so there a rollback pulls the current image again (see CI/CD → Rollback). 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.