Operations

Kubernetes

Deploying routess with the bundled Helm chart.

The chart at charts/routess deploys all four apps (web, api, docs, landing) plus their services, ingresses, and security policies. Postgres is not managed by the chart; bring your own database.

For chart-level details, see charts/routess/README.md.

Prerequisites

  • A Kubernetes cluster and kubectl access
  • Helm 3.16+
  • A reachable PostgreSQL instance (the chart does not create one)
  • Secrets at hand: JWT secret, analytics salt, PAT pepper, Google OAuth credentials, database credentials

Install from GHCR

helm install routess oci://ghcr.io/robbeverhelst/charts/routess \
  --version <VERSION> \
  --namespace routess \
  --create-namespace \
  -f my-values.yaml

Find the latest chart version on the releases page; the chart version tracks the app version.

Pick an ingress strategy

The default ingress.className is cloudflare-tunnel, which is not a standard ingress class; it assumes a Cloudflare Tunnel controller is installed. On any other setup, override it (the nginx overlay below sets className: nginx).

The chart ships with two example overlays:

Cloudflare Tunnel

helm install routess oci://ghcr.io/robbeverhelst/charts/routess \
  -f charts/routess/values.example.cloudflare.yaml \
  --set ingress.web.hosts[0]=routess.example.com \
  --set ingress.api.hosts[0]=api.routess.example.com \
  --set ingress.docs.hosts[0]=docs.routess.example.com \
  --set networkPolicies.cloudflareNamespace=cloudflare \
  ...

ingress-nginx + cert-manager

helm install routess oci://ghcr.io/robbeverhelst/charts/routess \
  -f charts/routess/values.example.nginx.yaml \
  --set ingress.web.hosts[0]=routess.example.com \
  --set ingress.api.hosts[0]=api.routess.example.com \
  --set ingress.docs.hosts[0]=docs.routess.example.com \
  ...

Requires ingress-nginx, cert-manager, and a ClusterIssuer named letsencrypt-prod already present in the cluster.

Required secrets

Pass these from your secret manager (1Password Connect, External Secrets Operator, sealed-secrets, etc.):

--set api.secrets.jwtSecret=<random> \
--set api.secrets.analyticsSalt=<random> \
--set api.secrets.patPepper=<random> \
--set api.secrets.googleClientId=<from-google> \
--set api.secrets.googleClientSecret=<from-google> \
--set api.secrets.dbHost=<postgres-host> \
--set api.secrets.dbUser=<postgres-user> \
--set api.secrets.dbPassword=<postgres-password> \
--set networkPolicies.postgresEgress.host=<postgres-host>

The API refuses to start without JWT_SECRET, ANALYTICS_SALT, and PAT_PEPPER. See ADR-0022.

Two related values live under api.env, not api.secrets: api.env.dbName (default routess) and api.env.dbPort (default "5432"). Override them if your database differs. api.secrets.resendApiKey is optional; empty means verification emails are logged to stdout instead of sent.

Rotation impact: rotating jwtSecret logs every user out, rotating patPepper invalidates every personal access token, and rotating analyticsSalt resets analytics pseudonyms.

Values structure

Top-level keys:

  • global: namespace, shared Umami analytics config
  • web, api, docs, landing: per-app deployment config (replicas, resources, probes, security)
  • ingress: host configuration per service
  • networkPolicies: default-deny + app-specific allows (ADR-0006)
  • monitoring.serviceMonitor: Prometheus Operator integration
  • serviceAccount: minimal RBAC per service

See values.yaml for the full reference.

Probes and disruption budgets

The API exposes GET /health/live (process up) and GET /health/ready (database reachable); the chart wires them as liveness and readiness probes. Web, docs, and landing probe GET /.

PodDisruptionBudgets keep at least one replica of web, api, and landing during node drains (podDisruptionBudget.minAvailable: 1); docs runs a single replica with no PDB.

Scaling

Each app ships an optional HorizontalPodAutoscaler: set <app>.autoscaling.enabled=true (defaults: min 2, max 5, target 80% CPU; docs scales 1 to 3). Web, docs, and landing are stateless and scale freely.

For the API, remember each pod opens its own PostgreSQL connections; raising maxReplicas multiplies connection count. If you scale wide, put a pooler (PgBouncer, or your managed Postgres's built-in pooling) in front of the database.

Swagger in production

The chart sets api.env.swaggerEnabled: "true", so interactive API docs are served at /api on the API host. The application default in production is off; set the value to "false" if you don't want them public.

Render locally

helm template test ./charts/routess \
  --set api.secrets.jwtSecret=test \
  --set api.secrets.analyticsSalt=test \
  --set api.secrets.patPepper=test \
  --set api.secrets.googleClientId=test \
  --set api.secrets.googleClientSecret=test \
  --set api.secrets.dbHost=10.0.0.1 \
  --set api.secrets.dbUser=test \
  --set api.secrets.dbPassword=test

Upgrade

Snapshot the database first; migrations are forward-only and helm rollback does not undo schema changes. A pre-upgrade pg_dump is your rollback path.

helm upgrade routess oci://ghcr.io/robbeverhelst/charts/routess \
  --version <NEW_VERSION> \
  -n routess \
  -f my-values.yaml

The API runs MikroORM migrations on startup. Standard rolling-update mechanics apply: the first new API pod migrates the schema, so watch its logs before letting the rest roll.

On this page