CWA 2 is a prerelease (2.0.0-alpha), heading for a beta. It runs production sites; configuration can still change between releases, always with upgrade notes. View versions and support
Deployment & Operations

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.

If you deploy with the template's pipeline, you don't write values. You set CI variables, and bin/devops/k8s.sh writes the values file and runs Helm. The Helm value column of CI/CD Variables maps each variable to the value it sets, for example CWA_CI_API_GOMEMLIMIT → php.goMemLimit. This page explains what the chart does with those values, for when you read a release, change the chart, or deploy without the pipeline.

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 — only when autoscaling.enabled is on and autoscaling.maxReplicas is above 1. The default max is 1, so by default there is none (see Autoscaling › php stays 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)
{{fullname}}-image-variantsCronJobSuspended and never scheduled: the template the post-deploy image variants Jobs are created from (see Image Variants Jobs)
{{fullname}}, {{fullname}}-redis, {{fullname}}-mercure, {{fullname}}-redis-watchPodDisruptionBudget, Deployment, Service, NetworkPolicy, CronJobOnly in scaled mode (scaled.enabled): the php pods' budget, the release's Redis cache and Mercure hub, and the Redis watch (see Scaled Mode)
{{fullname}}-pwa-env, {{fullname}}-api-envConfigMap, SecretThe site's own settings from siteEnv. Each is created only when it has something in it (see Site settings)
postgresqlSubchartBundled Bitnami PostgreSQL, enabled by default

There is no Mercure Deployment — the hub runs as a Caddy module inside the php container. Scaled mode is the exception: there the hub has its own pod, and php forwards /.well-known/mercure to it. 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, so its data is lost whenever its pod restarts. That's fine for review apps, not for production. For production set postgresql.enabled: false (CI variable CWA_CI_POSTGRES_ENABLED=false) and supply postgresql.url pointing at your managed instance (Cloud SQL, RDS, etc.).

The in-cluster database's user, password and name are postgresql.global.postgresql.auth.username, .password and .database, which default to example, !ChangeMe! and api. The subchart creates the database from them, and the chart builds the API's database-url from the same values, percent-encoding each part, so any character in a password is safe. The pipeline sets them from POSTGRES_USER, POSTGRES_PASSWORD and POSTGRES_DB (see CI/CD › Database).

In-cluster persistence

Persistence is postgresql.primary.persistence (enabled: false, size: 1Gi, storageClass: standard). The pipeline sets it from CWA_CI_POSTGRES_PERSISTENCE, CWA_CI_POSTGRES_PERSISTENCE_SIZE and CWA_CI_POSTGRES_STORAGE_CLASS (see CI/CD › Database). It's off unless you set it.

A StatefulSet's volume can't be added, removed, resized or moved to another storage class after it's created, and helm can't do it either. So when these settings don't match the live database, the deploy stops before helm and prints the commands to run, starting with kubectl delete statefulset <name> --cascade=orphan. Turning persistence on for a running database loses its data, so pg_dump it first.

Uninstalling a release leaves its volume behind, except on review apps: stopping a review app deletes its volume too.

The values the pipeline writes

The chart does not accept arbitrary env or envFrom blocks. The variables the template itself uses are templated from the chart's own ConfigMap and Secret, driven by structured values; a site's own settings go in siteEnv (see Site settings). helm/cwa/values.yaml is the authoritative list.

The template's pipeline generates these values on every deploy, as values.tmp.yaml, from CI variables. If you deploy the chart without the pipeline, these are the keys you almost always need to set yourself:

php:
    image:
        repository: ghcr.io/your-org/app-php
        tag: v1.2.3
    appSecret: "…"                 # left empty, every render picks a new random one
    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: "…"
    admin:
        username: admin
        password: "…"
        email: hello@example.com
    gcloud:
        bucket: my-media-bucket
        publicUrl: https://cdn.example.com/   # optional, trailing slash required
        pathPrefix: site-a/                   # optional, path inside the bucket

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
    siteUrl: https://www.example.com              # NUXT_SITE_URL: canonical URLs and og: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-prod   # CWA_CI_CLUSTER_ISSUER
    hosts:
        - host: www.example.com
          paths:
              - path: /
                pathType: ImplementationSpecific
    tls:
        - secretName: letsencrypt-cert-stable-api         # <CWA_CI_TLS_SECRET_NAME>-<track>-api
          hosts: [www.example.com]

