Skip to content

Kubernetes

GuideMode runs on Kubernetes as one image in two roles: replicas that serve traffic, and replicas that also run background work. There is no leader election, no sidecar and no second image.

To get the image and the reference manifests, contact info@guidemode.dev. What follows is what they contain, and why each piece is the way it is.

Every setting is an environment variable, so one Secret holds the deployment’s entire configuration. See the configuration reference.

apiVersion: v1
kind: Secret
metadata:
name: guidemode
stringData:
APP_URL: https://guidemode.example.com
OAUTH_ENCRYPTION_KEY: <a 32-byte secret>
DATABASE_URL: postgresql://guidemode:...@postgres:5432/guidemode
S3_BUCKET: guidemode-sessions
S3_ENDPOINT: https://s3.eu-west-1.amazonaws.com
S3_ACCESS_KEY_ID: <key>
S3_SECRET_ACCESS_KEY: <secret>

Run the migrate command to completion before rolling out a new version. It is the same image with a different command.

apiVersion: batch/v1
kind: Job
metadata:
name: guidemode-migrate
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: guidemode/server:<version>
command: ['node', 'dist/node/migrate.js']
envFrom:
- secretRef:
name: guidemode

The migrator takes a single direct connection and finishes in seconds. If you deploy with Helm or Argo, run this as a pre-upgrade hook or a sync wave ahead of the Deployment.

A server pod that starts against a database still missing a migration refuses to bind its port and names what it expected. A database that is ahead of the image is accepted, since that is exactly what a rolling deploy and a rollback both look like from a pod’s side.

apiVersion: apps/v1
kind: Deployment
metadata:
name: guidemode-web
spec:
replicas: 3
template:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: server
image: guidemode/server:<version>
ports:
- containerPort: 3000
env:
- name: JOBS_WORKER_ENABLED
value: 'false'
envFrom:
- secretRef:
name: guidemode
readinessProbe:
httpGet:
path: /health/ready
port: 3000
livenessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 30

JOBS_WORKER_ENABLED: 'false' makes a pod web-only. It still enqueues work, it simply never claims any. That is the whole difference between the two roles.

The same manifest with JOBS_WORKER_ENABLED left at its default of true and a smaller replica count. Workers claim queued jobs, run syncs and AI analysis, and fire the scheduler.

You do not have to split them. A single Deployment with workers enabled is a valid deployment and is what the Compose stack is. Splitting is worth it when long sync runs would otherwise compete with request latency.

GuideMode’s scheduler does not elect anybody. Every worker replica tries to claim each time window, a unique key in the database lets exactly one through, and the rest carry on. Nothing is wrong when the pod holding the schedule is the pod being drained, and a skewed clock costs one insert that loses and nothing else.

One container port serves the API, the UI and the websocket upgrade. Forward websocket upgrades in your Ingress and you are done.

No sticky sessions are required. Live progress is fanned out through Postgres, so a client connected to one replica receives events raised by another. An in-process broadcast would silently deliver to half your clients at two replicas; this does not.

The one setting worth checking twice is the proxy’s read timeout. GuideMode pings an idle connection every 30 seconds, so a timeout below that cuts the socket, the client reconnects with backoff, and the user sees progress that stalls and jumps. Give it an hour:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: guidemode
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: '3600'
nginx.ingress.kubernetes.io/proxy-send-timeout: '3600'
nginx.ingress.kubernetes.io/proxy-body-size: '64m'
spec:
ingressClassName: nginx
tls:
- hosts: [guidemode.example.com]
secretName: guidemode-tls
rules:
- host: guidemode.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: guidemode
port:
name: http

The equivalent knob elsewhere: respondingTimeouts.readTimeout on a Traefik entrypoint, timeout-client and timeout-server on HAProxy Ingress, idle_timeout.timeout_seconds on an AWS ALB, timeoutSec on a GCE BackendConfig, streamIdleTimeout on an Envoy Gateway route.

Nothing below the ingress compresses anything. The application serves the bytes on disk, and the main JavaScript bundle is 624 KB uncompressed against 196 KB gzipped — so an ingress without compression triples every first visit.

ingress-nginx compresses by default when the controller’s enable-gzip is on, which it is in most distributions but is worth confirming rather than assuming. Elsewhere it is a controller-level setting: a Traefik compress middleware, an Envoy compressor filter, or the load balancer’s own.

The cache headers, by contrast, need nothing. The application sets them, and they are correct: content-hashed assets are immutable for a year, and index.html is never cached, which is what makes a deploy visible immediately.

Each pod opens a small pool, plus one direct connection when it listens for notifications. Size your database against:

max_connections >= pods x 6 + 1

The pool is five connections per pod, the extra one is the listener, and the final one is for a migration, which is direct, single-connection and lasts seconds. If you put PgBouncer in front, set DATABASE_URL_DIRECT to a connection that bypasses it: the notification listener needs a session of its own, and a transaction pooler will hand it to somebody else between messages.

On SIGTERM a pod stops claiming new work first, then finishes what it holds within a grace period and hands its leases back. Give terminationGracePeriodSeconds a few seconds more than JOBS_SHUTDOWN_GRACE_MS, which defaults to 20 seconds; 30 is what the manifests ship.

A grace period sized instead to the longest sync step would make every rollout sixteen minutes and would eventually collide with a cluster drain timeout. Handing the lease back is what makes the short one safe: another pod picks the work up in seconds rather than waiting out a visibility timeout. Work that was in flight when a pod is killed outright is reclaimed by another pod once its lease lapses.

No volumes are needed on the pods. Session transcripts go to the object store and everything else to Postgres. The container filesystem holds nothing you want to keep, which is what makes a replica disposable.