CWA 2.0 is in alpha: try it, build with it, give feedback. APIs can still change between releases; see Releases.
Deploying a CWA Site

CI/CD Variables

Every CI/CD variable the template's pipeline reads, grouped by concern, with its default and the Helm value it sets. Starts with the few a new site must set.

The template's pipeline is configured with CI/CD variables. Set them in Settings → CI/CD → Variables in GitLab. On GitHub, set them as Actions variables, except the ones the workflows map from secrets (see CI/CD → GitHub Actions).

If you deploy with the template's pipeline, you only set variables. bin/devops/k8s.sh turns them into the chart's values and runs Helm. The Helm value column below says where each one ends up, so you can read it alongside Kubernetes & Helm. A — means the variable steers the pipeline and sets no chart value.

Minimum for a new site

The deploy works out the settings that would otherwise be the same on every site: CWA_CI_INGRESS_ENABLED, CORS_ALLOW_ORIGIN, TRUSTED_HOSTS, MERCURE_CORS_ORIGIN and DATABASE_SSL_MODE. A variable you set still wins. The host-based defaults use the deploy's own hostname, plus KUBE_INGRESS_ALIAS_DOMAINS in production, so review apps and staging get their own instead of production's.

So a new site sets only what is its own:

  • The cluster connection: KUBE_CONTEXT (GitLab agent context), or on GitHub the KUBECONFIG and KUBE_CONTEXT secrets and the KUBE_NAMESPACE_PRODUCTION secret. See Kubernetes and hostnames.
  • The domain: KUBE_INGRESS_BASE_DOMAIN.
  • The database: DATABASE_URL with CWA_CI_POSTGRES_ENABLED="false". See Database.
  • The media bucket: GCLOUD_JSON, GCLOUD_BUCKET and optionally GCLOUD_PUBLIC_URL. See Media storage.
  • The mailer: MAILER_DSN and MAILER_EMAIL.
  • The first admin: at least ADMIN_PASSWORD. See Application.
  • A trusted certificate: CWA_CI_CLUSTER_ISSUER=letsencrypt-prod, once production's domain points at the cluster.
  • Recommended for production: the secrets (APP_SECRET, MERCURE_JWT_SECRET and the JWT key pair). They're optional: a deploy keeps the release's live values, and generates them only for a new release.

Deploying a CWA Site walks through these in order.

Naming rules

The variables follow three naming rules:

  • CWA_CI_* are the template's own pipeline and deploy switches, such as CWA_CI_STAGING_ENABLED or CWA_CI_PWA_AUTOSCALE_MIN. The sizing of the php pod is CWA_CI_API_*.
  • CWA_API_* and NUXT_* are settings the containers read at runtime, passed through by prefix. See Site settings.
  • Site values, secrets and names set by the platform keep their own names: DATABASE_URL, POSTGRES_*, APP_SECRET, JWT_*, MERCURE_JWT_SECRET, MAILER_*, GCLOUD_*, ADMIN_*, CORS_ALLOW_ORIGIN, TRUSTED_HOSTS, MERCURE_CORS_ORIGIN, DATABASE_SSL_MODE, CADDY_CACHE_CDN_CONFIG, GITHUB_TOKEN, KUBERNETES_VERSION, HELM_VERSION, and the KUBE_* and CI_* variables.
Template 2.0.0-alpha.16 renamed the switches, and the old names are no longer read. A project that still sets POSTGRESQL_ENABLED, CLUSTER_ISSUER, ENABLE_DATABASE_FIXTURES or another old name gets the default instead, without a warning. The full list is in the template 2.0.0-alpha.16 release notes.
GitLab environment scopes: the staging job runs under environment: production, so a variable scoped to production reaches staging too, as well as canary and production.

Kubernetes and hostnames

VariableDefaultHelm valueNotes
KUBE_CONTEXTrequired—GitLab agent context, e.g. my-group/my-project:my-agent. On GitHub it's a secret, used with the KUBECONFIG secret
KUBE_NAMESPACE$CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG—The pre-created namespace for this environment. On GitHub, staging and production use the KUBE_NAMESPACE_PRODUCTION secret
KUBE_INGRESS_BASE_DOMAINrequiredingress.hosts, through DOMAINBase domain for ingress URLs, e.g. k8s.example.com. Production is served at it, staging at staging.<it> and review apps at <branch>-review.<it>
CI_ENVIRONMENT_URLset by GitLabingress.hosts, through DOMAINFull URL of this environment. DOMAIN is its host. Required unless DOMAIN is set; without either, the deploy fails
CWA_CI_CLUSTER_ISSUERletsencrypt-stagingingress.annotations cert-manager.io/cluster-issuerThe cert-manager ClusterIssuer. See below
KUBE_INGRESS_ALIAS_DOMAINS—ingress.annotations server-alias, ingress.tlsComma-separated extra domains for production. Production's certificate covers them. See Changing a live site's hostnames
CWA_CI_TLS_SECRET_NAMEletsencrypt-certingress.tls[].secretNamePrefix of the ingress's TLS secret. You don't need to change it when hostnames change
CWA_CI_TLS_CERTIFICATE_TIMEOUT600s—How long a deploy waits for a new certificate when production's hostnames change
CWA_CI_INGRESS_ENABLED"true"ingress.enabledSet "false" to deploy without an ingress
CWA_CI_WWW_REDIRECT"false"ingress.annotations from-to-www-redirectSets ingress-nginx's annotation, which redirects between example.com and www.example.com
KUBERNETES_VERSION1.31.0—The kubectl version the deploy jobs install
HELM_VERSION3.19.0—The Helm version the deploy jobs install

CWA_CI_CLUSTER_ISSUER defaults to letsencrypt-staging (set by setup.sh), whose certificates browsers don't trust. Set letsencrypt-prod for production once the domain points at the cluster. Until then, a misconfigured domain fails against the staging issuer's much higher limits instead of using up Let's Encrypt's production limits. An empty value turns cert-manager off. Keep the variable on every production site. Both issuers must exist in the cluster under these names (see Deploying a CWA Site).

