Deploying a CWA Site
A project made with create-cwa already has a complete pipeline. It builds the images, runs the tests, and deploys to Kubernetes with Helm. This page takes a new site to production in order. Each step links to the page that explains it in full.
main deploying staging, DNS pointing at the ingress, then the manual production job.Prepare the cluster
The pipeline deploys into a cluster you already run. It doesn't install anything cluster-wide, so set these up once:
- A Kubernetes cluster. The deploy jobs install kubectl
1.31.0and Helm3.19.0by default. The daily orphan scan's time zone needs Kubernetes 1.27 or later. - ingress-nginx. The pipeline writes ingress-nginx annotations, and its rate limit and
wwwredirect settings are ingress-nginx's. Give the controller's ServiceexternalTrafficPolicy: Local, ideally with 2 replicas and a PodDisruptionBudget. Otherwise every visitor looks internal and Caddy's per-visitor rate limit never applies (see Origin Protection → Ingress rate limits). - cert-manager with two ClusterIssuers, named exactly
letsencrypt-stagingandletsencrypt-prod. The pipeline names the issuer in the ingress'scert-manager.io/cluster-issuerannotation, fromCWA_CI_CLUSTER_ISSUER, but never creates it. It defaults toletsencrypt-staging. - A way for CI to reach the cluster. On GitLab, a GitLab agent whose context you set as
KUBE_CONTEXT. On GitHub, a kubeconfig in theKUBECONFIGsecret, and its context in theKUBE_CONTEXTsecret.
CWA_CI_CLUSTER_ISSUER to the name on every deploy.The issuers use Let's Encrypt's HTTP-01 challenge through ingress-nginx. A minimal pair, following cert-manager's own examples (use your own email address):
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: ops@example.com
privateKeySecretRef:
name: letsencrypt-staging
solvers:
- http01:
ingress:
ingressClassName: nginx
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@example.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
ingressClassName: nginx
Create the namespaces
The pipeline never creates namespaces. It only deploys into ones that exist, with role bindings for the identity CI deploys with:
- Production's namespace, which staging and canary share. On GitLab it's
<project name>-productionunless you setKUBE_NAMESPACE. On GitHub, set its name in theKUBE_NAMESPACE_PRODUCTIONsecret. - One namespace per branch that should get a review app. A branch without one is skipped, and its pipeline shows orange.
How to create them, and the role binding to add, is in CI/CD → Creating a namespace for a branch. The CI identity also needs access to cert-manager's Certificates in production's namespace, so changing hostnames later causes no downtime (see CI/CD → Permissions).
Set the CI variables
The CLI sets up GitLab CI or GitHub Actions when you create the project. Set the few variables a new site needs: the cluster connection, the domain (KUBE_INGRESS_BASE_DOMAIN), an external database, the media bucket, the mailer and the first admin's password. The checklist is CI/CD Variables → Minimum for a new site.
DATABASE_URL and CWA_CI_POSTGRES_ENABLED="false" (see CI/CD Variables → Database).Point DNS at the ingress
Let's Encrypt's HTTP-01 challenge must reach the cluster's ingress, so point these hostnames at the ingress controller's address:
- production:
KUBE_INGRESS_BASE_DOMAIN, and anyKUBE_INGRESS_ALIAS_DOMAINS; - staging:
staging.<KUBE_INGRESS_BASE_DOMAIN>; - review apps:
<branch>-review.<KUBE_INGRESS_BASE_DOMAIN>, usually through a wildcard record.
If Cloudflare is in front, leave each record DNS only until its first certificate is issued (see Cloudflare → Each deploy track).
Push main and check staging
A push to main builds the images, runs the tests and deploys staging. On GitHub, staging runs only once you set the CWA_CI_STAGING_ENABLED variable to true (see Pipeline flags).
Staging gets a certificate from letsencrypt-staging, which browsers don't trust, so expect a certificate warning. That's on purpose: if a hostname or the DNS is wrong, the challenge fails against the staging issuer's generous limits instead of Let's Encrypt's production limits. Check that staging serves the site, warning aside.
Switch to trusted certificates
Once the domain points at the cluster and staging's certificate was issued, set CWA_CI_CLUSTER_ISSUER=letsencrypt-prod, and keep it on every production site. On GitLab, a variable scoped to production reaches staging and canary too.
Deploy production
Production is a manual job: production in GitLab's pipeline, or the Production workflow on GitHub, run from the Actions tab. On GitLab it waits for the builds and tests. Both remove staging and canary once production is deployed (see CI/CD → Production).
For the first deploy only, load the fixtures, which create the scaffold pages and the first admin user:
- Set
CWA_CI_FIXTURES_ENABLED="true". - Deploy production. On GitLab, then start the manual load fixtures production job. On GitHub, the Load fixtures step runs after the deploy by itself.
- Remove the variable. On GitHub the step runs after every deploy while it's set.
Fixture loads append and never delete content. See CI/CD → Fixtures on a deploy.
Optional: put Cloudflare in front
Proxy only, with no edge caching, is the recommended start: Cloudflare → Option A. Turn on edge caching later, if you want it, with Option B.
Check the site
- The cache warm job after the production deploy lists every sitemap page with its status. Any page that isn't
200is listed underCACHE WARM FAILED(see CI/CD → Warming the cache after a deploy). - Pages come from the cache. Request a page twice: the second response's
cache-statusheader shows a Souin hit. Behind Cloudflare's Option A,cf-cache-statusisDYNAMIC.curl -sI https://www.example.com/ | grep -i -E 'cf-cache-status|cache-status' - You can sign in. Sign in at
/loginwithADMIN_USERNAMEandADMIN_PASSWORD, and change the password if you left the default. - Optional: run the performance audit to see what a visitor experiences.
200, with its time to first byte.Which page do I need?
| I want to… | Page |
|---|---|
| Run the stack locally, or on one server with compose | Docker |
| Understand what the Helm chart deploys and how it scales | Kubernetes & Helm |
| Understand the pipeline's stages, scripts, fixtures and hostname changes | CI/CD |
| Look up a CI variable, its default, or the Helm value it sets | CI/CD Variables |
| Put Cloudflare in front, with or without edge caching | Cloudflare |
| Keep made-up URLs and request floods from getting past the cache | Origin Protection |
| See every cache layer and what clears it | Caching |
| Find out how much traffic the site can take | Load Testing |
| Measure what one visitor experiences | Performance Audit |
| Speed up a slow functional test suite | Faster Functional Tests |
| Fix an error or a symptom | Troubleshooting |
| Bring a project up to a newer template release | Releases |
Page Caching
How the module lets a shared cache store rendered page HTML, tags it for purging, and works out how long each page can be kept.
Docker
The Docker Compose stack for local development and single-server production — services, volumes, environment variables, the images and their build stages, and how Caddy caches pages.