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

CI/CD

The template ships full CI/CD for both GitLab CI and GitHub Actions — Docker Buildx builds, tests, per-branch review apps, and staged Kubernetes deployments via Helm.

The template ships complete CI/CD for both GitLab CI and GitHub Actions. Every step delegates to shell functions in bin/devops/ — readable, modifiable scripts you own.

Choose your CI/CD provider when you run pnpm create cwa — the CLI wires up the correct pipeline files automatically. You can switch later by copying the relevant files from the template.

GitHub Actions

Three workflows drive your app, in .github/workflows/:

WorkflowTriggerWhat it does
ci.ymlEvery pushBuild + test; deploy a review environment for non-main branches, or staging on main
production.ymlManualDeploy canary or full production
cleanup.ymlPR closedTear down the review environment

The template repo also has publish-create-cwa.yml, which publishes the scaffolding CLI. create-cwa leaves it out of new projects; if an older project has it, it is safe to delete.

Images push to GHCR (ghcr.io/${{ github.repository }}). The workflows call the same bin/devops/ shell functions as GitLab CI. Required secrets and variables are declared in the workflow files themselves, and listed under Required CI/CD Variables below.

GitHub Actions only passes a variable to a job if the job's env: block names it. GitLab passes every CI/CD variable automatically. If you add a variable to bin/devops/k8s.sh, also add it to the env: block of every deploy job in ci.yml and production.yml. Otherwise GitHub deploys silently use the default.
The workflows run on every push by default. Set the GitHub Actions variableCI_DISABLED=true (under Settings → Secrets and variables → Actions → Variables) to suppress them. This is used on the template repo itself, which deploys via GitLab CI and only mirrors to GitHub.

GitLab CI

The .gitlab-ci.yml has seven stages: build, test, review, staging, canary, production and cleanup. There is no GitLab Auto DevOps magic here.

GitLab runs branch pipelines only. The workflow creates no merge request pipelines, because every job is limited to branches. A merge request shows its branch pipeline's result, so Pipelines must succeed works as expected.

Projects generated before components-web-app cf9c665 also create a merge request pipeline that holds only the unit tests. It fails, because no image has been built for it, and Pipelines must succeed then blocks the merge request. Add a workflow: rule with if: $CI_PIPELINE_SOURCE == "merge_request_event" and when: never, and limit the unit tests job to branches, as the template does.

Pipeline Overview

On every branch: build then test then spin up a review app on Kubernetes. When a branch merges to main: build and test run again, then the pipeline auto-deploys to staging, from where you manually promote through canary to production.

Stages

Build

Two parallel jobs — build api and build app — each run docker buildx and push to the GitLab Container Registry. Layer caching is handled with --cache-to / --cache-from against a dedicated cache image tag, so incremental builds are fast.

$CI_REGISTRY_IMAGE/php:$CI_COMMIT_REF_SLUG    ← FrankenPHP image (--target frankenphp_prod)
$CI_REGISTRY_IMAGE/app:$CI_COMMIT_REF_SLUG    ← Nuxt image       (--target prod)

The PHP image build uses api/Dockerfile; the Nuxt image uses app/Dockerfile. Build logic lives in build_api() and build_app() in bin/devops/k8s.sh.

Test

Two parallel jobs against the image built in the previous stage:

JobWhat runs
unit testsvendor/bin/phpunit tests/Unit — fast, no database
behat testsBehat feature suite with a live PostgreSQL service; the schema is rebuilt from the entity mapping (Doctrine SchemaTool, not migrations) before each scenario. For a large suite, see Faster Behat Tests

Both export JUnit XML for GitLab's test report UI.

Review Apps

A branch (other than main) gets a review app, with its own Helm release, only once someone creates its Kubernetes namespace. The pipeline never creates namespaces. The URL follows the pattern https://$CI_COMMIT_REF_SLUG-review.$KUBE_INGRESS_BASE_DOMAIN.

Namespaces must be created by hand, with role bindings for the CI's identity. Letting one project create namespaces and role bindings would give it rights across the whole cluster, so the pipeline's permissions stop at the namespace. See the comment in ensure_namespace().