On GitHub, the workflows' setup actions read vars.KUBERNETES_VERSION and vars.HELM_VERSION. On GitLab, setup.sh sets the defaults only when no CI variable sets them (13243ae).

Secrets

VariableDefaultHelm valueNotes
APP_SECRETkept, else generatedphp.appSecretSymfony's secret, which signs URLs and other tokens
JWT_SECRET_KEYkept, else generatedphp.jwt.secretThe private key's contents
JWT_PASSPHRASEkept, else generatedphp.jwt.passphraseThe private key's passphrase. If you set JWT_SECRET_KEY, set this too, or the deploy fails
JWT_PUBLIC_KEYderivedphp.jwt.publicWhen you supply JWT_SECRET_KEY and leave this empty, it's derived from the key with JWT_PASSPHRASE, and the deploy fails if the passphrase doesn't decrypt the key
MERCURE_JWT_SECRETkept, else generatedmercure.jwtKey.publisher.key, mercure.jwtKey.subscriber.keySigns the Mercure publisher and subscriber tokens

All of them are optional. For each one that isn't a CI variable, deploy_secrets reads the value from the release's live Secret, and generates a new one only when the release doesn't have one yet (#144, 816997e). The JWT key is kept together with its own passphrase, and the public key is derived from them. So logins, signed URLs and Mercure cookies survive a deploy, and pods on either side of a rollout agree. They're passed to Helm with --set, so they win over a project's project_values.

Setting them as protected, masked CI variables (GitHub: secrets) is still worth it for production. A release that is uninstalled, for example with CWA_CI_HELM_UNINSTALL or a stopped review app, loses its Secret, and its next deploy generates new values. A CI variable also lets you rotate a value on purpose.

To rotate a value, set a new one as the CI variable and deploy. Removing the variable doesn't rotate it, because the deploy then keeps the live value. For the JWT key pair, set JWT_SECRET_KEY and JWT_PASSPHRASE, and leave JWT_PUBLIC_KEY empty to have it derived. A new JWT key pair signs every user out when it's deployed, because tokens signed with the old key no longer verify.

The JWT_* values hold the keys' contents, for the deploy. The functional tests job unsets them, so they never reach the tests (see CI/CD → Test).

Application

VariableDefaultHelm valueNotes
CORS_ALLOW_ORIGIN^https://(?:<hosts>)$php.corsAllowOriginRegex. Set it only when another origin calls the API from a browser
TRUSTED_HOSTS^(?:<hosts>|localhost)$php.trustedHostsSymfony trusted hosts regex. Wrap the alternatives in one anchored group, ^(?:www\.example\.com|php)$, see the warning below
MERCURE_CORS_ORIGINhttps://<host> for each hostmercure.corsOriginSpace-separated origins the Mercure hub accepts
ADMIN_USERNAMEadminphp.admin.usernameInitial admin account username
ADMIN_PASSWORDadminphp.admin.passwordInitial admin account password — change this
ADMIN_EMAILhello@cwa.rocksphp.admin.emailInitial admin account email
MAILER_DSN— (null://null)php.mailer.dsnSMTP or SES DSN for transactional email. Unset, nothing is sent
MAILER_EMAIL—php.mailer.emailFrom address for outgoing email. Production's orphan scan also emails it

<hosts> is the deploy's own hostname, plus KUBE_INGRESS_ALIAS_DOMAINS in production, with dots escaped.

Anchor every TRUSTED_HOSTS alternative. Symfony doesn't anchor the pattern as a whole, so in ^localhost|caddy|example\.com$ only the first alternative is tied to the start and only the last to the end. The middle one matches anywhere in a hostname: the template's old default trusted evil-caddy.attacker.net. Group the alternatives, ^(?:localhost|caddy|example\.com)$. The template's own defaults use this form. Check the value in your CI variables, and in api/.env, compose.yaml and helm/cwa/values.yaml if your project predates template 2.0.0-alpha.3.

Media storage

VariableDefaultHelm valueNotes
GCLOUD_JSON{}php.gcloud.jsonKeyService-account key JSON for the bucket. Store it as a secret
GCLOUD_BUCKETno-gcloud-bucketphp.gcloud.bucketGoogle Cloud Storage bucket for uploads
GCLOUD_PUBLIC_URLbucket URLphp.gcloud.publicUrlBase URL for media links, e.g. https://cdn.example.com/. Must end in a slash. If empty, uses https://storage.googleapis.com/<bucket>/. See Kubernetes → Media URLs

On GitHub, set GCLOUD_JSON as a secret and the other two as variables.

How long media is cached

From api-components-bundle 2.0.0-alpha.15, every file written to the bucket gets its own Cache-Control. Originals get public, max-age=31536000, immutable, because a stored name is never reused. Image variants get public, max-age=2592000, because a regenerated variant keeps its path. Files stored before then keep GCS's one-hour default until you back-fill them (template 2.0.0-alpha.25's upgrade notes).

Google Cloud CDN caps these unless you raise its TTLs. A backend bucket sends browsers at most its client TTL and keeps its own copy for at most its max TTL. The defaults are one hour and one day, so a CDN created with them serves every file with max-age=3600 whatever the bucket says. Raising a cap never lengthens a file's own TTL, so a year is safe for both.

Create the CDN's backend bucket with both caps at a year, or raise them on an existing one:

gcloud compute backend-buckets update <backend-bucket> --max-ttl=31536000 --client-ttl=31536000

Changes reach the CDN's edges within a few minutes. Invalidate the CDN host afterwards so files it already holds pick up the new headers: gcloud compute url-maps invalidate-cdn-cache <url-map> --host <cdn host> --path "/*". To check, curl -sI a variant through the CDN: it should send max-age=2592000.

Database

