CI/CD
The template ships a complete .gitlab-ci.yml with eight stages. Every step delegates to shell functions in bin/devops/ — readable, modifiable scripts you own. There is no GitLab Auto DevOps magic here.
GitLab only. CI/CD support is currently GitLab CI only. GitHub Actions support is planned but not yet implemented. If you need GitHub Actions, the shell functions in
bin/devops/are reusable — you would need to write the workflow YAML that calls them.
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 | simple-phpunit tests/Unit — fast, no database |
behat tests | Behat feature suite with a live PostgreSQL service; migrations run before the suite |
Both export JUnit XML for GitLab's test report UI.
Review Apps
Every branch (except main) gets its own Kubernetes namespace and Helm release. The URL follows the pattern https://$CI_COMMIT_REF_SLUG-review.$KUBE_INGRESS_BASE_DOMAIN.
ensure_namespace().Review apps are torn down manually via the stop review job (triggered from the GitLab environment list), or automatically when the branch is deleted.
Staging
Runs automatically on every merge to main (controlled by $STAGING_ENABLED, default "true"). Calls deploy staging which runs helm upgrade --install in the staging namespace.
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.
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.
Performance (optional)
A manual job that runs sitespeed.io against the production environment URL. Reads URLs from .gitlab-urls.txt if present, otherwise tests the environment root. Results upload as GitLab artifacts.
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 |
helm_init | Updates and builds Helm chart dependencies |
ensure_namespace | Verifies the K8s namespace exists (fails fast if not) |
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 |
delete [track] | Runs helm uninstall for review/canary/staging cleanup |
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 | Yes | Pre-created namespace for this environment |
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) |
KUBE_INGRESS_ALIAS_DOMAINS | No | Comma-separated extra domains to alias in ingress |
INGRESS_ENABLED | No | Set "true" to enable the ingress resource (default: "false") |
JWT & Mercure
| Variable | Notes |
|---|---|
JWT_PASSPHRASE | Auto-generated if not set |
JWT_SECRET_KEY | Auto-generated if not set |
JWT_PUBLIC_KEY | Derived from secret key if not set |
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.
Application
| Variable | Default | Notes |
|---|---|---|
CORS_ALLOW_ORIGIN | — | Regex, e.g. ^https?://(.*\.)?example\.com |
TRUSTED_HOSTS | — | Symfony trusted hosts regex |
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 |
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
| Variable | Default |
|---|---|
REPLICA_COUNT | 1 |
AUTOSCALE | true |
AUTOSCALE_MIN | 1 |
AUTOSCALE_MAX | 3 |
AUTOSCALE_CPU_PERCENT | 90 |
AUTOSCALE_MEMORY_PERCENT | 90 |
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 |
PERFORMANCE_DISABLED | false | Skip the sitespeed.io performance job |
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
The previous image tag (the git SHA) is baked into the Helm release history, so Helm's rollback restores both the configuration and the image version in one command.