The review job checks for the namespace first (skip_review_without_namespace):

NamespaceGitLabGitHub Actions
Exists, and the CI can read itDeploysDeploys
Not found, or the CI is forbidden to read itThe job exits with code 3, which the job allows, so the pipeline is orange ("passed with warnings")A No review environment warning annotation. The deploy, fixtures and cache warm steps are skipped
Any other kubectl error, such as an unreachable cluster or a bad contextRedRed

The jobs that follow a review (the review fixture load, cache warm, performance audit and stop jobs) start with skip_unless_review_deployed. When the review job didn't deploy, they exit with code 3 and say why, instead of running against an environment that doesn't exist. Projects created before the template's 2.0.0-alpha.2 release don't have this guard.

An orange pipeline on a branch just means that branch has no review app. A deploy that really fails is still red. Staging, canary and production don't make this check: a missing namespace there fails the job.

Creating a namespace for a branch

  1. Find the name. The job log prints it: No namespace '<name>', so this branch has no review environment to deploy to. It is KUBE_NAMESPACE, which defaults to <project name>-<environment slug>. On GitHub that is <repository name>-review-<branch slug>. On GitLab the slug is GitLab's CI_ENVIRONMENT_SLUG for review/<branch>, which GitLab shortens and may give a random suffix, so copy the name from the log.
  2. Create the namespace, and bind the identity your CI deploys with to a role in it. For example, with the built-in edit role:
    kubectl create namespace <name>
    kubectl create rolebinding ci-deploy --namespace <name> \
      --clusterrole=edit --serviceaccount=<ci-namespace>:<ci-service-account>
    

    Which identity and role you need depends on your cluster and on how the CI connects (the GitLab agent, or the KUBECONFIG secret on GitHub). Give it the same access as your other environments.
  3. Run the review job again.

A namespace that exists without the role bindings still shows orange, because the CI is forbidden to read it.

Review apps are torn down manually via the stop review job (triggered from the GitLab environment list), or automatically when the branch is deleted. Stopping one uninstalls its Helm release but leaves the namespace, so delete the namespace by hand once the branch is gone.

Staging

Runs automatically on every merge to main (controlled by $STAGING_ENABLED, default "true"). Calls deploy staging which runs helm upgrade --install for a separate -staging release. Staging runs under the production environment, so it deploys into production's namespace and gets production's variables.

Canary

A manual job that runs deploy canary. Useful for routing a portion of production traffic to the new image before full rollout. Enable/disable via $CANARY_ENABLED.

Like production, it waits for the builds and tests (see Production below).

Production

A manual job requiring explicit trigger from GitLab. Calls deploy (stable track), then delete canary and delete staging to clean up the intermediate environments.

Production and canary can't start until build api, build app, unit tests and behat tests have passed, and staging too when it is enabled. Each of these needs is optional, so BUILD_DISABLED and TEST_DISABLED still work.

In projects generated before this change, production and canary need only staging. With STAGING_ENABLED set to false, the manual deploy can be started before the images are built. It then deploys the previous main image and ignores failing tests. Add the four build and test jobs to the needs of both jobs in your .gitlab-ci.yml, each with optional: true.

The Devops Scripts

All CI logic lives in two scripts sourced at the top of every job:

bin/devops/setup.sh

Sets shared environment variables:

  • CI_APPLICATION_REPOSITORY / CI_APPLICATION_TAG — image repo and SHA tag
  • PHP_REPOSITORY / APP_REPOSITORY — full image paths
  • PHP_REPOSITORY_CACHE / APP_REPOSITORY_CACHE — layer cache image paths
  • DOMAIN — derived from CI_ENVIRONMENT_URL
  • DEPLOYMENT_BRANCH — defaults to main

bin/devops/k8s.sh

Contains all the functions the pipeline calls:

FunctionWhat it does
install_dependenciesInstalls Helm, kubectl, curl, and other tools on the Alpine CI runner
generate_jwt_keysGenerates RSA key pair and Mercure JWT secret if not set as CI variables
setup_docker_environmentHandles Docker-in-Docker host config for Kubernetes runners
build_api / build_appdocker buildx build --push with registry layer caching
run_test_phpunitRuns PHPUnit unit tests; outputs JUnit XML
run_test_behatConfigures test DB, runs Behat; outputs JUnit XML to api/build/logs/behat/junit/. See Faster Behat Tests once the suite is slow
helm_initUpdates and builds Helm chart dependencies
review_namespace_statePrints present, missing (the namespace is not found, or the CI is forbidden to read it) or error (any other kubectl failure) for $KUBE_NAMESPACE. The GitHub review job calls it directly
skip_review_without_namespaceRuns first in the GitLab review job. Exits with code 3 when the namespace is missing, which the job allows, so the pipeline shows orange. See Review Apps
skip_unless_review_deployedRuns first in the jobs that follow a review. Exits with code 3 when the review job uploaded no environment_url.txt, meaning it didn't deploy
ensure_namespaceVerifies the K8s namespace exists and fails the job if not. Staging, canary and production rely on it
create_docker_pull_secretCreates an imagePullSecret for the GitLab registry
deploy [track]Generates values.tmp.yaml from CI variables and runs helm upgrade --install
ensure_tls_certificateRuns inside deploy before Helm. When production's hostnames change, it issues the new certificate first. See Changing a live site's hostnames
purge_rendered_html [track]Waits for the Nuxt and php rollouts to finish, then clears every cached page. Runs after every deploy
warm_cache [base_url]Refills the page cache after purge_rendered_html, and reports each page's status and time to first byte. Runs as its own job or step after every deploy
performance_audit [base_url]Runs a Lighthouse audit of a few pages. Only runs when you start it. See Auditing performance after a deploy
load_fixtures [track]Loads fixtures into the php pod with --append, so existing content is kept, unless FIXTURES_PURGE asks for a purge. Then flushes the whole HTTP cache. Only runs when ENABLE_DATABASE_FIXTURES is "true". See Pipeline Flags
delete [track]Runs helm uninstall for review/canary/staging cleanup

Why every deploy clears the page cache

Cached pages link to the JavaScript and CSS files of the build that rendered them. A new front-end build gives those files new names and deletes the old ones. Without a purge, the cache keeps serving pages that point at files that no longer exist, so visitors see an unstyled page that does not work until the cached copy expires, which can take up to an hour.

purge_rendered_html prevents this. It waits until the new Nuxt pods have fully replaced the old ones, because an old pod could otherwise put an old page back into the cache. Then it runs php bin/console silverback:api-components:purge-rendered-html in the php pod. That command clears only the rendered pages, which share the cwa-html cache tag, and leaves cached API responses alone. It uses the pipeline's existing kubectl access, so it needs no extra credentials. If a site has no HTTP cache configured, the command does nothing and still succeeds.

Warming the cache after a deploy

A purge leaves the cache empty, so the first visitor to each page would wait for a server render. warm_cache fills it again, as its own job after a deploy: warm cache review, warm cache staging, warm cache canary and warm cache production on GitLab, and a Warm the page cache step on GitHub, after the fixtures step so a new environment is warmed once its content exists. Only production is warmed by default; the other tracks opt in (see the table below). The production deploy never waits for a warm job. It reads /sitemap.xml from the environment URL (following one level of sitemap index), then requests every page anonymously, three at a time by default, and prints each page's status and time to first byte.

Any page that doesn't return 200 is listed under a CACHE WARM FAILED banner, and on GitHub it also raises an ::error annotation on the run summary. The warm job fails, but it is allowed to (allow_failure: true on GitLab, continue-on-error: true on GitHub), so the pipeline shows a warning instead of a failed deploy. By then the new release is already live, and the deploy and its cleanup steps have already run. Read the warm job's log when it warns.