VariableDefaultHelm valueNotes
CWA_CI_POSTGRES_ENABLEDtruepostgresql.enabledSet "false" to use an external database, which turns off the in-cluster PostgreSQL
DATABASE_URL—postgresql.urlConnection string when using an external database
DATABASE_SSL_MODEprefer, or require when CWA_CI_POSTGRES_ENABLED is "false"php.databaseSSL.modedisable, prefer, require, verify-full. An external database is reached over the network, so it gets TLS by default
DATABASE_CA_CERT—php.databaseSSL.caCA cert PEM for SSL verification
DATABASE_CLIENT_CERT / DATABASE_CLIENT_KEY—php.databaseSSL.cert / php.databaseSSL.keyA client certificate and key, for a database that asks for one
POSTGRES_USERexamplepostgresql.global.postgresql.auth.usernameIn-cluster PostgreSQL only: the database user. The name the postgres image, compose and the test jobs use. POSTGRES_USERNAME is not read
POSTGRES_PASSWORD!ChangeMe!postgresql.global.postgresql.auth.passwordIn-cluster PostgreSQL only: the user's password. Any character is safe
POSTGRES_DBapipostgresql.global.postgresql.auth.databaseIn-cluster PostgreSQL only: the database name
CWA_CI_POSTGRES_PERSISTENCEunset (off)postgresql.primary.persistence.enabledIn-cluster PostgreSQL only: true keeps its data on a volume, false turns that off. Any other value fails the deploy
CWA_CI_POSTGRES_PERSISTENCE_SIZE1Gipostgresql.primary.persistence.sizeThe volume's size
CWA_CI_POSTGRES_STORAGE_CLASSstandardpostgresql.primary.persistence.storageClassThe volume's storage class. - sets none (Bitnami's storageClassName: ""), which turns off dynamic provisioning
CWA_CI_POSTGRES_IMAGE_TAG14postgresql.image.tagThe in-cluster PostgreSQL image tag

With the in-cluster PostgreSQL (CWA_CI_POSTGRES_ENABLED not "false"), the deploy writes the three POSTGRES_* variables that are set to the chart's postgresql.global.postgresql.auth. Bitnami's subchart creates the database from those values, and the chart builds the API's database-url from them, percent-encoding each part (e15f168). An unset variable keeps the chart's default.

By default the in-cluster PostgreSQL has no volume. Its data lives in the pod and is lost whenever the pod is replaced, for example after a node restart or a credentials change. Use an external database (DATABASE_URL with CWA_CI_POSTGRES_ENABLED="false") for a real site, or turn on persistence.

PostgreSQL creates the user and database only when it starts on an empty data directory. Without persistence, changing the user or database name on a live site restarts the PostgreSQL pod empty, so the content has to be loaded again. A new password alone doesn't restart the pod, so the API can't connect until you run kubectl delete pod <release>-postgresql-0, which also starts it empty.

With persistence, the volume keeps the user, password and database it was created with, and a deploy doesn't change them. Change them in PostgreSQL first, as the postgres superuser, with ALTER USER or ALTER DATABASE (renaming a user clears its password, so set it again), then set the new values and deploy.

On GitLab, a POSTGRES_* variable with the All environment scope also reaches the functional tests job, where it replaces the job's own runner/password/test, for both the test database and its URL. Scope these variables to your deploy environments. On GitHub, the deploy jobs don't map them from secrets, so set them as Actions variables; the test job sets its own and ignores them.

In-cluster PostgreSQL persistence

The three CWA_CI_POSTGRES_* persistence variables go to the chart's postgresql.primary.persistence, and only the ones that are set. Unset, the chart's defaults apply: no volume, and standard at 1Gi once it's turned on (7675190).

Kubernetes can't change a StatefulSet's volume once it exists, so Helm would fail part way through. Before anything changes, the deploy compares the variables with the live PostgreSQL and stops when they differ, with the commands to run and what happens to the data. It never deletes anything itself:

Change on a live releaseWhat the deploy tells you to do
Turn persistence onpg_dump the data if you need it, run kubectl delete statefulset <release>-postgresql -n <namespace> --cascade=orphan, and deploy again. The new pod starts with an empty volume, so load fixtures or restore the dump
Turn persistence offThe same kubectl delete statefulset … --cascade=orphan, then deploy. The new pod starts empty. The volume data-<release>-postgresql-0 keeps its data until you delete it with kubectl delete pvc
Change the sizeSet it back, or delete the StatefulSet with --cascade=orphan and deploy: the volume and its data are kept at the old size. To grow it, if the storage class allows expansion, patch the volume with the kubectl patch pvc command the log prints
Change the storage classSet it back, or pg_dump, delete the StatefulSet with --cascade=orphan and the volume with kubectl delete pvc, deploy, and restore the dump

A first deploy, with no PostgreSQL yet, can use any settings. If the CI can't read the StatefulSet, the deploy prints a warning and skips the check.

Helm leaves volumes behind when it uninstalls a release. Stopping a review app also deletes its PostgreSQL volume, so a branch deployed again starts with fresh data and credentials. Staging and canary keep theirs: a persistent staging volume outlives delete staging, so delete it by hand with kubectl delete pvc data-<release>-staging-postgresql-0. On GitLab, a CWA_CI_POSTGRES_PERSISTENCE scoped to production reaches staging and canary too.

Composer

VariableDefaultHelm valueNotes
GITHUB_TOKENunset—Optional. Authenticates composer's downloads from github.com, in the API image build and the GitLab test jobs, so installs don't hit GitHub's anonymous limit of 60 requests an hour per IP. A fine-grained token with no permissions is enough. Store it as a masked variable

setup.sh turns the token into COMPOSER_AUTH and build_api passes that to the build as a secret (see CI/CD → Build). Leave the variable unset rather than empty: an empty token is rejected outright, which is worse than anonymous. GitHub Actions needs nothing: the build step maps the workflow's own secrets.GITHUB_TOKEN (!12).

Autoscaling and sizing

The php pod and the Nuxt pod scale separately. The CWA_CI_API_* variables control php and the CWA_CI_PWA_* variables control Nuxt.

