CI/CD
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/:
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml | Every push | Build + test; deploy a review environment for non-main branches, or staging on main |
production.yml | Manual | Deploy canary or full production |
cleanup.yml | PR closed | Tear 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.
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.CI_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.
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:
| Job | What runs |
|---|---|
unit tests | vendor/bin/phpunit tests/Unit — fast, no database |
behat tests | Behat 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.
ensure_namespace().The review job checks for the namespace first (skip_review_without_namespace):
| Namespace | GitLab | GitHub Actions |
|---|---|---|
| Exists, and the CI can read it | Deploys | Deploys |
| Not found, or the CI is forbidden to read it | The 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 context | Red | Red |
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
- Find the name. The job log prints it:
No namespace '<name>', so this branch has no review environment to deploy to.It isKUBE_NAMESPACE, which defaults to<project name>-<environment slug>. On GitHub that is<repository name>-review-<branch slug>. On GitLab the slug is GitLab'sCI_ENVIRONMENT_SLUGforreview/<branch>, which GitLab shortens and may give a random suffix, so copy the name from the log. - Create the namespace, and bind the identity your CI deploys with to a role in it. For example, with the built-in
editrole: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 theKUBECONFIGsecret on GitHub). Give it the same access as your other environments. - 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.
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 tagPHP_REPOSITORY/APP_REPOSITORY— full image pathsPHP_REPOSITORY_CACHE/APP_REPOSITORY_CACHE— layer cache image pathsDOMAIN— derived fromCI_ENVIRONMENT_URLDEPLOYMENT_BRANCH— defaults tomain
bin/devops/k8s.sh
Contains all the functions the pipeline calls:
| Function | What it does |
|---|---|
install_dependencies | Installs Helm, kubectl, curl, and other tools on the Alpine CI runner |
generate_jwt_keys | Generates RSA key pair and Mercure JWT secret if not set as CI variables |
setup_docker_environment | Handles Docker-in-Docker host config for Kubernetes runners |
build_api / build_app | docker buildx build --push with registry layer caching |
run_test_phpunit | Runs PHPUnit unit tests; outputs JUnit XML |
run_test_behat | Configures test DB, runs Behat; outputs JUnit XML to api/build/logs/behat/junit/. See Faster Behat Tests once the suite is slow |
helm_init | Updates and builds Helm chart dependencies |
review_namespace_state | Prints 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_namespace | Runs 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_deployed | Runs 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_namespace | Verifies the K8s namespace exists and fails the job if not. Staging, canary and production rely on it |
create_docker_pull_secret | Creates an imagePullSecret for the GitLab registry |
deploy [track] | Generates values.tmp.yaml from CI variables and runs helm upgrade --install |
ensure_tls_certificate | Runs 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.
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.| Variable | Default | Effect |
|---|---|---|
WARM_CACHE_PRODUCTION | on | Set "false" to stop warming production |
WARM_CACHE_STAGING | off | Set "true" to warm staging |
WARM_CACHE_CANARY | off | Set "true" to warm canary |
WARM_CACHE_REVIEW | off | Set "true" to warm review apps |
WARM_CACHE_CONCURRENCY | 3 | Pages 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 stagingorperformance audit productionjob. Each one waits for its deploy job, and for its warm job when warming is on. SetPERFORMANCE_AUDIT_REVIEW,PERFORMANCE_AUDIT_STAGINGorPERFORMANCE_AUDIT_PRODUCTIONto"false"to remove that job.performance audit canaryis 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 variablePERFORMANCE_AUDIT_PRODUCTION,PERFORMANCE_AUDIT_STAGINGorPERFORMANCE_AUDIT_REVIEWto"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.
| Variable | Default | Effect |
|---|---|---|
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_PAGES | 3 | How many sitemap pages to audit when PERFORMANCE_AUDIT_URLS is unset |
PERFORMANCE_AUDIT_FORM_FACTORS | mobile | mobile, desktop or mobile,desktop. On GitHub you choose this when you start the workflow |
PERFORMANCE_AUDIT_RUNS | 5 | Runs per page. Budgets use the median run |
PERFORMANCE_AUDIT_THROTTLING | devtools | devtools (real throttling) or simulate. GitLab only: the GitHub workflow doesn't pass it, so GitHub always uses devtools |
PERFORMANCE_AUDIT_CONFIG | bin/devops/lighthouserc.json | The Lighthouse CI config. It holds only the budgets; the collect settings are passed on the command line |
PERFORMANCE_AUDIT_LHCI_VERSION | 0.15.1 | The @lhci/cli version |
PERFORMANCE_AUDIT_IMAGE | a pinned cypress/browsers image | GitLab 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:
| Metric | Budget | A miss is |
|---|---|---|
| Performance score | at least 0.8 | an error |
| Largest Contentful Paint | at most 2.5s | an error |
| Cumulative Layout Shift | at most 0.1 | an error |
| Total Blocking Time | at most 200ms | an error |
| Page weight | at most 1.6MB | a warning only |
To change them, edit bin/devops/lighthouserc.json, or point PERFORMANCE_AUDIT_CONFIG at your own file.
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 abrowser_performancereport. 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.
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
| Variable | Required | Notes |
|---|---|---|
KUBE_CONTEXT | Yes | GitLab agent context, e.g. my-group/my-project:my-agent |
KUBE_NAMESPACE | No | Pre-created namespace for this environment. Defaults to $CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG |
KUBE_INGRESS_BASE_DOMAIN | Yes | Base domain for ingress URLs, e.g. k8s.example.com |
CI_ENVIRONMENT_URL | Yes | Full URL of this environment (GitLab sets this for named environments) |
CLUSTER_ISSUER | No | cert-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_DOMAINS | No | Comma-separated extra domains to alias in ingress. Production's certificate covers them. See Changing a live site's hostnames |
LETSENCRYPT_SECRET_NAME | No | Prefix of the ingress's TLS secret (default: letsencrypt-cert). You don't need to change it when hostnames change |
TLS_CERTIFICATE_TIMEOUT | No | How long a deploy waits for a new certificate when production's hostnames change (default: 600s) |
INGRESS_ENABLED | No | Set "true" to enable the ingress resource (default: "false") |
JWT & Mercure
| Variable | Notes |
|---|---|
JWT_PASSPHRASE | Auto-generated only together with a new JWT_SECRET_KEY. If you set JWT_SECRET_KEY, set this too, or the deploy fails |
JWT_SECRET_KEY | Auto-generated if not set |
JWT_PUBLIC_KEY | Generated 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_SECRET | Auto-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
| Variable | Default | Notes |
|---|---|---|
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_USERNAME | admin | Initial admin account username |
ADMIN_PASSWORD | admin | Initial admin account password — change this |
ADMIN_EMAIL | hello@cwa.rocks | Initial 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 |
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
| Variable | Default | Notes |
|---|---|---|
GCLOUD_JSON | {} | Service-account key JSON for the bucket. Store it as a secret |
GCLOUD_BUCKET | no-gcloud-bucket | Google Cloud Storage bucket for uploads |
GCLOUD_PUBLIC_URL | bucket URL | Base 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
| Variable | Default | Notes |
|---|---|---|
POSTGRESQL_ENABLED | true | Set "false" to use an external DB (disables the bundled Postgres pod) |
DATABASE_URL | — | Connection string when using external Postgres |
DATABASE_SSL_MODE | prefer | disable, 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):
| Variable | Default |
|---|---|
REPLICA_COUNT | 1 |
AUTOSCALE | true |
AUTOSCALE_MIN | 1 |
AUTOSCALE_MAX | 1 |
AUTOSCALE_CPU_PERCENT | 90 |
AUTOSCALE_MEMORY_PERCENT | 90 |
PHP_CPU_REQUEST | 200m for production and canary, 100m otherwise |
PHP_MEMORY_REQUEST | 350Mi for production and canary, 256Mi otherwise |
PHP_MEMORY_LIMIT | 1Gi 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):
| Variable | Default |
|---|---|
PWA_REPLICA_COUNT | 1 (used only when autoscaling is off) |
PWA_AUTOSCALE | true |
PWA_AUTOSCALE_MIN | 2 for production and canary, 1 otherwise |
PWA_AUTOSCALE_MAX | 6 for production and canary, 2 otherwise |
PWA_AUTOSCALE_CPU_PERCENT | 70 |
PWA_AUTOSCALE_MEMORY_PERCENT | unset (no memory target) |
PWA_CPU_REQUEST / PWA_CPU_LIMIT | 250m for production and canary, 100m otherwise / 1000m |
PWA_MEMORY_REQUEST / PWA_MEMORY_LIMIT | 160Mi 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.
| Variable | Default | Notes |
|---|---|---|
ORPHAN_SCAN | "true" for production, "false" otherwise | "true" or "false". Your value is used on every track |
ORPHAN_SCAN_SCHEDULE | 0 3 * * * | Cron schedule |
ORPHAN_SCAN_TIMEZONE | Europe/London | Time 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.
70b9802:- Empty
MAILER_EMAILcrashes the scan. The config reads%env(default::MAILER_EMAIL)%, which turns an empty value intonull. On bundle2.0.0-alpha.6,scan-orphanedthen fails with aTypeErrorbefore it scans, so update the bundle or change it to%env(string:default::MAILER_EMAIL)%inapi/config/packages/silverback_api_components.yaml, or setMAILER_EMAIL. - GitHub deploys don't pass these variables. They pass none of
MAILER_DSN,MAILER_EMAILorORPHAN_SCAN*. An unsetMAILER_DSNalso makes the Helm render fail atsecrets.yaml. - The CronJob inherits
RESET_DATABASE. A release left withRESET_DATABASE=truewould drop the schema every night.
.github/workflows/* and helm/cwa/templates/ with the template.Pipeline Flags
| Variable | Default | Effect |
|---|---|---|
BUILD_DISABLED | false | Skip the build stage entirely |
TEST_DISABLED | false | Skip the test stage |
STAGING_ENABLED | true | Auto-deploy staging on merge to main |
CANARY_ENABLED | true | Show the canary job |
REVIEW_DISABLED | — | Set "true" to disable review apps on branches |
ENABLE_DATABASE_FIXTURES | false | Set "true" to load fixtures after deploying: a manual job on GitLab, an automatic step on GitHub |
FIXTURES_PURGE | false | Makes 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 pins2.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.
| Track | Purges with | Otherwise |
|---|---|---|
| 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.
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.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 changeKUBE_INGRESS_BASE_DOMAIN. Staging and review apps are subdomains of it, so they move too. Keep the old host inKUBE_INGRESS_ALIAS_DOMAINSif 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.
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 changeLETSENCRYPT_SECRET_NAMEby 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.
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.
- Choose a new value for
LETSENCRYPT_SECRET_NAME, for exampleletsencrypt-cert-2. The production ingress uses the secret<LETSENCRYPT_SECRET_NAME>-stable-api. - Apply a
Certificatethat 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 - Wait until it is ready:
kubectl wait --for=condition=Ready certificate/letsencrypt-cert-2-stable-api -n <namespace> --timeout=600s. - Set
LETSENCRYPT_SECRET_NAMEto the new value, update the hostname variables, and deploy. - 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.
Kubernetes & Helm
Deploying the CWA stack to Kubernetes using Helm — values configuration, secrets management, migration Jobs, and rolling updates.
Load Testing
Stress test a CWA site with the template's k6 script — how many visitors it serves, how quickly, and whether they got the page cache or a server-side render.