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.
Settings as a Secret
Section titled “Settings as a Secret”Every setting is an environment variable, so one Secret holds the deployment’s entire configuration. See the configuration reference.
apiVersion: v1kind: Secretmetadata: name: guidemodestringData: 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>Migrations as a Job
Section titled “Migrations as a Job”Run the migrate command to completion before rolling out a new version. It is the same image with a different command.
apiVersion: batch/v1kind: Jobmetadata: name: guidemode-migratespec: backoffLimit: 2 template: spec: restartPolicy: Never containers: - name: migrate image: guidemode/server:<version> command: ['node', 'dist/node/migrate.js'] envFrom: - secretRef: name: guidemodeThe 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.
The web Deployment
Section titled “The web Deployment”apiVersion: apps/v1kind: Deploymentmetadata: name: guidemode-webspec: 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: 30JOBS_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 worker Deployment
Section titled “The worker Deployment”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.
Scheduled work needs no leader
Section titled “Scheduled work needs no leader”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.
Websockets and the single port
Section titled “Websockets and the single port”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/v1kind: Ingressmetadata: 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: httpThe 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.
Compression is the ingress’s job
Section titled “Compression is the ingress’s job”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.
Connection budget
Section titled “Connection budget”Each pod opens a small pool, plus one direct connection when it listens for notifications. Size your database against:
max_connections >= pods x 6 + 1The 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.
Draining
Section titled “Draining”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.
Storage
Section titled “Storage”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.