php (API)

VariableDefaultHelm valueNotes
CWA_CI_API_REPLICA_COUNT1replicaCountUsed only when autoscaling is off
CWA_CI_API_AUTOSCALEtrueautoscaling.enabled
CWA_CI_API_AUTOSCALE_MIN1autoscaling.minReplicas
CWA_CI_API_AUTOSCALE_MAX1autoscaling.maxReplicas1 on purpose, see below
CWA_CI_API_AUTOSCALE_CPU_PERCENT90autoscaling.targetCPUUtilizationPercentage
CWA_CI_API_AUTOSCALE_MEMORY_PERCENT90autoscaling.targetMemoryUtilizationPercentage
CWA_CI_API_CPU_REQUEST100m on every trackphp.resources.requests.cpu
CWA_CI_API_MEMORY_REQUEST350Mi for production and canary, 256Mi otherwisephp.resources.requests.memory
CWA_CI_API_MEMORY_LIMIT1Gi on every trackphp.resources.limits.memoryThe php memory budget must fit it
CWA_CI_API_GOMEMLIMITemptyphp.goMemLimitEmpty: what the php memory budget leaves of CWA_CI_API_MEMORY_LIMIT, at least 64MiB, when the limit is in Mi or Gi. A Go value such as 800MiB overrides it, and off leaves GOMEMLIMIT unset

CWA_CI_API_AUTOSCALE_MAX is 1 on purpose. The php pod keeps its cache and its Mercure data in memory, so a second pod would not see purges made by the first. See Kubernetes → Autoscaling.

php memory budget

These size the php container's memory (#136, 1868035):

VariableDefaultHelm valueNotes
CWA_CI_API_WORKERS4php.workersFrankenPHP's worker threads (FRANKENPHP_WORKER_NUM)
CWA_CI_API_PHP_MEMORY_LIMIT128Miphp.memoryLimitEach request's PHP memory_limit (FRANKENPHP_MEMORY_LIMIT, as 128M)
CWA_CI_API_IMAGINE_MEMORY_LIMIT320Miphp.imagineMemoryLimitThe limit the bundle raises a request to while it generates an imagine filter. Keep it equal to the bundle's imagine.memory_limit, whose default is 320M
CWA_CI_API_RESERVED_MEMORY160Miphp.reservedMemoryopcache, APCu, the binaries and a console command run in the pod

The budget is:

workers × PHP memory limit + (imagine limit − PHP memory limit) + reserved + Go ≤ CWA_CI_API_MEMORY_LIMIT

Go (Caddy, Souin and Mercure) gets what is left, as GOMEMLIMIT: with the defaults, 4 × 128 + 192 + 160 = 864Mi, which leaves Go 160Mi of 1Gi. The imagine limit counts once: the budget allows for one request generating a filter at a time.

The deploy fails before it changes anything in the cluster when the budget leaves Go under 64Mi, or when a value isn't a whole number or an Mi/Gi quantity. The log shows the sum and what to change. With a CWA_CI_API_MEMORY_LIMIT in another form, such as 1G, the check is skipped and GOMEMLIMIT is left unset unless CWA_CI_API_GOMEMLIMIT sets it. So when you raise the workers or the PHP memory limit, raise CWA_CI_API_MEMORY_LIMIT too.

Console commands, such as the orphan scan or a kubectl exec into the pod, don't use FRANKENPHP_MEMORY_LIMIT. They run at the memory_limit of 256M in api/frankenphp/conf.d/10-app.ini.

Set the worker count with CWA_CI_API_WORKERS, not CWA_API_FRANKENPHP_WORKER_NUM.FRANKENPHP_WORKER_NUM and FRANKENPHP_MEMORY_LIMIT are reserved names, so the deploy refuses them as site settings.

The template's Image entity accepts uploads up to 30 megapixels: what the 320M imagine limit can thumbnail with the template's libvips driver. If you switch the driver back to GD, lower it to 20 megapixels. See Uploadable.

Nuxt (SSR)

VariableDefaultHelm valueNotes
CWA_CI_PWA_REPLICA_COUNT1pwa.replicaCountUsed only when autoscaling is off
CWA_CI_PWA_AUTOSCALEtruepwa.autoscaling.enabled
CWA_CI_PWA_AUTOSCALE_MIN2 for production, 1 for canary and every other trackpwa.autoscaling.minReplicasA canary runs beside production, which keeps serving, so one pod is enough (095c074)
CWA_CI_PWA_AUTOSCALE_MAX6 for production and canary, 2 otherwisepwa.autoscaling.maxReplicas
CWA_CI_PWA_AUTOSCALE_CPU_PERCENT175 (of the CPU request)pwa.autoscaling.targetCPUUtilizationPercentageChange it together with CWA_CI_PWA_CPU_REQUEST
CWA_CI_PWA_AUTOSCALE_MEMORY_PERCENTunset (no memory target)pwa.autoscaling.targetMemoryUtilizationPercentage
CWA_CI_PWA_CPU_REQUEST / CWA_CI_PWA_CPU_LIMIT100m on every track / 1000mpwa.resources.requests.cpu / pwa.resources.limits.cpu
CWA_CI_PWA_MEMORY_REQUEST / CWA_CI_PWA_MEMORY_LIMIT160Mi for production and canary, 128Mi otherwise / 1Gipwa.resources.requests.memory / pwa.resources.limits.memory

Change CWA_CI_PWA_CPU_REQUEST and CWA_CI_PWA_AUTOSCALE_CPU_PERCENT together. The target is a percentage of the request, and request × target should stay near 175m, about one render a second. See Kubernetes → Nuxt scales out.

The minimums, maximums and memory requests depend on the environment, so review apps and staging don't reserve as much as production. Limits are the same everywhere.