In projects generated before 23 September 2026, a GitLab warm job can fail with exit code 141 after every page returned 200. warm_cache ended with sort | head -3, and GitLab runs jobs with pipefail, so the SIGPIPE that sort gets when head exits failed the job. Replace head -3 with sed -n '1,3s#^# #p' (and drop the sed after it) in bin/devops/k8s.sh. The template now uses sed -n 1p instead of head -1 in its other pipes for the same reason.
VariableDefaultEffect
WARM_CACHE_PRODUCTIONonSet "false" to stop warming production
WARM_CACHE_STAGINGoffSet "true" to warm staging
WARM_CACHE_CANARYoffSet "true" to warm canary
WARM_CACHE_REVIEWoffSet "true" to warm review apps
WARM_CACHE_CONCURRENCY3Pages requested at once
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.

Auditing performance after a deploy

warm_cache tells you how quickly the server answered. performance_audit tells you what a visitor experiences. It runs Lighthouse CI (@lhci/cli) against a few pages and reports LCP, CLS, TBT, FCP, Speed Index and page weight. Run it after the cache warm, so it measures cached pages, which is what visitors get. Each page is loaded anonymously with no query string, so the audit neither bypasses nor splits the cache.

The audit is manual. It is available by default, but it never runs by itself.

  • GitLab: after a deploy, press play on the performance audit review, performance audit staging or performance audit production job. Each one waits for its deploy job, and for its warm job when warming is on. Set PERFORMANCE_AUDIT_REVIEW, PERFORMANCE_AUDIT_STAGING or PERFORMANCE_AUDIT_PRODUCTION to "false" to remove that job. performance audit canary is opt-in (PERFORMANCE_AUDIT_CANARY="true"), because canary shares production's hostname and would measure whichever pod answers.
  • GitHub Actions: run the Performance audit workflow (performance-audit.yml) from the Actions tab. Choose the environment (production, staging or review) and the form factors. For a review app, run the workflow from that branch, because the URL is built from the branch name. Set a repository variable PERFORMANCE_AUDIT_PRODUCTION, PERFORMANCE_AUDIT_STAGING or PERFORMANCE_AUDIT_REVIEW to "false" to disable that environment.

By default it audits the first three pages of /sitemap.xml, on mobile, five runs each. That is 15 page loads. Five runs stop one slow run on a shared CI runner from changing the median. Pages are audited one after another, never in parallel, because parallel runs share the runner's CPU and network and skew every metric.

