Skip to content

Docker

A complete GuideMode instance on one host: Postgres, an S3-compatible object store, a migration job, and three server instances behind a load balancer. Suitable for an evaluation or a small internal deployment.

To get the image and the compose file, contact info@guidemode.dev.

One instance would be simpler, and it is the wrong default. Three of GuideMode’s properties only exist above one replica, and every one of them fails quietly rather than loudly:

  • Live progress needs no sticky sessions, because it is fanned out through Postgres. A client connected to one instance receives events raised by another.
  • Scheduled work needs no leader. Every instance tries to claim each time window and a unique key lets exactly one through.
  • No job runs twice, because a claim is a single SELECT ... FOR UPDATE SKIP LOCKED.

A stack that cannot exhibit those is a stack in which they can quietly stop being true. So the default runs three, which means an evaluation, a small production instance and a Kubernetes install are all the same shape — and the day you scale up is not the day you find out.

Set SERVER_REPLICAS=1 if you want one. It works, and you give up the above.

The stack runs either way, and each half is decided by one variable. Set it and the service it replaces is not started at all.

Everything bundled Bring your own
Postgres Started for you Yours, via DATABASE_URL
Object store MinIO, started for you Yours, via S3_ENDPOINT
Best for Evaluations, a single internal host An existing managed database, or a bucket you already run

Six services, one host, nothing else to provision.

Service What it is
postgres Postgres 17. Stock image, no extensions to install
minio The object store for session transcripts
minio-init Creates the bucket once, then exits. MinIO does not create one on first write
migrate The server image with a different command. Runs to completion before the server starts
server The API, the UI, the job worker and the scheduler. Three of them
nginx The load balancer, and the only service with a published port

Put your settings in a .env file beside the compose file.

Terminal window
# Required. Every link in every email is built from this.
APP_URL=https://guidemode.example.com
# Required. 32 bytes. Encrypts stored OAuth tokens at rest.
OAUTH_ENCRYPTION_KEY=<a 32-byte secret>
# Recommended: change both from their defaults.
POSTGRES_PASSWORD=<a strong password>
S3_SECRET_ACCESS_KEY=<a strong secret>

You do not write a connection string in this model. The compose file builds one from POSTGRES_PASSWORD and gives the same value to the database, the migration job and the server, so one password cannot drift into three copies. S3_SECRET_ACCESS_KEY works the same way, serving as both the object store’s root credential and the server’s key.

For the database, set DATABASE_URL and that is the whole change. The bundled postgres service removes itself from the project, nothing waits on it, and both the migration job and the server use your connection string. There is nothing to edit and nothing to comment out. POSTGRES_PASSWORD no longer configures anything and can be removed.

Terminal window
APP_URL=https://guidemode.example.com
OAUTH_ENCRYPTION_KEY=<a 32-byte secret>
# The bundled Postgres is not started when this is set.
DATABASE_URL=postgresql://guidemode:<password>@db.example.com:5432/guidemode

Any Postgres 17 will do, managed or otherwise. If a transaction pooler such as PgBouncer sits in front of it, also set DATABASE_URL_DIRECT to a connection that bypasses it: live progress uses a Postgres notification listener, which needs a session of its own.

For the object store, set S3_ENDPOINT and the bundled MinIO goes the same way, along with the init job that creates its bucket. Fill in the rest of the group beside it. AWS S3, Cloudflare R2’s S3 endpoint and any S3-compatible store all work.

Terminal window
S3_BUCKET=guidemode-sessions
S3_ENDPOINT=https://s3.eu-west-1.amazonaws.com
S3_ACCESS_KEY_ID=<key>
S3_SECRET_ACCESS_KEY=<secret>
S3_REGION=eu-west-1

Your bucket is yours to create. The init job only exists because MinIO does not make one on first write.

Addressing style is derived from the endpoint, which is right for AWS and for MinIO. Set S3_ADDRESSING to path or virtual-host only if you front the store with a gateway that needs the other one. Getting it wrong returns a bucket-not-found that reads like a permissions problem, so it is worth stating when you are unsure.

The two halves are independent. Using your own database with the bundled MinIO, or the reverse, is a normal configuration.

Add credentials for the integrations you use to the same .env file, in either model. Every one is optional, and an integration whose settings are absent is switched off rather than broken. The whole file is handed to the server, so anything in the configuration reference can go in it.

