Kubernetes (Helm)
This guide describes how to run the Grant Platform on Kubernetes using the grant-platform Helm chart in the repository (charts/grant-platform/). It replaces the Docker Compose gateway nginx pattern with Ingress, Services, and your cluster’s ingress controller—the usual approach for HTTP routing and TLS on Kubernetes.
The same canonical environment contract applies as for Docker: @grantjs/env variable names, with APP_URL and related URLs derived from a single chart value (global.appUrl). See Environment setup for how config maps to containers.
What you deploy
The chart deploys:
| Workload | Role |
|---|---|
| API | Grant API (REST + GraphQL); ClusterIP Service, configurable replicas |
| Web | Next.js app; catch-all / behind Ingress |
| Docs | VitePress static site; served under /docs (separate Ingress with path rewrite); configurable replicas (docs.replicaCount) |
| Example (optional) | Next.js example app under /example |
| Migrations | bootstrapDatabase() runs in the API before HTTP listen (PostgreSQL advisory lock; safe with multiple replicas) |
PostgreSQL and Redis are not bundled. Install them separately (managed cloud services, operators, or another Helm release) and point the chart at them.
Prerequisites
- Kubernetes 1.25+
- An Ingress controller — the chart defaults to Traefik-style routing (
ingress.docs.mode: traefik): aMiddlewarestrips the/docsprefix on a second Ingress. ingress-nginx is supported viaingress.docs.mode: nginx(regex + rewrite). Seecharts/grant-platform/README.mdfor controller-specific TLS and timeout examples. - PostgreSQL and Redis reachable from the cluster (DNS names or external endpoints).
- Helm 3
Install from the repository
From a clone of the Grant repo at the monorepo root:
helm upgrade --install grant ./charts/grant-platform \
--namespace grant \
--create-namespace \
--set global.appUrl=https://grant.example.com \
--set externalDatabase.url=postgresql://user:pass@postgres:5432/grant_db \
--set externalDatabase.host=postgres \
--set redis.host=redis \
--set redis.password=your-redis-passwordAdjust externalDatabase.* and redis.* to match your Services (e.g. my-postgres.default.svc.cluster.local). The chart sets DB_URL, REDIS_HOST, REDIS_PASSWORD, and APP_URL (and derived OAuth URLs) via a ConfigMap and Secret. Full defaults and options are in charts/grant-platform/values.yaml; the chart README in-repo lists image tags, API probes / graceful shutdown, ServiceMonitor, and optional persistence.
Replica counts are set per workload (api.replicaCount, web.replicaCount, docs.replicaCount, example.replicaCount). If you use a private registry (mirrored images), create a pull Secret in the release namespace and set imagePullSecrets in values so API, web, docs, and example can pull images.
Canonical APP_URL
Set global.appUrl to the HTTPS URL users use in the browser (no trailing path), for example https://grant.example.com. The chart maps this to:
APP_URL,SECURITY_FRONTEND_URL,OPENAPI_PRODUCTION_URLDOCS_URLas{APP_URL}/docsGITHUB_CALLBACK_URL/GITHUB_PROJECT_CALLBACK_URLunder/api/auth/...
This matches the single-host model documented in Environment setup and the path routing used in deploy/gateway.conf.template.
Ingress and path routing
- Main Ingress — Routes
/graphql,/api,/api-docs,/.well-known,/org,/acc,/health,/storageto the API Service;/exampleto the example app;/to the web Service. - Docs Ingress —
ingress.docs.mode: traefik(default): TraefikMiddlewarewithstripPrefix/docsplus a prefix path/docs.nginx: regex path andnginx.ingress.kubernetes.io/rewrite-targetso/docs/...is served under the same host as the rest of the app.
Configure proxy body size and timeouts for your controller (e.g. nginx annotations in ingress.annotations, or Traefik Middleware / annotations) via ingress.annotations and ingress.extraAnnotations.
Closed install: origin verify and public signup
Same two controls as Docker (Configuration). Set AUTH_PUBLIC_SIGNUP_ENABLED=false on the API ConfigMap (config.env or files/api-configmap-env-defaults.yaml). Put ORIGIN_VERIFY_SECRET in the runtime Secret (api.existingSecretEnv / External Secrets) — it is already excluded from the ConfigMap. SECURITY_API_KEY is unused and is not this gate.
The API Service is ClusterIP by default. Two valid fronts (never a CloudFront Function; never NEXT_PUBLIC_*):
Preferred: Ingress to web, Next injects the header
- Leave the API Service ClusterIP. Ingress
/to web as today; you can drop the/api,/graphql,/health,/storage,/.well-known,/org,/accIngress paths so the API is not on the public Ingress at all. - Give the web Deployment the secret at server runtime (
web.extraEnvinvalues.yaml):
web:
extraEnv:
- name: ORIGIN_VERIFY_SECRET
valueFrom:
secretKeyRef:
name: grant-runtime # or api.existingSecretEnv
key: ORIGIN_VERIFY_SECRETNext proxy.ts attaches x-origin-verify on proxied paths. Set SECURITY_ORIGIN_VERIFY_REQUIRED=true on the API.
Split Ingress: Ingress attaches the header
Ingress still routes /api and /graphql to the API Service and adds the request header. Then Next does not need the secret. OAuth callback URLs still use global.appUrl.
ingress-nginx (proxy_set_header via a headers ConfigMap referenced from the Ingress — do not put the secret in a committed annotation if you can avoid it):
apiVersion: v1
kind: ConfigMap
metadata:
name: grant-origin-verify-headers
data:
x-origin-verify: '<from a Secret, not git>'
---
# on the main Ingress:
ingress:
className: nginx
annotations:
nginx.ingress.kubernetes.io/proxy-set-headers: grant/grant-origin-verify-headersTraefik (custom request headers Middleware):
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: grant-origin-verify
spec:
headers:
customRequestHeaders:
x-origin-verify: '<from a Secret, not git>'Point the API paths at that middleware. Gateway API uses an HTTPRoute header modifier the same way.
Direct kubectl port-forward / CLI against the API Service must send x-origin-verify. Against the Ingress host (web origin) it must not — the front attaches it. See CLI.
TLS (cert-manager)
For automatic TLS, add annotations such as:
ingress:
extraAnnotations:
cert-manager.io/cluster-issuer: letsencrypt-prodand set ingress.tls to the Secret name your issuer creates. The exact annotation depends on your cert-manager and ClusterIssuer names.
Production secrets
Prefer api.existingSecretEnv: a Kubernetes Secret in the release namespace whose keys use canonical env names (DB_URL, REDIS_PASSWORD, AUTH_MFA_SECRET_ENCRYPTION_KEY, …). When set, the chart does not generate the default *-runtime Secret. Alternatively, enable externalSecret (External Secrets Operator) to populate the same secret name when api.existingSecretEnv is unset—see values.yaml.
AUTH_MFA_SECRET_ENCRYPTION_KEY must be identical across all API replicas; never use a random per-pod value. The same holds for PROJECT_OAUTH_CONNECTION_ENCRYPTION_KEY and API_KEY_EXPORTABLE_SECRET_ENCRYPTION_KEY: a replica with a different value cannot read the secrets another replica encrypted.
Storage and replicas
If STORAGE_PROVIDER=local with multiple API replicas, use ReadWriteMany storage or S3 (STORAGE_PROVIDER=s3). The chart documents volume mounts for local storage in values.yaml.
Related
- Deployment overview
- Docker deployment — Compose, images, and demo stack (same container images)
- Environment setup
- Configuration