The Nuxt HPA scales on CPU only. Server rendering is CPU-bound, and an extra pod doesn't relieve memory, because each pod keeps its own caches. Node also rarely hands memory back, and an HPA only scales down when every metric is under its target, so a memory target would add pods under normal load and then keep them. Set CWA_CI_PWA_AUTOSCALE_MEMORY_PERCENT if you want one anyway. If you set a variable, your value is used in every environment.

Orphan scan

The daily orphan scan is a CronJob that runs scan-orphaned and emails MAILER_EMAIL when the orphans change.

VariableDefaultHelm valueNotes
CWA_CI_ORPHAN_SCAN"true" for production, "false" otherwisecronjobs.orphanScan.enabled"true" or "false". Your value is used on every track
CWA_CI_ORPHAN_SCAN_SCHEDULE0 3 * * *cronjobs.orphanScan.scheduleCron schedule
CWA_CI_ORPHAN_SCAN_TIMEZONEEurope/Londoncronjobs.orphanScan.timeZoneTime zone for the schedule. Needs Kubernetes 1.27+; older clusters use UTC

Staging and canary are off by default because they can share production's database, so their scans would repeat production's alert. Review apps are short-lived.

The email goes to MAILER_EMAIL, the same address the site sends from (orphaned_resources.notify.recipients in api/config/packages/silverback_api_components.yaml). Both a bare address and the Name <address> form work; see Emailing when orphans change.

An empty MAILER_EMAIL means no email. An unset MAILER_DSN renders as null://null, so nothing is sent. The GitHub deploy jobs pass MAILER_DSN (a secret), MAILER_EMAIL and the CWA_CI_ORPHAN_SCAN* variables.

Origin protection

These protect php and Nuxt from requests that get past the cache. Every one is optional: leave it unset, or empty, and the default in api/frankenphp/Caddyfile or worker.Caddyfile applies. On GitHub, set them as Actions variables and they reach the deploy without a workflow change.

The Caddy settings are site settings: set CWA_API_<NAME> in CI and the php container gets <NAME>, so CWA_API_RATE_LIMIT_EVENTS arrives as RATE_LIMIT_EVENTS. The local compose stack uses the bare names (see Docker → Origin protection settings). The CWA_CI_INGRESS_* ones are deploy switches, which the deploy turns into ingress annotations.

