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.
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
| Resource | Type | Description |
|---|---|---|
{{fullname}} | Deployment | FrankenPHP — Symfony API, Caddy front door, and the embedded Mercure hub |
{{fullname}}-pwa | Deployment | Nuxt SSR server, port 3000 |
{{fullname}}, {{fullname}}-pwa | Service | One per Deployment, both ClusterIP |
{{fullname}} | Ingress | A single Ingress, routing everything to the php Service |
{{fullname}} | HorizontalPodAutoscaler | php — 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}}-pwa | HorizontalPodAutoscaler | Nuxt SSR — enabled by default (pwa.autoscaling.enabled) |
{{fullname}}-pwa | PodDisruptionBudget | Nuxt SSR — maxUnavailable: 1, so a node drain never takes out every SSR pod at once |
{{fullname}}-orphan-scan | CronJob | The daily orphaned-resource scan. Off unless cronjobs.orphanScan.enabled; the pipeline turns it on for production only (see Daily Orphan Scan) |
{{fullname}}-image-variants | CronJob | Suspended 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-watch | PodDisruptionBudget, Deployment, Service, NetworkPolicy, CronJob | Only 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-env | ConfigMap, Secret | The site's own settings from siteEnv. Each is created only when it has something in it (see Site settings) |
postgresql | Subchart | Bundled 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.
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.
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.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
maxMemorystays well under the memory limit. libvips allocates outside PHP'smemory_limit, so--max-memorychecks the process's resident memory. When it reachesmaxMemory, the command stops with exit code3while variants are still missing, andgenerate_image_variantsstarts 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 afteractiveDeadlineSeconds. 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/namelabel (<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
150mlimit, 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_limit128M.php.memoryLimitreaches the Caddyfile'sphp_iniasFRANKENPHP_MEMORY_LIMIT, written inMiorGi(any other form fails the render). The bundle raises a request's limit to itsimagine.memory_limit(320M by default) only while it generates a filter, so keepphp.imagineMemoryLimitequal to it. Console commands useapi/frankenphp/conf.d/10-app.ini's 256M instead. The libvips driver keeps most of a decoded image in native memory, outsidememory_limit, and uses far less than GD did. - FrankenPHP runs a fixed pool of worker threads,
php.workers(4), passed asFRANKENPHP_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. GOMEMLIMITis 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 inMiorGi;php.goMemLimit(CWA_CI_API_GOMEMLIMITin CI) overrides it with a Go value such as800MiB, andoffleaves 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).
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 onkubernetes.io/hostnamewithwhenUnsatisfiable: ScheduleAnyway, so replicas prefer separate nodes. The budget usesmaxUnavailable, notminAvailable: 1. With a single SSR pod (CWA_CI_PWA_AUTOSCALE_MIN=1),minAvailable: 1would block every node drain.
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.
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.
- 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. - 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 thegzipentries server-side rendering reads. - 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'sbrotliPHP extension, which the template's image has (see Docker › Packages and extensions in the base). - 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 printsWaiting 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.reservedMemoryalready covers. - Only
/_apiresponses are warmed, never HTML.purge_rendered_htmldrops every page straight after the rollout, and old Nuxt pods still running during it could render pages that point at the old build's/_nuxtfiles. 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
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 dummySELECTon the default database connection and returns200 {"status":"ok"}, or503 {"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 returns200 {"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.
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,usernameandemail; - email addresses anywhere in a line, raw or percent-encoded;
- JWTs, and
BearerorBasicheader 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.
$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
5xxor an uncaught exception. A4xxnever sends one. - The subject is
<WEBSITE_NAME> API error, and it's sent fromMAILER_EMAIL. With noMAILER_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/secretandchecksum/site-envannotations, 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 getsSIGTERM, 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'SIGTERMthen stops Caddy: it finishes the requests in flight and, after itsgrace_periodof 15 seconds (in the Caddyfile), cuts the Mercure subscriptions still open, which never end by themselves, so the pod stops cleanly instead of showingError.
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.envartifacts: without a digest, the deploy pins the build the branch tag holds now. On GitHub, usehelm rollbackfor production:production.ymlbuilds 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
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.
Deploy to One Server
Run a CWA site on one Linux server with Docker Compose and the template's bin/server scripts - set up the server, write the site's settings with init, deploy with deploy.sh, schedule backups, restore one, and run commands by hand. A preview.
Moving From One Server to Kubernetes
Move a CWA site from one server with Docker Compose to Kubernetes with the template's pipeline - the media into a Cloud Storage bucket, the database from a final dump taken under maintenance mode, the secrets from .env.prod as CI variables, and the certificates from the staging issuer to letsencrypt-prod.