Deploying a CWA Site
A project made with create-cwa can run in production in two ways. Pick one first:
| One server with Docker Compose | Kubernetes with Helm | |
|---|---|---|
| Status | Preview | Supported |
| What runs it | compose.prod.yaml on one Linux server, deployed by the template's bin/server/ scripts | The template's Helm chart, deployed by its GitLab CI or GitHub Actions pipeline |
| Scaling | One server | The front end autoscales, and scaled mode runs several API pods |
| Read | Deploy to One Server, then Docker › Production Docker Compose | The steps below, then Kubernetes & Helm and CI/CD |
bin/server/ scripts set up, deploy, back up and restore a site on one server, and media is stored on the server, but the scripts haven't run a production site yet. They assume a plain Linux server, such as Debian or Ubuntu, with Docker installed, and no particular hosting provider. See Deploy to One Server, and Moving From One Server to Kubernetes when you outgrow it.Kubernetes is the supported way to run a site, and the way to scale one. The pipeline builds the images, runs the tests, and deploys to Kubernetes with Helm. The rest of this page takes a new site to production on Kubernetes in order. Each step links to the page that explains it in full.
KUBE_INGRESS_BASE_DOMAIN, staging.… and <branch>-review.… point at the ingress
externalTrafficPolicy: Local
letsencrypt-stagingletsencrypt-prod
production, staging and canary deploy here
optional
- 1Push main
Builds the images, runs the tests and deploys staging. On GitHub, set
CWA_CI_STAGING_ENABLEDto true first - 2Check staging
Its certificate is from
letsencrypt-staging, so expect a browser warning - 3Switch to trusted certificates
Set
CWA_CI_CLUSTER_ISSUER=letsencrypt-prod - 4Deploy production manual
First deploy only: with
CWA_CI_FIXTURES_ENABLED="true", then load the fixtures
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). Before later deploys, you can read production's pending migrations, and turn on a backup before they run: see Migrations and Backups.
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 Cloudflare › Option B.
Check the site
- The cache warm after the production deploy (on GitLab, in the
image variants and warm productionjob) lists every sitemap page with its status. Any page that isn't200is 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. If the deploy or the fixture load warned that the login isadmin/admin, setADMIN_PASSWORDand load the fixtures again: a password you change in the admin goes back toADMIN_PASSWORDat the next fixture load (see CI/CD Variables › The admin password). - 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 understand the compose files | Docker |
| Deploy, back up and restore a site on one server (a preview) | Deploy to One Server |
| Move a site from one server to Kubernetes | Moving From One Server to Kubernetes |
| Understand what the Helm chart deploys and how it scales | Kubernetes & Helm |
| Understand the pipeline's stages, scripts, fixtures and hostname changes | CI/CD |
| See pending migrations before a production deploy, back the database up, or close the site while it migrates | Migrations and Backups |
| 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 |
| Test the built site in a browser before every deploy | Browser Tests |
| 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 (a preview) — services, volumes, environment variables, the images and their build stages, and how Caddy caches pages.