The pipeline also sets the image repository and tag (a pinned <tag>@<digest> with pullPolicy: IfNotPresent, see Rollback), the per-track sizing, the admin account, the mailer, the database and the Mercure settings. The secrets (php.appSecret, php.jwt.secret, php.jwt.public and the Mercure keys) are passed with --set instead, as described in Managing Secrets.

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.

The embedded hub is Mercure 1.0, so mercure.publicUrl must be your public https:// hub URL. The chart requires it and fails to render without it (k8s.sh sets it from MERCURE_SUBSCRIBE_DOMAIN). The API can't build its __Secure-mercure_access_token cookie for a plain-HTTP URL. Also, the Caddyfile's resource_identifier should be this same URL. mercure.extraDirectives is added to the Caddyfile's mercure block as it is, and Mercure 1.0 refuses unknown directives, including the removed demo and ui. See Real-Time Updates (Mercure) › Hub Caddyfile.

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.

php.gcloud.pathPrefix is the path inside the bucket where the app stores its objects, such as site-a/. The chart passes it to php as GCLOUD_PATH_PREFIX, and empty (the default) stores at the bucket root. Media URLs are publicUrl followed by the prefix, so leave the prefix out of publicUrl. The pipeline sets it from the GCLOUD_PATH_PREFIX CI variable, and adds review/<slug>/ for a review app. See Storage path prefix.

Site Settings

The pipeline passes a site's own NUXT_* and CWA_API_* CI variables to the chart in siteEnv, as maps of name to base64-encoded value, and the environment name in cwaEnvironment. How to set them is in CI/CD › Site settings.

cwaEnvironment: production     # CWA_ENVIRONMENT (php), NUXT_PUBLIC_CWA_ENVIRONMENT (Nuxt); empty sets neither
siteEnv:
    pwa: {}          # NUXT_PUBLIC_* → ConfigMap <fullname>-pwa-env
    pwaSecret: {}    # other NUXT_* → Secret <fullname>-pwa-env
    apiSecret: {}    # CWA_API_<NAME> as <NAME> → Secret <fullname>-api-env

templates/site-env.yaml decodes the values, and the cwa.siteEnvFrom helper adds the matching envFrom to the Nuxt Deployment, and to the API Deployment and the orphan scan CronJob alike. An explicit env: entry beats envFrom, which is why the deploy refuses a name the chart already sets. A change to siteEnv restarts both Deployments' pods, through a checksum/site-env annotation.

The API's optional settings, such as RATE_LIMIT_EVENTS, CACHE_QUERY_ALLOWLIST, CLOUDFLARE_PURGE_PLAN or JWT_COOKIE_SAMESITE, have no values keys of their own. Put them in siteEnv.apiSecret, which the pipeline fills from CWA_API_<NAME>. A setting left out keeps the default from the Caddyfile or the Symfony config, and the pipeline leaves out an empty CWA_API_ value for that reason. Souin's store and backend timeout are among them (CADDY_CACHE_STORAGE, CADDY_CACHE_BACKEND_TIMEOUT). Only php.caddy.cdnConfig and php.caddy.extraConfig have values keys, both base64 and written to the Secret only when set (see CI/CD Variables › HTTP cache).

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. A project adds its own values without editing k8s.sh: the project_values function in an optional bin/devops/project.sh prints YAML that the deploy passes as the last -f file, so it overrides the generated values, though not APP_SECRET and the JWT and Mercure keys passed with --set. The pipeline takes each of those from a CI variable, else from the release's live Secret, so they stay the same from one deploy to the next (see CI/CD Variables › Secrets). See CI/CD › Project hooks. 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 php container's entrypoint migrates on every start, before the readiness probe can pass. It runs the template's app:database:migrate command, which runs doctrine:migrations:migrate --allow-no-migration --all-or-nothing, then ANALYZE.

On PostgreSQL the command holds an advisory lock while it works, so php processes that start together migrate once: a new API pod beside the 03:00 orphan scan (Daily Orphan Scan), or several API pods. The others print Another process is migrating; waiting for it to finish..., wait up to 600 seconds, then find nothing left to do. One still waiting after that fails with Another process held the migration lock for over 600 seconds.