The exceptions are the handful the compose file sets itself — APP_URL, OAUTH_ENCRYPTION_KEY, DATABASE_URL and the S3_* group. Those win over the file, which is deliberate: it means a stray DATABASE_URL cannot point the stack at some other database by accident.

Terminal window
docker compose up -d

Requires Docker Compose 2.24 or newer for the optional .env file on the server service.

The order is enforced by the compose file itself. Whatever is bundled comes up first, the migrations run to completion, and only then do the server instances start. A first start against an empty database takes a minute or two while the schema is built.

Two ports are published: the load balancer on PORT, which defaults to 3000, and the MinIO console on MINIO_CONSOLE_PORT, which defaults to 9001. The server instances and the database publish nothing.

Check it is up:

Terminal window
curl http://localhost:3000/health/ready
{
"status": "ready",
"schema": { "applied": 221, "bundled": 221, "ahead": false },
"jobs": { "workerEnabled": true }
}

And what it says it is:

Terminal window
curl http://localhost:3000/api/version
{
"product": "guidemode",
"version": "0.1.52",
"minimumClientVersion": "0.2.0",
"runtime": "node"
}

A server reporting "version": "dev" was started without RELEASE_VERSION. It runs perfectly well, but it cannot tell anybody’s CLI whether the two of them still agree, so set it on the service or bake it into the image:

Terminal window
docker build -f apps/server/Dockerfile --build-arg RELEASE_VERSION=0.1.52 -t guidemode/server:0.1.52 .
Terminal window
docker compose ps

Three server containers, one nginx, and the two one-shot jobs sitting exited. Requests are spread across the instances with no affinity, which the load balancer will tell you:

Terminal window
curl -sI http://localhost:3000/health | grep -i x-upstream

Run it a few times and the address changes. A browser reloading the app is doing the same thing, and stays signed in throughout, because sessions live in Postgres rather than in an instance.

Migrations are a command, so an upgrade is two steps in a fixed order.

Terminal window
# 1. Pull the new image
docker compose pull
# 2. Migrate, and wait for it to finish
docker compose run --rm migrate
# 3. Restart the instances on the new image
docker compose up -d server

The instances are replaced together. A rolling upgrade with no dropped requests is what Kubernetes is for — see Kubernetes.

If you skip step two, the new server refuses to start and tells you which migration it expected. That refusal is the safety feature, not an obstacle.

The bundled nginx listens on one port and serves the API, the UI and the websocket upgrade on it. Point your own reverse proxy or TLS terminator at that single port. There is nothing to route separately, and no sticky sessions are needed: live progress is delivered through Postgres, so a client connected to one instance still receives events raised by another.

If you replace the bundled nginx with your own proxy, three of its settings are load bearing and are the same three an Ingress needs:

  • Forward the websocket upgrade. Without it the handshake is answered as ordinary HTTP and live progress silently never moves.
  • Set a read timeout above 30 seconds. That is the application’s ping interval; a shorter timeout cuts the socket, the client reconnects with backoff, and the user watches progress stall and jump.
  • Compress text responses. Nothing else does. The main JavaScript bundle is 624 KB uncompressed and 196 KB gzipped, so a proxy without compression triples every first visit.

APP_URL must be the public HTTPS address, not the container address. It is what sign-in redirects, invitation emails and webhook registrations are built from.

Back up two things:

  • The Postgres database. Everything except session transcripts lives here
  • The object store bucket. Session transcripts, which cannot be reconstructed from the database

A dump of the database alone will restore a working instance with its history, metrics and settings intact; the transcripts of individual AI sessions will be missing.

The server exits at startup naming a setting. Configuration is validated before the port is bound, and every problem is listed at once. Fix them together and start again.

The server exits saying the database is behind. Run the migrate service, then start it.

Uploading a session fails, or a transcript will not open. The object store is not reachable or the bucket does not exist. Check the configuration report at GET /api/config-report as a global administrator; it says whether an object store is configured without revealing the credentials.

A bucket error that reads as a permissions problem. MinIO addresses buckets by path and AWS by hostname. The wrong choice returns a bucket-not-found that looks like an IAM problem. The compose file states S3_ADDRESSING explicitly for this reason; keep it.