VariableDefaultHelm valueNotes
CWA_API_CACHE_QUERY_ALLOWLISTpage search ordersiteEnv.apiSecretQuery parameters that pages keep, space-separated. A name also keeps name[...]. Add your pages' own parameters, but never cwa_force. * turns it off. See Query parameters on pages
CWA_API_RATE_LIMIT_ENABLEDtruesiteEnv.apiSecretfalse turns off the per-visitor limit on cache misses
CWA_API_RATE_LIMIT_EVENTS3000siteEnv.apiSecretRequests per window per visitor. Cache hits, /_nuxt, Mercure and private addresses aren't counted
CWA_API_RATE_LIMIT_WINDOW1msiteEnv.apiSecretWindow length, as a Go duration such as 10s
CWA_API_CADDY_TRUSTED_PROXIESprivate_rangessiteEnv.apiSecretProxies whose X-Forwarded-For is believed. Add a public proxy's ranges, e.g. a Google load balancer: private_ranges 35.191.0.0/16 130.211.0.0/22
CWA_API_CLOUDFLARE_IP_RANGESCloudflare's published rangessiteEnv.apiSecretOnly requests from these addresses are counted by CF-Connecting-IP. Update it if Cloudflare changes its ranges before a template release does
CWA_API_FRANKENPHP_MAX_WAIT_TIME5ssiteEnv.apiSecretA request that waits this long for a free php worker gets a 503 at once instead of queueing into a 504. 0 turns it off. See Origin Protection → Load shedding
CWA_CI_INGRESS_RATE_LIMIT_RPSunset (off)ingress.annotations limit-rpsingress-nginx limit-rps. See Origin Protection → Ingress rate limits before you set it
CWA_CI_INGRESS_RATE_LIMIT_BURST_MULTIPLIERunset (nginx's default, 5)ingress.annotations limit-burst-multiplierUsed only with CWA_CI_INGRESS_RATE_LIMIT_RPS
CWA_CI_INGRESS_RATE_LIMIT_CONNECTIONSunsetingress.annotations limit-connectionsUsed only with CWA_CI_INGRESS_RATE_LIMIT_RPS

See Rate limit on cache misses for what is counted and how the visitor's address is found.

The per-visitor rate limit needs the cluster's ingress controller to keep the client address. Set externalTrafficPolicy: Local on the ingress controller's own Service, ideally with 2 replicas and a PodDisruptionBudget, as the template's own cluster runs it. With the default Cluster, every request seems to come from a node's private address, so every visitor looks internal and the limit never applies (#135). Check visitor_ip in php's access log. See Origin Protection → Ingress rate limits.
CADDY_EDGE isn't a deploy variable. Only compose.prod.yaml sets it, and the chart doesn't pass it, so on Kubernetes it stays false. That's right there, because Caddy sits behind the ingress.

CDN purging

Only needed when a CDN caches your pages. See Cloudflare.

VariableDefaultHelm valueNotes
CADDY_CACHE_CDN_CONFIGstrategy hardphp.caddy.cdnConfigSouin's cdn block, one setting per line. Leave it unset without a CDN. See below
CLOUDFLARE_API_TOKEN——GitLab only, and a name you choose: a masked variable holding a Cloudflare API token scoped to Zone → Cache Purge for the site's zone. CADDY_CACHE_CDN_CONFIG refers to it, so turn on Expand variable reference for that variable. The template never reads it directly
CWA_API_CLOUDFLARE_PURGE_PLANfreesiteEnv.apiSecretCloudflare only. free, pro, business or enterprise: paces purges to that plan's limit. An unknown value logs an error and uses free. See Queued and paced purges
CWA_API_CLOUDFLARE_PURGE_REQUESTSfrom the plansiteEnv.apiSecretPurge requests per CWA_API_CLOUDFLARE_PURGE_WINDOW. Set it lower when staging and production, or several sites, share a Cloudflare account
CWA_API_CLOUDFLARE_PURGE_WINDOWfrom the plansiteEnv.apiSecretA Go duration, such as 1m or 1s
CWA_API_CLOUDFLARE_PURGE_BURSTfrom the plansiteEnv.apiSecretPurge requests that can be sent at once

CADDY_CACHE_CDN_CONFIG: setting it replaces the default, so repeat strategy hard. For Cloudflare: strategy hard, provider cloudflare, zone_id <zone ID> and api_key $CLOUDFLARE_API_TOKEN, with no email line. It is stored in the release's Secret.

The CWA_API_CLOUDFLARE_PURGE_* settings reach php as CLOUDFLARE_PURGE_*, like any site setting. Locally, compose passes the bare names through when they're set. CADDY_CACHE_CDN_CONFIG keeps its own name, because the deploy checks it before anything changes.

On GitHub, a variable can't refer to another one. Put the token directly in CADDY_CACHE_CDN_CONFIG, store that as a secret, and map it in the deploy jobs' env:, because the template's workflows don't map it.

On GitLab, scope both variables to production, and protect the token only if the deploy branch is protected: see Cloudflare → Secrets. A deploy fails before rolling anything out if a cdn setting has no value or an unexpanded $NAME.

Image variants

These control generating image variants after a deploy, in one-off Kubernetes Jobs (see Kubernetes → Image Variants Jobs). Since template 2.0.0-alpha.27 · release notes

VariableDefaultHelm valueNotes
CWA_CI_IMAGE_VARIANTS_PRODUCTIONon—Set "false" to stop generating variants after production deploys
CWA_CI_IMAGE_VARIANTS_STAGINGoff—Set "true" to run it after staging deploys
CWA_CI_IMAGE_VARIANTS_CANARYoff—Set "true" to run it after canary deploys
CWA_CI_IMAGE_VARIANTS_REVIEWoff—Set "true" to run it after review deploys
CWA_CI_IMAGE_VARIANTS_MEMORY1536Micronjobs.imageVariants.resources (memory request and limit)Each Job's memory. Raise it if a Job is OOM-killed (exit 137)
CWA_CI_IMAGE_VARIANTS_MAX_MEMORY1Gcronjobs.imageVariants.maxMemoryimagine:generate --max-memory: the resident memory at which a Job stops and the next one carries on. Keep it well under CWA_CI_IMAGE_VARIANTS_MEMORY
CWA_CI_IMAGE_VARIANTS_MAX_RUNS50—The most Jobs one deploy creates. Variants still missing after that are left to the next deploy
CWA_CI_IMAGE_VARIANTS_TIMEOUT1900—Seconds the pipeline waits for each Job. The Job itself is stopped after cronjobs.imageVariants.activeDeadlineSeconds (1800)

As with the cache warm, there's one variable per track, because the staging job runs under environment: production. On GitHub, set them as Actions variables.

Cache warm

These control the cache warm after a deploy.

VariableDefaultHelm valueNotes
CWA_CI_WARM_CACHE_PRODUCTIONon—Set "false" to stop warming production
CWA_CI_WARM_CACHE_STAGINGoff—Set "true" to warm staging
CWA_CI_WARM_CACHE_CANARYoff—Set "true" to warm canary
CWA_CI_WARM_CACHE_REVIEWoff—Set "true" to warm review apps
CWA_CI_WARM_CACHE_CONCURRENCY3—Pages requested at once
CWA_CI_WARM_CACHE_INSECURE——Set "true" to skip TLS verification, e.g. for an environment with a self-signed certificate

Set these as GitLab CI/CD variables or GitHub repository variables. There's one variable per track, not one environment-scoped variable, because the template's staging deploy job uses environment: production. An environment scope therefore can't tell staging and production apart.

Performance audit

These control the manual performance audit.

VariableDefaultHelm valueNotes
CWA_CI_PERFORMANCE_AUDIT_PRODUCTION, _STAGING, _REVIEWon—Set "false" to remove that environment's audit job (GitLab) or disable it in the workflow (GitHub)
CWA_CI_PERFORMANCE_AUDIT_CANARYoff—GitLab only. Set "true" to add performance audit canary. Canary shares production's hostname and would measure whichever pod answers
CWA_CI_PERFORMANCE_AUDIT_URLS——Pages to audit instead of the sitemap. Paths or full URLs, comma or space separated. A path such as /blog is joined to the environment URL
CWA_CI_PERFORMANCE_AUDIT_MAX_PAGES3—How many sitemap pages to audit when CWA_CI_PERFORMANCE_AUDIT_URLS is unset
CWA_CI_PERFORMANCE_AUDIT_FORM_FACTORSmobile—mobile, desktop or mobile,desktop. On GitHub you choose this when you start the workflow
CWA_CI_PERFORMANCE_AUDIT_RUNS5—Runs per page. Budgets use the median run
CWA_CI_PERFORMANCE_AUDIT_THROTTLINGdevtools—devtools (real throttling) or simulate
CWA_CI_PERFORMANCE_AUDIT_CONFIGbin/devops/lighthouserc.json—The Lighthouse CI config. It holds only the budgets; the collect settings are passed on the command line
CWA_CI_PERFORMANCE_AUDIT_LHCI_VERSION0.15.1—The @lhci/cli version
CWA_CI_PERFORMANCE_AUDIT_INSECURE——Set "true" to skip TLS verification, for the sitemap fetch and in Chrome, e.g. for an environment with a letsencrypt-staging or self-signed certificate
CWA_CI_PERFORMANCE_AUDIT_OUTPUTperformance-report—The directory the reports and summary.md are written to. The CI jobs collect performance-report/ as their artifact, so change it only when running performance_audit outside the pipeline
CWA_CI_PERFORMANCE_AUDIT_IMAGEa pinned cypress/browsers image—GitLab only. The job image, which must have Node and Chrome. Set in .gitlab-ci.yml

Pipeline flags

VariableDefaultHelm valueNotes
CWA_CI_BUILD_DISABLEDfalse—Skip the build stage entirely
CWA_CI_TEST_DISABLEDfalse—Skip the test stage
CWA_CI_STAGING_ENABLEDtrue on GitLab, unset (off) on GitHub—Auto-deploy staging on merge to main. .gitlab-ci.yml defaults it to "true". ci.yml has no default, so on GitHub staging runs only once you set it to true
CWA_CI_CANARY_ENABLEDtrue—GitLab only. Show the canary job. On GitHub, canary is an option of the manual production workflow
CWA_CI_REVIEW_DISABLED——Set "true" to disable review apps on branches
CWA_CI_PRODUCTION_DISABLED——GitLab only. Set "true" to remove the production job, with its fixture, image variants, cache warm and performance audit jobs
CWA_CI_DISABLED——GitHub only. Set "true" to stop the workflows running at all (see CI/CD → GitHub Actions)
CWA_CI_FIXTURES_ENABLEDfalse—Set "true" to load fixtures after deploying: a manual job on GitLab, an automatic step on GitHub. See Fixtures on a deploy
CWA_CI_FIXTURES_PURGEfalse—Makes the fixture job empty the database before loading. Review apps: "true" or "force". Production: only "force". See Purging before a load
CWA_CI_RESET_DATABASEfalsephp.resetDatabaseSet "true" to make the php pod drop the whole schema before it runs migrations, every time it starts. Never leave it on
CWA_CI_HELM_UNINSTALL——Any value uninstalls the release before the deploy installs it again
CWA_CI_TRACE——Any value makes setup.sh print each command it runs (set -x)

Site settings

A site's own settings, such as an analytics ID for Nuxt or an API key for a Symfony service, are CI variables. The Kubernetes deploy passes them to the containers by their prefix, so adding one needs no change to k8s.sh, the chart or a workflow (#125, 497e8ac, 75a416b).

CI variableReachesAsStored in
NUXT_PUBLIC_<NAME>The Nuxt podsNUXT_PUBLIC_<NAME>, unchangedThe ConfigMap <fullname>-pwa-env (siteEnv.pwa)
Any other NUXT_<NAME>The Nuxt podsNUXT_<NAME>, unchangedThe Secret <fullname>-pwa-env (siteEnv.pwaSecret)
CWA_API_<NAME>The php pod and the orphan scan CronJob<NAME>, without the prefixThe Secret <fullname>-api-env (siteEnv.apiSecret)

The chart adds them to the containers with envFrom (see Kubernetes → Site Settings). The API's settings need the CWA_API_ prefix because its own names, such as DATABASE_URL or APP_SECRET, are too generic to pass through whole.

An empty CWA_API_ value is left out, and the log says so (api: <NAME> left unset, CWA_API_<NAME> is empty). An empty variable would replace the Caddyfile's {$NAME:default} and Symfony's defaults, so leaving it out keeps them. An empty NUXT_* value is still delivered, because Nuxt applies an empty value on purpose.

The API's optional settings

The template's own optional API settings go through the same passthrough. Set them as CWA_API_<NAME> in CI; the local compose stack uses the bare <NAME>. Unset, the default in the Caddyfile or in Symfony applies (d64c278).

CI variableWhat it sets
CWA_API_CACHE_QUERY_ALLOWLIST, CWA_API_RATE_LIMIT_ENABLED, CWA_API_RATE_LIMIT_EVENTS, CWA_API_RATE_LIMIT_WINDOW, CWA_API_CADDY_TRUSTED_PROXIES, CWA_API_CLOUDFLARE_IP_RANGES, CWA_API_FRANKENPHP_MAX_WAIT_TIMEOrigin protection. See Origin protection
CWA_API_CLOUDFLARE_PURGE_PLAN, _REQUESTS, _WINDOW, _BURSTCloudflare purge pacing. See CDN purging
CWA_API_CADDY_GLOBAL_CONFIGExtra lines for the Caddyfile's global options block, such as debug
CWA_API_JWT_COOKIE_SAMESITEThe SameSite attribute of the session, JWT and refresh token cookies. Default lax
CWA_API_MERCURE_JWT_ALGORITHMThe algorithm Symfony signs Mercure tokens with. Default hmac.sha256
CWA_API_WEBSITE_NAMEThe site's name in emails (the bundle's website_name). Default New Website, from api/.env. A multi-site project can export it per site from project.sh (c1a3201)

Their bare CI names, such as RATE_LIMIT_EVENTS or CLOUDFLARE_PURGE_PLAN, are no longer read by the deploy.

Each side reads them as it reads any environment variable:

  • Nuxt uses a NUXT_* variable only for a key you declare in runtimeConfig in app/nuxt.config.ts. NUXT_PUBLIC_ANALYTICS_ID sets runtimeConfig.public.analyticsId, and NUXT_MAPS_KEY sets the server-only runtimeConfig.mapsKey.
  • Symfony reads CWA_API_MAPS_KEY as MAPS_KEY, through '%env(MAPS_KEY)%'. Give it a default in api/.env so the local stack still boots.
// app/nuxt.config.ts
export default defineNuxtConfig({
    runtimeConfig: {
        mapsKey: '',          // NUXT_MAPS_KEY, server only
        public: {
            analyticsId: '',  // NUXT_PUBLIC_ANALYTICS_ID, sent to browsers
        },
    },
})
NUXT_PUBLIC_* values aren't secret. Nuxt sends public runtime config to every browser, in the page HTML. Put keys and tokens in a NUXT_* name without PUBLIC_, which stays on the server.
Site settings are runtime only, never build args. The values reach the containers when they start. The image builds get only what build_api and build_app name, so one image serves every environment. Don't add a --build-arg or build secret for a site setting, and don't read one in a Dockerfile: read it at runtime, through runtimeConfig in Nuxt or %env()% in Symfony.

On GitLab, every CI variable already reaches the deploy job. Remember that staging runs under environment: production, so a setting scoped to production reaches staging too. Use the environment name where the two must behave differently.

The deploy log prints Environment name: <name> and one line for each setting, such as pwa config NUXT_PUBLIC_ANALYTICS_ID (from NUXT_PUBLIC_ANALYTICS_ID) or api secret MAPS_KEY (from CWA_API_MAPS_KEY). It never prints a value.

Reserved names

The deploy fails, before it changes anything in the cluster, when a site setting uses a name the chart or its image already sets on that container. The chart's own env: entry would win over envFrom, so the value would be ignored without a word. It also fails on a name that isn't usable, such as CWA_API_ with nothing after it, or a <NAME> that starts with a digit. The log lists every problem:

❌ Site environment variables (NUXT_*, CWA_API_*) that can't be passed through:
  CWA_API_DATABASE_URL: DATABASE_URL is reserved on the api container: the chart or its image sets it (use its own CI variable, if it has one, or delete this one)

Reserved on the Nuxt container (SITE_ENV_RESERVED_PWA in k8s.sh):

NUXT_CWA_API_URL, NUXT_PUBLIC_CWA_API_URL, NUXT_PUBLIC_CWA_API_URL_BROWSER, NUXT_SITE_URL and NUXT_PUBLIC_CWA_ENVIRONMENT. The deploy sets NUXT_SITE_URL to https://$DOMAIN, so cached pages' canonical URL and og:url use the site's own host, not whichever host rendered the page. A multi-site project gives each site its host through DOMAIN (#140, 45a3794). The chart sets the API URLs itself (see Kubernetes → apiUrl should stay unset). NUXT_PUBLIC_CWA_API_URL is the deprecated name of NUXT_CWA_API_URL, and as public config it would put the internal API URL in every page.

Reserved on the php container (SITE_ENV_RESERVED_API), as the <NAME> in CWA_API_<NAME>:

ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_USERNAME, APP_DEBUG, APP_ENV, APP_SECRET, APP_UPSTREAM, BROWSER_SERVER_NAME, CACHE_URL, CADDY_CACHE_CDN_CONFIG, CADDY_CACHE_EXTRA_CONFIG, CORS_ALLOW_ORIGIN, CWA_ENVIRONMENT, DATABASE_CA_CERT, DATABASE_CLIENT_CERT, DATABASE_CLIENT_KEY, DATABASE_SSL_MODE, DATABASE_URL, FRANKENPHP_CONFIG, FRANKENPHP_MEMORY_LIMIT, FRANKENPHP_WORKER_NUM, GCLOUD_BUCKET, GCLOUD_JSON, GCLOUD_PUBLIC_URL, GOMEMLIMIT, JWT_PASSPHRASE, JWT_PUBLIC_KEY, JWT_SECRET_KEY, MAILER_DSN, MAILER_EMAIL, MERCURE_CORS_ORIGIN, MERCURE_EXTRA_DIRECTIVES, MERCURE_JWT_SECRET, MERCURE_PUBLIC_URL, MERCURE_PUBLISHER_JWT_ALG, MERCURE_PUBLISHER_JWT_KEY, MERCURE_SUBSCRIBER_JWT_ALG, MERCURE_SUBSCRIBER_JWT_KEY, MERCURE_URL, RESET_DATABASE, SERVER_NAME, TRUSTED_HOSTS and TRUSTED_PROXIES.

Most of these have their own CI variable, such as MAILER_DSN or CORS_ALLOW_ORIGIN (or CWA_CI_API_GOMEMLIMIT for GOMEMLIMIT, CWA_CI_API_WORKERS for FRANKENPHP_WORKER_NUM, CWA_CI_API_PHP_MEMORY_LIMIT for FRANKENPHP_MEMORY_LIMIT, and CWA_CI_RESET_DATABASE for RESET_DATABASE), so set that one instead.

If your own chart values add env: entries to a container, reserve their names too, without editing k8s.sh: set SITE_ENV_RESERVED_API_EXTRA or SITE_ENV_RESERVED_PWA_EXTRA to a space-separated list in project.sh or as a CI variable (#133).

bin/devops/project.sh
export SITE_ENV_RESERVED_API_EXTRA="MAPS_REGION"

On GitHub

Variables named NUXT_* or CWA_API_* reach the deploy through github-export-vars.sh, like every other variable. GitHub can't list secrets, so each deploy job (review, staging, canary and production) also runs bin/devops/github-export-secrets.sh, in the step Expose NUXT_* and CWA_API_* secrets to the deploy. It receives every secret and exports only the NUXT_* and CWA_API_* ones to the later steps, masked, including each line of a multi-line value. It runs after the kubectl and Helm setup actions, so those never see the values, and no build job runs it. So a site secret needs no workflow edit: add it under Settings → Secrets and variables → Actions → Secrets.

The step adds a warning to the run in two cases:

  • Public value stored as a secret: a NUXT_PUBLIC_* secret. It is still exported, but browsers receive it, so make it a variable.
  • Secret shadowed: a variable, or the job's env:, already sets the name, so the secret isn't used. Delete one of the two.

The environment name

The deploy names the environment it serves, from its track, and sets it as CWA_ENVIRONMENT on the php pod and the orphan scan, and as NUXT_PUBLIC_CWA_ENVIRONMENT on the Nuxt pods (Helm value cwaEnvironment):

TrackName
Productionproduction
Stagingstaging
Canarycanary
Review appsreview

So staging is told apart from production, although its GitLab job runs as environment: production. A CWA_ENVIRONMENT CI variable replaces the name on every track it reaches; on GitLab, a value scoped to production reaches staging and canary too. It may hold only letters, digits, ., _ and -, or the deploy fails with CWA_ENVIRONMENT must be letters, digits, '.', '_' or '-', not '<value>'.

Nuxt sees the name only once you declare it, as runtimeConfig.public.cwaEnvironment. Symfony reads it with '%env(CWA_ENVIRONMENT)%'; give it a default in api/.env for the local stack.