Helm charts to deploy the whole SDG stack onto OpenShift (or vanilla Kubernetes). There is
one umbrella chart (single-digital-gateway) that installs everything into a namespace from a
single per-environment values file, plus seven standalone subcharts it composes.
charts/
├── single-digital-gateway/ # umbrella — installs the whole stack (this is what you deploy)
│ ├── Chart.yaml # file:// deps on the 7 subcharts below
│ ├── values.yaml # shared base (DATABASE_URL wiring) — applied under every env file
│ ├── values-dev.yaml # per-namespace env config …
│ ├── values-test.yaml # …
│ └── values-prod.yaml # …
├── postgres/ # Crunchy PostgresCluster (needs the PGO operator)
├── valkey/ # Valkey session store (StatefulSet + PVC)
├── platform-api/ # staff BFF (NestJS)
├── citizen-portal-api/ # citizen BFF (NestJS)
├── platform-web/ # staff SPA (nginx)
├── citizen-portal-web/ # citizen SPA (nginx)
└── notification-service/ # internal notifications m2m service (NestJS, no Route)
Each environment namespace (<slug>-dev, <slug>-test, <slug>-prod) has a ResourceQuota of
0.5 CPU / 2Gi that meters container requests — requests.cpu / requests.memory, the BC Gov
Private Cloud model (confirm yours with oc describe quota -n <slug>-dev). Limits are not
quota-metered; they only cap what a container may burst to on its node. So two sums matter, and
only the first is a hard budget:
Requests — quota-metered (must stay ≤ 500m / 2048Mi):
| Workload | CPU req | Mem req | |
|---|---|---|---|
| platform-api | 25m | 128Mi | |
| citizen-portal-api | 25m | 128Mi | |
| notification-service | 25m | 128Mi | |
| platform-web | 10m | 32Mi | nginx |
| citizen-portal-web | 10m | 32Mi | nginx |
| valkey | 25m | 64Mi | |
| postgres — database | 50m | 256Mi | |
| postgres — repo-host | 10m | 48Mi | pgBackRest repo pod |
| postgres — pgbackrest (sidecar) | 10m | 16Mi | in the instance pod |
| postgres — pgbackrest-config (sidecar) | 5m | 16Mi | in the instance pod |
| Steady total | 195m | 848Mi | of 500m / 2048Mi |
| + migrate Job (pre-upgrade, transient) | +25m | +64Mi | Helm runs release hooks serially, so at most ONE migrate Job exists at a time |
| + backup Job running too (worst case) | +5m | +32Mi | peak 225m / 944Mi |
Worst-case peak uses under half the quota — comfortable headroom for surge pods, an HA Postgres replica, or new services later.
Limits — not metered, sized for behaviour (sum 570m / 1648Mi): each container's limit buys
boot speed and burst latency, so they deliberately sum past 500m — that's fine under a
requests-metered quota. Notable: the pgBackRest sidecar carries a 100m limit on a 10m request
because its operator-fixed 1-second liveness probe (pgbackrest server-ping) gets CPU-throttled
past 1s under a lower limit → CrashLoopBackOff that severs in-flight backups (pgBackRest
ERROR [039]). Do not "tidy" that limit down — see the comment in charts/postgres/values.yaml.
The whole stack is single-instance (apps replicaCount: 1, postgres.instances.replicas: 1,
no HA), and apps default to strategy.maxSurge: 0 (the single pod is recreated in place — brief
downtime on upgrade). With the requests headroom above, a surge pod is affordable: set
<app>.strategy.rollingUpdate.maxSurge: 1 per app for zero-downtime rollouts if brief
double-scheduling is acceptable.
LimitRange assumption. Every container the charts render carries explicit requests+limits, but a couple of operator-managed init containers rely on the namespace's LimitRange defaults. A quota on a resource rejects any pod whose containers omit that resource — if the namespace has no LimitRange, add one with small defaults.
Re-check after changing any chart's resources — render the umbrella and sum per dimension (steady containers, plus the largest hook Job and the pgBackRest backup Job as transient peak):
helm dependency build charts/single-digital-gateway
helm template sdg charts/single-digital-gateway \
-f charts/single-digital-gateway/values-prod.yaml | grep -B2 -A4 'requests:'To grow beyond this budget later: raise the app replicaCount / strategy.maxSurge and
postgres.instances.replicas (to 2 for HA), then re-check the request sums.
The stack requests three PVCs, tuned in the env files to total 4Gi (under a 5Gi namespace storage quota):
| PVC | Value key | Size |
|---|---|---|
| Postgres data | postgres.instances.storage.size |
2Gi |
| pgBackRest repo | postgres.backups.storage.size |
1Gi |
| Valkey data | valkey.persistence.size |
1Gi |
| Total | 4Gi |
Notes:
- These sizes are set per environment in
values-{dev,test,prod}.yaml— raise them when a namespace has a larger storage quota (prod especially, for backup retention). - Set
valkey.persistence.enabled: falseto drop the Valkey PVC entirely — sessions become ephemeral, so a Valkey pod restart logs everyone out (acceptable in dev/test). - pgBackRest always needs a repo volume (a repo is mandatory), so the minimum is 2 PVCs.
- PVCs are not deleted by
helm uninstall(see Uninstall); if a redeploy hits the quota, delete leftover PVCs from a previous install first.
-
Helm 3.8+ and
oc/kubectllogged in to the target cluster. -
Namespace admin is sufficient — cluster admin is NOT required. Every chart creates only namespaced resources (Deployment, StatefulSet, Service, Route, ConfigMap, ServiceAccount, Job, the
PostgresClusterCR). There are no CRDs, ClusterRoles, or operators in these charts.- The target namespaces are pre-provisioned by the platform (e.g. BC Gov Private Cloud
license-plate namespaces
<slug>-dev|test|prod|tools). Do NOT pass--create-namespace— you don't have permission to create/patch the Namespace object, and Helm will fail withnamespaces "<slug>-dev" is forbidden: … cannot patch resource "namespaces". Just target the existing namespace with-n <slug>-dev.
- The target namespaces are pre-provisioned by the platform (e.g. BC Gov Private Cloud
license-plate namespaces
-
Crunchy Postgres Operator (PGO) must already be running cluster-wide and watching your namespaces, and its
PostgresClusterCRD must be installed. Thepostgreschart only creates aPostgresClusterCR — the platform-run operator reconciles it. On the BC Gov Private Cloud PaaS this is already provided; you neither install nor can install the operator yourself (that needs cluster admin). Verify it's available with:oc get crd postgresclusters.postgres-operator.crunchydata.com. -
Container images pushed to a registry the cluster can pull. Default repositories are
ghcr.io/bcgov/csbc-single-digital-gateway/{platform-api,citizen-portal-api,platform-web,citizen-portal-web,notification-service,db-migrate,notification-db-migrate}(overrideimage.repository/image.tagper environment). If the GHCR packages are private (the org default), create a pull secret and reference it viaimagePullSecrets, or podsImagePullBackOff:oc -n <slug>-dev create secret docker-registry ghcr-pull \ --docker-server=ghcr.io --docker-username=<gh-user> --docker-password=<gh-pat-read:packages> # then in each env file, e.g.: # platform-api: # imagePullSecrets: [{ name: ghcr-pull }]
-
Pre-created Secrets in each namespace (the charts reference them by name; they never create them):
Secret Used by Keys platform-api-secretsplatform-api OIDC_CLIENT_SECRET,AUTH_SESSION_SECRET,VALKEY_URL,NOTIFICATIONS_M2M_CLIENT_SECRET,BC_GEOCODER_API_KEY(optional)citizen-portal-api-secretscitizen-portal-api OIDC_CLIENT_SECRET,AUTH_SESSION_SECRET,VALKEY_URL,NOTIFICATIONS_M2M_CLIENT_SECRET,BC_GEOCODER_API_KEY(optional)valkey-secretsvalkey VALKEY_PASSWORDnotification-service-secretsnotification-service SMTP_URLsdg-pguser-sdgboth APIs ( DATABASE_URL)operator-generated — do not create DATABASE_URLis not in the app secrets — it is sourced from the operator'ssdg-pguser-sdg(and notification-service'sNOTIFICATION_DATABASE_URLfromsdg-pguser-notifications) secret (key: uri) via the umbrella's basevalues.yaml.VALKEY_URLis a fullredis://:<password>@<release>-valkey:6379string; put it in the app secret and use the same password asvalkey-secrets.
Example — create the Valkey password and an app secret:
oc -n <slug>-dev create secret generic valkey-secrets \
--from-literal=VALKEY_PASSWORD="$(openssl rand -hex 24)"
oc -n <slug>-dev create secret generic platform-api-secrets \
--from-literal=OIDC_CLIENT_SECRET=... \
--from-literal=AUTH_SESSION_SECRET="$(openssl rand -hex 32)" \
--from-literal=VALKEY_URL="redis://:<same-password>@sdg-valkey:6379"
# optional — enables the address "Search for your address" field (feature 154). Omit to disable:
# --from-literal=BC_GEOCODER_API_KEY=<bc-api-portal-key>BC_GEOCODER_API_KEY is optional: add it to each API secret to enable the address-search
typeahead (BC OLS geocoder, feature 154). When absent, the field is hidden. The non-sensitive
BC_GEOCODER_URL defaults to the public endpoint in each API chart's env:.
The umbrella declares the seven subcharts as local file:// dependencies — vendor them once:
helm dependency build charts/single-digital-gatewayThe platform-api and notification-service migrate Jobs are pre-install/pre-upgrade Helm
hooks, and Helm runs pre-install hooks before any release resource — including the
PostgresCluster CR. On a brand-new namespace the operator hasn't created sdg-pguser-sdg /
sdg-pguser-notifications or a ready database yet, so split the first install:
# 1) bring up Postgres (+ Valkey + apps) WITHOUT the migrate hooks
# (namespace is pre-provisioned — do NOT pass --create-namespace)
helm install sdg charts/single-digital-gateway \
-n <slug>-dev \
-f charts/single-digital-gateway/values-dev.yaml \
--set platform-api.migrations.enabled=false \
--set notification-service.migrations.enabled=false
# 2) wait for the operator to provision the cluster + user secrets
oc -n <slug>-dev wait --for=condition=Ready \
pod -l postgres-operator.crunchydata.com/role=master --timeout=300s
# 3) upgrade with migrations enabled (the default) → pre-upgrade migrates run against the ready DBs
helm upgrade sdg charts/single-digital-gateway \
-n <slug>-dev -f charts/single-digital-gateway/values-dev.yamlhelm upgrade --install sdg charts/single-digital-gateway \
-n <slug>-test -f charts/single-digital-gateway/values-test.yamlSwap -dev / values-dev.yaml for -test / -prod and the matching env file. The Helm release
name (sdg above) is your choice — it prefixes the generated resource names (sdg-platform-api,
…). The Postgres cluster name (postgres.name: sdg, hence secret sdg-pguser-sdg) is separate
and set in the env files.
The per-container limits are tuned in the subchart defaults, so the env files only carry
per-environment identity — route subdomains, image.tag, OIDC issuer/redirect URLs, BFF_ORIGIN,
LOG_LEVEL, and backup policy (dev: no schedules; test: weekly full; prod: weekly full + daily
incremental). Before deploying, set the real image tags, your IdP issuer (<oidc-issuer-host>),
and the cluster apps domain in the route URLs (see below).
Each audience has a single external Route — its *-web nginx, which serves the SPA at /
and reverse-proxies /api/* to the co-located BFF Service. The API charts run with
route.enabled: false (no external Route); they are reached only in-cluster through the web nginx.
So the browser talks to one origin per audience:
staff: sdg-platform-<env>.apps… → platform-web nginx → / (SPA) + /api/* → platform-api
citizen: sdg-portal-<env>.apps… → citizen-portal-web → / (SPA) + /api/* → citizen-portal-api
Only the two *-web charts carry a route host, resolved in this order (route.*):
route.host— an explicit FQDN (external DNS + a cert). Use for production vanity domains.route.subdomain— just the prefix. OpenShift appends the cluster's ingress domain →<subdomain>.<apps-domain>, served by the router's default wildcard cert (no DNS/cert to manage). This is the env-file default (e.g.sdg-platform-dev).- Neither set — OpenShift auto-generates
<route-name>-<namespace>.<apps-domain>.
Find your cluster's apps domain:
oc get ingresses.config/cluster -o jsonpath='{.spec.domain}' # e.g. apps.gold.devops.gov.bc.caThe env files use apps.gold.devops.gov.bc.ca inside the app URLs — replace it if your cluster's
apps domain differs (oc get ingresses.config/cluster -o jsonpath='{.spec.domain}'), and keep it
in sync with each web route.subdomain. Notes:
- The web
env.API_UPSTREAM(<release>-platform-api:80) is the in-cluster BFF Service nginx proxies to;env.BFF_ORIGINis/api(same-origin, relative). Both are required — the web container fails fast without them. OIDC_REDIRECT_URInow carries/api(https://<web-host>/api/auth/callback, through nginx): register that exact URI in the Keycloak realm client, or the callback fails.- Because the SPA and BFF are now the same origin, the BFF's
SameSite=laxsession cookie and the CSRFOriginallowlist have no cross-site surface — no CORS needed. - Subdomains must be unique cluster-wide — dev/test/prod share one cluster, hence the
-dev/-test/-prodsuffix. A duplicate host is rejected (HostAlreadyClaimed). tls.termination: edge(the chart default) pairs with the wildcard cert; leave it as-is.
All seven are standard application charts and can be installed standalone (helm install <name> charts/<name> -n <ns> -f my-values.yaml) if you don't want the umbrella. Common app-chart values:
replicaCount, image.{repository,tag}, env (→ ConfigMap), extraEnv (raw env, supports
valueFrom; merged into the app and migrate containers), existingSecret, route.*,
resources, strategy, probes.*, autoscaling.*, and the OpenShift-safe podSecurityContext /
securityContext.
| Chart | Kind(s) | Notes |
|---|---|---|
| platform-api | Deployment, Service, ConfigMap, ServiceAccount, migrate Job | Staff BFF (NestJS), port 4000. migrations.enabled: true — owns migrations for the shared DB. route.enabled: false in the umbrella — reached via platform-web nginx at /api/* (feature 78). |
| citizen-portal-api | Deployment, Service, ConfigMap, ServiceAccount | Citizen BFF (NestJS), port 4000. migrations.enabled: false (platform-api migrates the shared DB). route.enabled: false in the umbrella — reached via citizen-portal-web nginx at /api/*. |
| platform-web | Deployment, Service, Route, ConfigMap, ServiceAccount | Staff SPA + front door (nginx, port 8080). Serves / and reverse-proxies /api/* → platform-api. Requires env.BFF_ORIGIN (/api) + env.API_UPSTREAM (<release>-platform-api:80). |
| citizen-portal-web | Deployment, Service, Route, ConfigMap, ServiceAccount | Citizen SPA + front door (nginx, port 8080). Serves / and proxies /api/* → citizen-portal-api. Requires env.BFF_ORIGIN + env.API_UPSTREAM. |
| notification-service | Deployment, Service, ConfigMap, ServiceAccount, migrate Job | Internal notifications m2m resource server (NestJS), port 4002. No Route template — browsers never reach it; the BFFs call the ClusterIP Service with client-credentials tokens. migrations.enabled: true — owns migrations for its own DB (sdg_notifications, operator secret sdg-pguser-notifications via postgres.extraUsers). |
| valkey | StatefulSet, Service (+ headless), ConfigMap, ServiceAccount, optional NetworkPolicy | Session store, single replica, persistent PVC. Password from auth.existingSecret. maxmemory capped below the pod limit (noeviction). |
| postgres | PostgresCluster (CRD) | Crunchy PGO cluster. Requires the operator. Exposes instances.*, backups.{schedules,retentionFull,storage,repoHost,sidecars,jobs}, optional pgbouncer/monitoring. Generates secret sdg-pguser-sdg (+ sdg-pguser-notifications via extraUsers). |
Two databases, two owners — each migrated exactly once per deploy:
- Shared app DB (
sdg) — migrated by platform-api's hook (imagedb-migrate). Keepcitizen-portal-api.migrations.enabled: falseunless it is deployed without platform-api. - Notifications DB (
sdg_notifications) — migrated by notification-service's own hook (imagenotification-db-migrate); no conflict, since Helm runs release hooks serially.
Each migrate Job gets its DB URL from the app's extraEnv (the operator-generated pguser secret)
and its own lean migrations.resources (requests are what the quota meters; the transient Job adds
only +25m / 64Mi to the request sum while it runs).
pgBackRest is always on (a repo is mandatory). Schedules are cron strings — empty disables that
schedule. retentionFull is how many full backups to keep. The backups.repoHost / backups.sidecars
/ backups.jobs resource blocks exist so every pgBackRest container carries explicit requests
(what the quota meters) and a deliberate limit — in particular the sidecar's 100m CPU limit, which
its 1-second liveness probe needs (see the resource model above). dev disables schedules; prod
keeps weekly-full + daily-incremental.
helm uninstall sdg -n <slug>-devPVCs (Postgres data, pgBackRest repo, Valkey data) and the operator-generated sdg-pguser-sdg
secret are not removed by helm uninstall — delete them explicitly if you want a clean slate:
oc -n <slug>-dev delete pvc -l app.kubernetes.io/instance=sdg
oc -n <slug>-dev delete postgrescluster sdg # operator then cleans up its PVCs/secrethelm dependency build charts/single-digital-gateway
helm lint charts/single-digital-gateway -f charts/single-digital-gateway/values-dev.yaml
helm template sdg charts/single-digital-gateway -f charts/single-digital-gateway/values-dev.yaml | lesscharts/single-digital-gateway/charts/ (the vendored subchart tarballs) is git-ignored — it is
rebuilt from the sibling charts by helm dependency build. Chart.lock is committed for
reproducibility.