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.
Why three instances
Section titled “Why three instances”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.
Two models
Section titled “Two models”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 |
Model one: everything bundled
Section titled “Model one: everything bundled”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.
# 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.
Model two: bring your own
Section titled “Model two: bring your own”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.
APP_URL=https://guidemode.example.comOAUTH_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/guidemodeAny 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.
S3_BUCKET=guidemode-sessionsS3_ENDPOINT=https://s3.eu-west-1.amazonaws.comS3_ACCESS_KEY_ID=<key>S3_SECRET_ACCESS_KEY=<secret>S3_REGION=eu-west-1Your 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.
Integrations
Section titled “Integrations”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.
Start it
Section titled “Start it”docker compose up -dRequires 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:
curl http://localhost:3000/health/ready{ "status": "ready", "schema": { "applied": 221, "bundled": 221, "ahead": false }, "jobs": { "workerEnabled": true }}And what it says it is:
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:
docker build -f apps/server/Dockerfile --build-arg RELEASE_VERSION=0.1.52 -t guidemode/server:0.1.52 .Seeing the instances
Section titled “Seeing the instances”docker compose psThree 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:
curl -sI http://localhost:3000/health | grep -i x-upstreamRun 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.
Upgrading
Section titled “Upgrading”Migrations are a command, so an upgrade is two steps in a fixed order.
# 1. Pull the new imagedocker compose pull
# 2. Migrate, and wait for it to finishdocker compose run --rm migrate
# 3. Restart the instances on the new imagedocker compose up -d serverThe 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.
Putting it behind TLS
Section titled “Putting it behind TLS”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.
Backups
Section titled “Backups”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.
Troubleshooting
Section titled “Troubleshooting”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.