The audit uses real throttling (Lighthouse's devtools method) by default. Lighthouse's simulated throttling gave very different scores for the same page on shared CI runners, from 0.60 to 0.96. Only the performance category is collected.

VariableDefaultEffect
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
PERFORMANCE_AUDIT_MAX_PAGES3How many sitemap pages to audit when PERFORMANCE_AUDIT_URLS is unset
PERFORMANCE_AUDIT_FORM_FACTORSmobilemobile, desktop or mobile,desktop. On GitHub you choose this when you start the workflow
PERFORMANCE_AUDIT_RUNS5Runs per page. Budgets use the median run
PERFORMANCE_AUDIT_THROTTLINGdevtoolsdevtools (real throttling) or simulate. GitLab only: the GitHub workflow doesn't pass it, so GitHub always uses devtools
PERFORMANCE_AUDIT_CONFIGbin/devops/lighthouserc.jsonThe Lighthouse CI config. It holds only the budgets; the collect settings are passed on the command line
PERFORMANCE_AUDIT_LHCI_VERSION0.15.1The @lhci/cli version
PERFORMANCE_AUDIT_IMAGEa pinned cypress/browsers imageGitLab only. The job image, which must have Node and Chrome. Set in .gitlab-ci.yml

Budgets

The budgets are in bin/devops/lighthouserc.json. They use Lighthouse's "good" thresholds:

MetricBudgetA miss is
Performance scoreat least 0.8an error
Largest Contentful Paintat most 2.5san error
Cumulative Layout Shiftat most 0.1an error
Total Blocking Timeat most 200msan error
Page weightat most 1.6MBa warning only

To change them, edit bin/devops/lighthouserc.json, or point PERFORMANCE_AUDIT_CONFIG at your own file.

Don't put a collect.settings block in this file. performance_audit passes settings such as --collect.settings.chromeFlags on the command line, and any --collect.settings.* option replaces the file's whole settings block. Projects generated earlier have onlyCategories in the file, which never applied, so their audits ran all four categories.

A missed error budget fails the audit, but it never fails a deploy. On GitLab the job is allowed to fail, so the pipeline shows "passed with warnings". On GitHub the audit run fails with a warning. That run is only the audit, and the deploy has already finished.

Reading the results

The reports go in performance-report/: Lighthouse's HTML and JSON for each page and form factor, the budget results, and a summary.md table.

The end of the job log shows the same results as an aligned text table. It has one row per page and form factor, from the median run. The columns are Page, Device, Score, LCP, FCP, TBT, CLS, Speed Index and Weight. Each value with a budget is marked:

  • ✓ within budget
  • ✗ over an error budget, which fails the audit
  • ⚠ over a warning budget

The marks are read from the budgets in lighthouserc.json, so they match what failed the audit.

  • GitLab: download the job's performance-report/ artifact, kept for 30 days. The job also writes a browser_performance report. GitLab uses it to compare metrics between pipelines in the merge request, but that widget needs GitLab Premium. On Free, you still get the artifact.
  • GitHub Actions: the table is in the workflow run's summary. The full reports are in the performance-report-<environment> artifact, kept for 30 days.
Screenshot: the summary of a GitHub Performance audit run, showing the per-page table (page, device, score, LCP, FCP, TBT, CLS, Speed Index, weight, with ✓/✗/⚠ budget marks) and the "Performance audit" warning annotation from a missed budget.
Only audit a production build. The local Docker stack serves Nuxt's unbundled dev build, which is several megabytes per page, so its numbers mean nothing.

Required CI/CD Variables

Set these in Settings → CI/CD → Variables in GitLab. Variables marked auto-generated are created by generate_jwt_keys() at pipeline start if not set — useful for review apps, but for production you should pin them as CI secrets so they don't rotate on every deploy.

Kubernetes

VariableRequiredNotes
KUBE_CONTEXTYesGitLab agent context, e.g. my-group/my-project:my-agent
KUBE_NAMESPACENoPre-created namespace for this environment. Defaults to $CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG
KUBE_INGRESS_BASE_DOMAINYesBase domain for ingress URLs, e.g. k8s.example.com
CI_ENVIRONMENT_URLYesFull URL of this environment (GitLab sets this for named environments)
CLUSTER_ISSUERNocert-manager ClusterIssuer name (default: letsencrypt-staging, set by setup.sh). The staging issuer's certificates are not trusted by browsers, so set this for production
KUBE_INGRESS_ALIAS_DOMAINSNoComma-separated extra domains to alias in ingress. Production's certificate covers them. See Changing a live site's hostnames
LETSENCRYPT_SECRET_NAMENoPrefix of the ingress's TLS secret (default: letsencrypt-cert). You don't need to change it when hostnames change
TLS_CERTIFICATE_TIMEOUTNoHow long a deploy waits for a new certificate when production's hostnames change (default: 600s)
INGRESS_ENABLEDNoSet "true" to enable the ingress resource (default: "false")

JWT & Mercure

VariableNotes
JWT_PASSPHRASEAuto-generated only together with a new JWT_SECRET_KEY. If you set JWT_SECRET_KEY, set this too, or the deploy fails
JWT_SECRET_KEYAuto-generated if not set
JWT_PUBLIC_KEYGenerated with a new key. When 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_SECRETAuto-generated if not set

For production, generate these once and save them as protected, masked CI variables. Otherwise they regenerate on every deploy, invalidating all active user sessions.

Before template 2.0.0-alpha.3 (85d3f43), a supplied JWT_SECRET_KEY without JWT_PASSPHRASE got a random passphrase that could never decrypt it, and JWT_PUBLIC_KEY was never derived. Login then failed at runtime instead of the deploy failing.

Application

VariableDefaultNotes
CORS_ALLOW_ORIGIN—Regex, e.g. ^https?://(.*\.)?example\.com
TRUSTED_HOSTS—Symfony trusted hosts regex. Wrap the alternatives in one anchored group, ^(?:www\.example\.com|php)$, see the warning below
ADMIN_USERNAMEadminInitial admin account username
ADMIN_PASSWORDadminInitial admin account password — change this
ADMIN_EMAILhello@cwa.rocksInitial admin account email
MAILER_DSN—SMTP or SES DSN for transactional email
MAILER_EMAIL—From address for outgoing email. Production's orphan scan also emails it
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 from components-web-app b43fd09; check the value in your CI variables and in older projects' api/.env, compose.yaml and helm/cwa/values.yaml.

Media Storage

VariableDefaultNotes
GCLOUD_JSON{}Service-account key JSON for the bucket. Store it as a secret
GCLOUD_BUCKETno-gcloud-bucketGoogle Cloud Storage bucket for uploads
GCLOUD_PUBLIC_URLbucket URLBase URL for media links, e.g. https://cdn.example.com/. Must end in a slash. If empty, uses https://storage.googleapis.com/<bucket>/

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

Database

VariableDefaultNotes
POSTGRESQL_ENABLEDtrueSet "false" to use an external DB (disables the bundled Postgres pod)
DATABASE_URL—Connection string when using external Postgres
DATABASE_SSL_MODEpreferdisable, prefer, require, verify-full
DATABASE_CA_CERT—CA cert PEM for SSL verification

Autoscaling

The php pod and the Nuxt pod scale separately. The AUTOSCALE_* variables control php and the PWA_* variables control Nuxt.

php (API):

VariableDefault
REPLICA_COUNT1
AUTOSCALEtrue
AUTOSCALE_MIN1
AUTOSCALE_MAX1
AUTOSCALE_CPU_PERCENT90
AUTOSCALE_MEMORY_PERCENT90
PHP_CPU_REQUEST200m for production and canary, 100m otherwise
PHP_MEMORY_REQUEST350Mi for production and canary, 256Mi otherwise
PHP_MEMORY_LIMIT1Gi on every track. Sized for 20 MB photo uploads: see Kubernetes → Resource Requests and Limits

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.

Nuxt (SSR):

VariableDefault
PWA_REPLICA_COUNT1 (used only when autoscaling is off)
PWA_AUTOSCALEtrue
PWA_AUTOSCALE_MIN2 for production and canary, 1 otherwise
PWA_AUTOSCALE_MAX6 for production and canary, 2 otherwise
PWA_AUTOSCALE_CPU_PERCENT70
PWA_AUTOSCALE_MEMORY_PERCENTunset (no memory target)
PWA_CPU_REQUEST / PWA_CPU_LIMIT250m for production and canary, 100m otherwise / 1000m
PWA_MEMORY_REQUEST / PWA_MEMORY_LIMIT160Mi for production and canary, 128Mi otherwise / 1Gi

The minimums, maximums and 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 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.

VariableDefaultNotes
ORPHAN_SCAN"true" for production, "false" otherwise"true" or "false". Your value is used on every track
ORPHAN_SCAN_SCHEDULE0 3 * * *Cron schedule
ORPHAN_SCAN_TIMEZONEEurope/LondonTime 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 from bundle 2.0.0-alpha.7. On 2.0.0-alpha.6, use a bare address; 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 ORPHAN_SCAN* variables.

Projects created before components-web-app 70b9802:
  • Empty MAILER_EMAIL crashes the scan. The config reads %env(default::MAILER_EMAIL)%, which turns an empty value into null. On bundle 2.0.0-alpha.6, scan-orphaned then fails with a TypeError before it scans, so update the bundle or change it to %env(string:default::MAILER_EMAIL)% in api/config/packages/silverback_api_components.yaml, or set MAILER_EMAIL.
  • GitHub deploys don't pass these variables. They pass none of MAILER_DSN, MAILER_EMAIL or ORPHAN_SCAN*. An unset MAILER_DSN also makes the Helm render fail at secrets.yaml.
  • The CronJob inherits RESET_DATABASE. A release left with RESET_DATABASE=true would drop the schema every night.
Compare those files, .github/workflows/* and helm/cwa/templates/ with the template.

Pipeline Flags

VariableDefaultEffect
BUILD_DISABLEDfalseSkip the build stage entirely
TEST_DISABLEDfalseSkip the test stage
STAGING_ENABLEDtrueAuto-deploy staging on merge to main
CANARY_ENABLEDtrueShow the canary job
REVIEW_DISABLED—Set "true" to disable review apps on branches
ENABLE_DATABASE_FIXTURESfalseSet "true" to load fixtures after deploying: a manual job on GitLab, an automatic step on GitHub
FIXTURES_PURGEfalseMakes the fixture job empty the database before loading. Review apps: "true" or "force". Production: only "force"

Fixtures seed a new environment: the scaffold pages and the first admin user. They are off unless ENABLE_DATABASE_FIXTURES is "true".

  • GitLab: a load fixtures job appears for review apps and for production, after the deploy job. It never runs automatically; trigger it from the pipeline UI.
  • GitHub Actions: a Load fixtures step runs after each review deploy, and after a manual production run, while the variable is "true". GitHub has no manual jobs inside a push-triggered workflow, so set the variable for a new environment's first deploy, then remove it.

Staging has no fixture job on either. It runs under the production environment, so with an external database (DATABASE_URL) it uses production's database, and a fixture load there would write to production.

By default load_fixtures runs doctrine:fixtures:load --append, so it never deletes existing content. The admin user fixture can be run any number of times. What the scaffold does on a database that already has content depends on the bundle version:

  • From bundle 2.0.0-alpha.5 (the template pins 2.0.0-alpha.6), appending creates only what is missing and keeps existing content. See Appending to an Existing Site.
  • Up to bundle 2.0.0-alpha.4, the scaffold stops at the first page route that already exists. Nothing is written, and the step reports a failure, which is allowed so it does not fail the deploy.

After a successful load, load_fixtures flushes the whole HTTP cache (silverback:api-components:purge-http-cache), API responses and pages alike. The load changes the database underneath what Souin has cached since the deploy, and nothing else would purge it, so production could otherwise serve the old content for up to a year.

Purging before a load

FIXTURES_PURGE is for an early project whose database should be rebuilt from a changed scaffold. It runs doctrine:fixtures:load without --append, which empties every table first.

TrackPurges withOtherwise
Review apps"true" or "force"Appends
Production"force" only"true" prints a notice and appends, so a project-wide "true" meant for review apps can't empty production

Only the fixture job reads it. A deploy or a pod restart never purges.

On GitHub the fixture step runs after every deploy while ENABLE_DATABASE_FIXTURES is set. With FIXTURES_PURGE set too, every review deploy empties the review database, and every manual production run with "force" empties production. Unset FIXTURES_PURGE once the database has been rebuilt. Add FIXTURES_PURGE to repository variables, not secrets: the workflows read vars.FIXTURES_PURGE.
Before the template fixed this (components-web-app#74), the GitHub workflows loaded fixtures on every deploy, including production. load_fixtures ran without --append, which empties the database first. If your project was generated from an earlier version and deploys with GitHub Actions, check .github/workflows/*.yml and bin/devops/k8s.sh for this before your next production deploy.

On GitHub, staging also only runs when the STAGING_ENABLED variable is set to true. ci.yml has no default for it.


Changing a live site's hostnames

Production's certificate covers its main hostname and every entry in KUBE_INGRESS_ALIAS_DOMAINS. You change that list when you launch a site, add www, or retire the preview hostname. To do it, edit the variables and deploy:

  • Add a hostname: add it to KUBE_INGRESS_ALIAS_DOMAINS.
  • Retire a hostname, such as the preview host: remove it from KUBE_INGRESS_ALIAS_DOMAINS. It then goes offline. The template has no redirect feature, by design.
  • Change the main hostname at launch, for example new.example.org → example.org: the main hostname is the production environment URL, https://$KUBE_INGRESS_BASE_DOMAIN, so change KUBE_INGRESS_BASE_DOMAIN. Staging and review apps are subdomains of it, so they move too. Keep the old host in KUBE_INGRESS_ALIAS_DOMAINS if it should still work.

When the list differs from the live certificate's, the deploy creates a new cert-manager Certificate for the new list. It waits until the certificate is issued, for up to TLS_CERTIFICATE_TIMEOUT (default 600s), and only then runs Helm. The ingress moves from one valid certificate to another, with no downtime. When the list is unchanged, which is almost every deploy, the deploy keeps the live certificate and nothing is reissued. The order and case of the hostnames don't matter.

Point a new hostname's DNS at the cluster's ingress before you deploy. Let's Encrypt's HTTP-01 challenge must reach it. If it can't, the certificate isn't issued and the deploy fails before anything changes. The live site keeps its current certificate, and the log lists the hostnames to check. Fix the DNS and run the deploy again.
Why the certificate is issued first. If the names on the live certificate change in place, cert-manager serves a temporary self-signed certificate while it gets the new one. That takes minutes, or up to an hour of back-off if a challenge fails. With HSTS, browsers refuse the connection outright, on every hostname, including the live one. Let's Encrypt also allows only 5 certificates for the same set of names per week, so don't switch the list back and forth.

Some details:

  • The deploy compares against the certificate on the chart's own ingress, which it finds by the release name. This is why changing the main hostname at launch is still safe.
  • Other ingresses in the namespace are ignored, even with the same chart labels. For example, a redirect ingress you added by hand for a retired hostname keeps its own certificate. The deploy doesn't compare against it or remove it.
  • New certificates are named <LETSENCRYPT_SECRET_NAME>-stable-api-<hash>, from a hash of the hostname list. After a successful deploy, older ones created this way are deleted, with their secrets. The one just replaced is kept, so a rollback still has a valid certificate. You no longer change LETSENCRYPT_SECRET_NAME by hand.
  • Only production (the stable track) is covered. Review, staging and canary each serve one hostname that doesn't change. The first deploy of a release also works as before, because nothing is live yet.

Permissions

The CI service account needs access to certificates.cert-manager.io in the namespace. The deploy creates, patches, gets, watches, lists and deletes Certificates, and deletes the secrets of the ones it removes. It checks only create up front. Without it, the deploy prints a warning and falls back to changing the live certificate in place, which causes the downtime described above.

On GitHub Actions, production.yml doesn't pass INGRESS_ENABLED, KUBE_INGRESS_ALIAS_DOMAINS, CLUSTER_ISSUER, LETSENCRYPT_SECRET_NAME or TLS_CERTIFICATE_TIMEOUT. Add the ones you use to the deploy job's env: block. Without INGRESS_ENABLED, there is no ingress, so none of this runs.

Changing hostnames by hand

Use this if the CI account can't manage Certificates, or your pipeline doesn't have ensure_tls_certificate.

  1. Choose a new value for LETSENCRYPT_SECRET_NAME, for example letsencrypt-cert-2. The production ingress uses the secret <LETSENCRYPT_SECRET_NAME>-stable-api.
  2. Apply a Certificate that writes that secret and covers the new hostnames:
    apiVersion: cert-manager.io/v1
    kind: Certificate
    metadata:
      name: letsencrypt-cert-2-stable-api
    spec:
      secretName: letsencrypt-cert-2-stable-api
      issuerRef:
        group: cert-manager.io
        kind: ClusterIssuer
        name: letsencrypt-prod # your CLUSTER_ISSUER
      dnsNames:
        - example.org
        - www.example.org
    
  3. Wait until it is ready: kubectl wait --for=condition=Ready certificate/letsencrypt-cert-2-stable-api -n <namespace> --timeout=600s.
  4. Set LETSENCRYPT_SECRET_NAME to the new value, update the hostname variables, and deploy.
  5. Keep the old Certificate and secret until the site works on the new one. Then delete them.

Rollback

# List Helm 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

Helm's rollback restores the previous release's values, image tag included. The template's pipeline tags images with the branch slug ($CI_COMMIT_REF_SLUG, so main for production), not the commit SHA, and deploys with pullPolicy: Always. The previous release therefore points at the same tag, and a rollback pulls whatever image that tag currently holds. To roll the code back as well, tag images with the commit SHA (CI_APPLICATION_TAG in setup.sh) and deploy that tag.