The chart has one more way to migrate: templates/database-job.yaml, which Helm never installs. The template's deploy renders it on its own to run a read-only migrations plan, a backup or the migrations in a Job before the rollout. A production deploy always plans and runs its pending migrations in that Job before Helm, so a failing migration stops the deploy while the old pods keep serving. The pods still migrate as they start, which after the Job finds nothing to do, and is what migrates on a first deploy, a rollback and a helm upgrade you run yourself. See Migrations and Backups › Migrating in a Job before the rollout.

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 CWA_CI_ORPHAN_SCAN, CWA_CI_ORPHAN_SCAN_SCHEDULE and CWA_CI_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, under the same lock as the API (see Running Migrations). 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.

Image Variants Jobs

After a deploy, the pipeline's generate_image_variants creates every image variant not stored yet, so a visitor's request never has to (see CI/CD › Generating image variants after a deploy). It does this in one-off Jobs with their own memory limit, never in the API pod that serves the site.

The Jobs are created from the CronJob {{fullname}}-image-variants (templates/image-variants-cronjob.yaml). It is suspend: true with a placeholder schedule, so Kubernetes never runs it by itself. Each Job runs bin/console silverback:api-components:imagine:generate -v --max-memory=<maxMemory> in the php image, through its entrypoint like the orphan scan, with the API's environment and site settings:

cronjobs:
    imageVariants:
        maxMemory: "1G"            # --max-memory: stop and exit 3 at this resident memory
        activeDeadlineSeconds: 1800
        resources:
            requests:
                cpu: 100m
                memory: 1536Mi
            limits:
                memory: 1536Mi
  • maxMemory stays well under the memory limit. libvips allocates outside PHP's memory_limit, so --max-memory checks the process's resident memory. When it reaches maxMemory, the command stops with exit code 3 while variants are still missing, and generate_image_variants starts a fresh Job to carry on. One variant is always generated per Job, so a run always makes progress.
  • A Job is never retried (backoffLimit: 0) and is stopped after activeDeadlineSeconds. Finished Jobs are deleted after a day, and the CronJob keeps the last three successful and three failed ones.
  • The Jobs' pods have their own app.kubernetes.io/name label (<name>-image-variants), so the API Service never sends traffic to them.

The pipeline sets maxMemory and the memory request and limit from CWA_CI_IMAGE_VARIANTS_MAX_MEMORY and CWA_CI_IMAGE_VARIANTS_MEMORY; see CI/CD Variables › Image variants. The Job's memory comes from the node, not from the API pod's limit, so give the cluster room for it while it runs.

Ingress with TLS

The chart renders the Ingress from ingress.hosts, ingress.tls and ingress.annotations; you don't write a manifest. It has a single backend — the php Service, {{ fullname }}, on port 80. 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.

TLS comes from cert-manager. The pipeline sets the cert-manager.io/cluster-issuer annotation from CWA_CI_CLUSTER_ISSUER (letsencrypt-staging unless you set letsencrypt-prod), and cert-manager writes the certificate to the secret named in ingress.tls. The cluster needs ingress-nginx and cert-manager, with ClusterIssuers of those names: see Deploying a CWA Site. The pipeline also adds ingress-nginx annotations for proxy buffering, a 30 MB request body limit, the optional www redirect (CWA_CI_WWW_REDIRECT) and production's alias hosts (KUBE_INGRESS_ALIAS_DOMAINS). When production's hostnames change, the deploy issues the new certificate before Helm runs: see Changing a live site's hostnames.

Behind Cloudflare, set SSL/TLS to Full (strict), and wait until this certificate is issued before you proxy the record. See Cloudflare.

Ingress rate limits

ingress-nginx can add an outer rate limit in front of Caddy's. It's off unless you set CWA_CI_INGRESS_RATE_LIMIT_RPS. See Origin Protection › Ingress rate limits.

Resource Requests and Limits

These are the chart's defaults:

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

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

