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

Deploying a CWA Site

Take a new CWA site from a generated project to production on Kubernetes, step by step, with the template's GitLab or GitHub pipeline. Starts with what the cluster needs.

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.

Image placeholder: a diagram of the first deploy: a cluster with ingress-nginx and cert-manager, a push to 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.0 and Helm 3.19.0 by 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 www redirect settings are ingress-nginx's. Give the controller's Service externalTrafficPolicy: 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-staging and letsencrypt-prod. The pipeline names the issuer in the ingress's cert-manager.io/cluster-issuer annotation, from CWA_CI_CLUSTER_ISSUER, but never creates it. It defaults to letsencrypt-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 the KUBECONFIG secret, and its context in the KUBE_CONTEXT secret.
The ClusterIssuer names must match. A ClusterIssuer with any other name is never used, and no certificate is issued. If yours have other names, set 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>-production unless you set KUBE_NAMESPACE. On GitHub, set its name in the KUBE_NAMESPACE_PRODUCTION secret.
  • 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.

Use an external database for a real site. The in-cluster PostgreSQL keeps no data by default, so it loses everything when its pod is replaced. Set 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 any KUBE_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:

  1. Set CWA_CI_FIXTURES_ENABLED="true".
  2. Deploy production. On GitLab, then start the manual load fixtures production job. On GitHub, the Load fixtures step runs after the deploy by itself.
  3. 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 200 is listed under CACHE 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-status header shows a Souin hit. Behind Cloudflare's Option A, cf-cache-status is DYNAMIC.
    curl -sI https://www.example.com/ | grep -i -E 'cf-cache-status|cache-status'
    
  • You can sign in. Sign in at /login with ADMIN_USERNAME and ADMIN_PASSWORD, and change the password if you left the default.
  • Optional: run the performance audit to see what a visitor experiences.
Image placeholder: a cache warm job's log after a first production deploy, every page 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 composeDocker
Understand what the Helm chart deploys and how it scalesKubernetes & Helm
Understand the pipeline's stages, scripts, fixtures and hostname changesCI/CD
Look up a CI variable, its default, or the Helm value it setsCI/CD Variables
Put Cloudflare in front, with or without edge cachingCloudflare
Keep made-up URLs and request floods from getting past the cacheOrigin Protection
See every cache layer and what clears itCaching
Find out how much traffic the site can takeLoad Testing
Measure what one visitor experiencesPerformance Audit
Speed up a slow functional test suiteFaster Functional Tests
Fix an error or a symptomTroubleshooting
Bring a project up to a newer template releaseReleases