These values are easy to get wrong:

  • The php CPU request is a floor, not a size. With no CPU limit, php uses more when the node has it. The request decides php's share of a busy node and what the scheduler reserves, and a pod with no request can be starved until its probes time out. php never scales past one pod, so the HPA's percentage of it changes nothing. 100m held a 50-visitor load test, which peaked at 282m.
  • The Nuxt CPU request is small because cached pages need no render. With page HTML cached, an SSR pod idles at a few millicores. The request is also what the Nuxt HPA's percentage is measured against, so change the two together (see Nuxt scales out).
  • 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, a pod can only render about one page per second, however many pods you run.
  • The php memory limit is a budget. The chart states it in values.yaml: php.workers × php.memoryLimit + (php.imagineMemoryLimit − php.memoryLimit) + php.reservedMemory + Go ≤ the memory limit. With the defaults, 4 workers at 128Mi, 320Mi while one request generates an imagine filter, and 160Mi reserved for opcache, APCu, the binaries and a console command leave Go 160Mi of 1Gi (#136). 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. The pipeline checks the budget before it deploys; see CI/CD Variables › php memory budget.
  • Each request runs at memory_limit 128M. php.memoryLimit reaches the Caddyfile's php_ini as FRANKENPHP_MEMORY_LIMIT, written in Mi or Gi (any other form fails the render). The bundle raises a request's limit to its imagine.memory_limit (320M by default) only while it generates a filter, so keep php.imagineMemoryLimit equal to it. Console commands use api/frankenphp/conf.d/10-app.ini's 256M instead. The libvips driver keeps most of a decoded image in native memory, outside memory_limit, and uses far less than GD did.
  • FrankenPHP runs a fixed pool of worker threads, php.workers (4), passed as FRANKENPHP_WORKER_NUM. By default FrankenPHP 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.
  • GOMEMLIMIT is what the budget leaves Go, at least 64MiB. FrankenPHP's Go runtime (Caddy, Souin and Mercure) then collects garbage harder as it nears the limit, instead of growing until the container is OOM-killed. It bounds only Go's heap, not PHP's memory or Brotli compression. It doesn't replace the limit. The chart works it out from a limit written in Mi or Gi; php.goMemLimit (CWA_CI_API_GOMEMLIMIT in CI) overrides it with a Go value such as 800MiB, and off leaves it unset.
  • 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 memory (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 Variables › Autoscaling and sizing.

Autoscaling

The two tiers scale differently. By default only Nuxt has an HPA.

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.

The template refuses more than one API pod outside scaled mode. A deploy with CWA_CI_API_AUTOSCALE_MIN or CWA_CI_API_AUTOSCALE_MAX above 1 while the autoscaler is on, or CWA_CI_API_REPLICA_COUNT above 1 with CWA_CI_API_AUTOSCALE=false, fails before anything changes in the cluster:

The API's scaling settings can't be deployed:
  more than one API pod (autoscaler min 1, max 3) needs scaled mode: set CWA_CI_API_SCALED_PRODUCTION=true

With the autoscaler off, the line is more than one API pod (CWA_CI_API_REPLICA_COUNT 2, autoscaler off) needs scaled mode: …. The check counts what the chart uses, replicaCount with the autoscaler off and the minimum and maximum with it on, so a setting with no effect isn't refused.

A CWA_API_CADDY_CACHE_STORAGE that names redis fails the same way. The chart also refuses more than one API pod, for a helm install without the pipeline: More than one API pod needs scaled.enabled: a second pod's cache is never purged (#85).

For more than one API pod, turn on scaled mode.CWA_CI_API_SCALED_PRODUCTION, _STAGING, _CANARY and _REVIEW run several API pods sharing one Redis cache and one Mercure hub in its own pod. See Scaled Mode for when you need it and what it costs.

While autoscaling.maxReplicas is 1, the chart renders no HPA for php, and the Deployment runs autoscaling.minReplicas pods. An HPA with a minimum and maximum of 1 could never scale, and would show memory over its target all the time. A maxReplicas above 1 renders the HPA, which needs scaled mode. When autoscaling.enabled is false, the Deployment uses replicaCount.

Nuxt scales out

The Nuxt Deployment has its own HPA and its own settings under pwa.autoscaling. It targets 175% of the 100m CPU request, so it adds a pod at about 175m: roughly one 150ms render a second, before renders start to queue. A target over 100% is valid, because the CPU limit is 1000m. Keep request × target near 175m if you change either: at 70% of 100m, ordinary traffic would add pods.

pwa:
    autoscaling:
        enabled: true
        minReplicas: 1
        maxReplicas: 6
        targetCPUUtilizationPercentage: 175
        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 (CWA_CI_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 requests /_api/_/site_config_parameters.jsonld for both liveness and readiness. A response confirms that 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:
    httpGet:
        path: /_api/_/site_config_parameters.jsonld
        port: http
        httpHeaders:
            - name: Accept
              value: application/ld+json,application/json
    periodSeconds: 10
    failureThreshold: 6
    timeoutSeconds: 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 and liveness back until Caddy is listening, and it allows up to 300 seconds for that, checking every 5. Kubernetes starts no probe until the container's postStart hook has returned, so with the API cache warm (see Warming the API cache on start) on, those 300 seconds start once the warm has finished. 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 a 30-second delay, a pod that serves at about 6 seconds wouldn't go Ready until 30–39 seconds, which is 24–33 seconds of avoidable downtime each time it's evicted or its node upgraded.

Liveness fails after six failed requests, 10 seconds apart, so a pod whose php stops answering is restarted after about a minute. That is long enough to ride out a burst of requests queued behind php's workers, and readiness has already taken the pod out of service after 30 seconds. Because the probe needs the database, a database outage that lasts longer than a minute also restarts the pod.

Keep timeoutSeconds: 5 on both probes. Without it, Kubernetes uses a one-second timeout, and the first request to a cold php worker can take longer than that.

The probe path would go through the Souin cache, so the Caddyfile's @use_cache skips requests to /_api/_/site_config_parameters.jsonld whose User-Agent starts with kube-probe/, and the kubelet's probes always reach php. The exclusion covers only that path, so a client that fakes the user agent can't skip the cache for anything else. Liveness uses the same request as readiness, so a pod whose php has stopped answering is restarted. Why this matters is in Troubleshooting › The API pod stays unready.

The pwa container uses a 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.

Warming the API cache on start

A new php pod starts with an empty Souin cache and cold PHP workers, so the first visitors after a deploy or a restart would wait for every API response. The php container has a postStart hook that warms its /_api cache before the pod takes traffic.

  1. The hook waits until the pod answers http://localhost/_api/_/site_config_parameters.jsonld, which means Symfony has booted and migrated, trying every 2 seconds.
  2. It runs bin/console silverback:api-components:warm-http-cache --max-time=<php.warmHttpCacheMaxTime>, which requests through the cache what a server render fetches for each live public route. These are the gzip entries server-side rendering reads.
  3. It runs the command again with --accept-encoding=br, for the entries browsers and Cloudflare read, since the cache keeps one entry per encoding (see Docker › Compressed cache entries). This pass needs the image's brotli PHP extension, which the template's image has (see Docker › Packages and extensions in the base).
  4. All of it runs under timeout (2 × php.warmHttpCacheMaxTime + 270 seconds, 390 by default) and || true, so the hook never fails the container. Its output goes to the container's log.
php:
    warmHttpCacheMaxTime: 60     # seconds; 0 removes the hook

The pipeline sets it for each track from CWA_CI_API_WARM_HTTP_CACHE_MAX_TIME_<TRACK> (see CI/CD Variables › php (API)). Production and canary fall back to CWA_CI_API_WARM_HTTP_CACHE_MAX_TIME, then 60 seconds. Staging and review apps get 0, so their pods start without the hook, unless you set their own value. It needs api-components-bundle 2.0.0-alpha.20 or later, which has the command.

  • Visitors aren't kept waiting. Kubernetes starts no probe until the hook returns, so the pod isn't ready while it warms. With one replica, the rolling update keeps the old pod serving meanwhile.
  • The deploy job says what it's waiting for. Outside scaled mode, while the track's warm time isn't 0, it prints Waiting for the API rollout: the new pod warms /_api before it's ready (gzip then br, up to 60s each, #160)... with the track's warm time.
  • The boot budget is the hook, then the startup probe. The hook allows up to 2 × php.warmHttpCacheMaxTime + 270 seconds for the boot and both passes (390 by default), and the startup probe's 300 seconds start after it.
  • No resource requests change. The console process is part of what php.reservedMemory already covers.
  • Only /_api responses are warmed, never HTML. purge_rendered_html drops every page straight after the rollout, and old Nuxt pods still running during it could render pages that point at the old build's /_nuxt files. The pipeline's cache warm refills the pages after the purge (see CI/CD › Warming the cache after a deploy).
  • A full flush empties the warm. After new image variants, the deploy flushes the whole HTTP cache, then runs the same two passes again in the API pod. With Cloudflare configured, a deploy keeps the warm and purges only the rendered pages and Cloudflare's edge, unless that purge fails (see CI/CD › Why every deploy clears the page cache). A fixture load's flush is followed by the same two passes (see CI/CD › Fixtures on a deploy).

The hook also runs when a pod restarts or is evicted, not only on a deploy.

Load shedding

php answers 503 at once to a request that waits more than FRANKENPHP_MAX_WAIT_TIME (default 5s) for a free worker, rather than letting requests queue into 504s. See Origin Protection › Load shedding.

The API's health endpoints

Optional: switch the php probes to the bundle's health routes. The template's chart doesn't use them yet: both of its php probes request site_config_parameters.jsonld, so a long database outage restarts the pod. The bundle provides two routes, imported with routing/all.php, which you can use for the probes instead. The template's Docker images already use /_api/_/health for their own health check:
  • /_api/_/health, for readiness. 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.
  • /_api/_/health/live, for liveness. It always returns 200 {"status":"ok"} and touches no database, cache or remote service, so it stays up during a database outage. A liveness probe on it restarts a pod only when php itself stops answering, not because the database is down.
Both are anonymous, every response is Cache-Control: private, no-store, and they're cheap enough to probe every few seconds. Exclude them from Souin first: they're never stored, but they still pass through Souin, which can hold a stuck request (see Troubleshooting › The API pod stays unready, and every probe times out). In the template's Caddyfile, add !{path}.startsWith("/_api/_/health") && to the /_api branch of the @use_cache expression; it covers both paths. The template's Caddyfile has it, for the Docker health check (see Docker › Waiting for the stack), though the chart's probes don't use the routes. If your access_control requires login for every GET, allow them: { path: ^/_api/_/health, roles: PUBLIC_ACCESS }.

Logs

In production, Symfony writes its log to stderr as JSON, in Google Cloud's structured logging format, so nothing is lost when the container restarts.

Read it with kubectl logs, or in your cluster's log explorer. On GKE, Cloud Logging reads the JSON, so severity, channel and context are fields you can query.

kubectl logs deployment/<fullname> --namespace <namespace>

A request's lines are written only when it logs an error, together with up to 50 lines from before the error. A 404 or 405 doesn't count. There's no deprecations file: deprecations show up among a failing request's lines.

What's redacted

Monolog's RedactingFormatter cleans every line before it's written. It runs last, on the finished output, so a secret copied into an exception message is caught too. It masks:

  • the values of keys such as password, token, secret, passphrase, jwt, authorization, cookie, api_key, username and email;
  • email addresses anywhere in a line, raw or percent-encoded;
  • JWTs, and Bearer or Basic header values;
  • the credentials in a DSN, such as a mailer or database URL in a connection error;
  • token=, password=, secret= and similar in a query string or form body;
  • the username and token path segments of the links in emails (/verify-email/, /confirm-email/, /confirm-new-email/, /reset-password/ and /password/reset/request/);
  • bcrypt password hashes.
New code that logs a secret or an email address in another shape needs its own rule. Add the key to $sensitiveKeys, or a pattern to $patterns, in api/config/packages/prod/monolog.yaml, and a case to api/tests/Unit/Monolog/ProdLogRedactionTest.php.

Error mail

To get an email when the API fails, set the CI variable CWA_API_ERROR_MAIL_TO to an address, with a working MAILER_DSN. php reads it as ERROR_MAIL_TO (see CI/CD Variables › The API's optional settings). Unset or empty, no mail is sent.

  • It sends one email per unique error per hour, for a 5xx or an uncaught exception. A 4xx never sends one.
  • The subject is <WEBSITE_NAME> API error, and it's sent from MAILER_EMAIL. With no MAILER_EMAIL, nothing is sent.
  • The body is the failing request's log lines, redacted like the log. Email addresses are masked, so it can't name the user.
  • A mail that can't be sent is dropped without an error. The log is the reliable record.
  • The hour is counted in each pod, so a restart or a deploy starts it again.

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.

When a deploy restarts pods

A deploy restarts a Deployment's pods only when its pod spec changes:

  • A new image digest. The pipeline pins each image by digest (see CI/CD › Which build a deploy runs). A build that was skipped because nothing changed reuses the image with the same digest, so those pods keep running (see CI/CD › An unchanged image isn't built again). A push that touches only app/ restarts the Nuxt pods, not php.
  • Changed settings. The pod templates carry checksum/config, checksum/secret and checksum/site-env annotations, hashes of the chart's ConfigMap, Secret and site settings. A changed value, such as a CI variable or a site setting, changes the hash and rolls the pods.
  • Changed chart values for the pods, such as their resources or replica settings.

A deploy that changes none of these leaves the pods running. The deploy still waits for the rollouts, which are already finished, then purges the rendered pages as usual.

To restart pods on purpose, restart the Deployment:

kubectl rollout restart deploy/<fullname> --namespace <namespace>       # php (API)
kubectl rollout restart deploy/<fullname>-pwa --namespace <namespace>   # Nuxt

<fullname> is the release name plus -cwa, or the release name alone if it already contains cwa. Or set CWA_CI_BUILD_FORCE="true" and run the pipeline: both images are built again, and their new digests roll the pods on the deploy (see CI/CD Variables › Pipeline flags).

Stopping a pod without 502s

When Kubernetes stops a pod, it sends the container SIGTERM and removes the pod from its Service's endpoints at the same time. The ingress and the php pod's proxy take a few seconds to notice, so they keep sending requests to a pod that has already closed its listener, and visitors would get 502s during every deploy. Both Deployments hold the pod open first with a preStop hook:

  • Nuxt (templates/pwa-deployment.yaml) sleeps 5 seconds. Nitro closes its listener as soon as it gets SIGTERM, so the hook keeps it serving while the pwa Service drops the endpoint.
  • php (templates/deployment.yaml) sleeps 5 seconds while the ingress drops the endpoint. Kubernetes' SIGTERM then stops Caddy: it finishes the requests in flight and, after its grace_period of 15 seconds (in the Caddyfile), cuts the Mercure subscriptions still open, which never end by themselves, so the pod stops cleanly instead of showing Error.

Keep both hooks shorter than the pod's termination grace period, which is Kubernetes' default of 30 seconds.

Rollback

The template's pipeline deploys each build pinned by its digest, as <track tag>@<digest> (see CI/CD › Which build a deploy runs). So there are two ways back to an earlier build, and both run exactly that build.

  • Run the deploy job of the pipeline that built it again (GitLab). The deploy rolls out that pipeline's own build and, once its rollouts finish, copies it to the track's tag, so the tag and the pods agree again. The pipeline's build jobs must still have their image_digest.env artifacts: without a digest, the deploy pins the build the branch tag holds now. On GitHub, use helm rollback for production: production.yml builds nothing, so running it again always deploys :main's current build.
  • helm rollback. Helm restores the earlier revision's values, and the image tags in them include the digest. The track's tag isn't moved back; the next deploy moves it.

A deploy whose migration or rollout failed never moves the track's tag, so after a failed deploy the tag still names the last good build (see CI/CD › Which build a deploy runs). Helm has already applied the failed revision by then, while the old pods keep serving, so helm rollback returns the release to the revision before it.

Helm tracks release history:

# List the release's history
helm history cwa --namespace production

# Roll back to a specific revision
helm rollback cwa 3 --namespace production

# Roll back to the previous release
helm rollback cwa --namespace production

# Watch the rollback progress
kubectl get pods -w --namespace production
Both need the older build to still be in the registry. Once a later deploy moves the track's tag, the older build has no tag, so a registry that deletes untagged images can remove it. Then running the old deploy job again fails before Helm, and a helm rollback's new pods can't pull the image, unless their node still has it.

Without the pipeline, pin each release the same way: deploy a tag that only that build has, such as its git SHA, or <tag>@<digest>. If every release points at the same moving tag, helm rollback pulls whatever that tag holds then, not the older build.

docker build --target frankenphp_prod -t ghcr.io/your-org/app-php:$GIT_SHA ./api
docker push ghcr.io/your-org/app-php:$GIT_SHA

Because migrations run from the entrypoint, that older image will run its own migrations on start. Those are already applied, so the schema stays as the newer release left it. Rolling back never restores data: for that, see Migrations and Backups › Restoring a backup. Migrations are upward-only (see Database Setup): to undo a schema change, write a new migration that reverses it and deploy that. An older image running against a newer schema has to cope with the extra columns or tables, which is why additive migrations (adding, not renaming or dropping) are the safe default.