Skip to content

Self-hosting

A real deployment is one container, one Postgres, and a reverse proxy. Nothing else is required.


Terminal window
docker build -f website/Dockerfile -t peekseek .
docker run --env-file website/.env -p 8000:8000 peekseek

The image builds docs/ with Node in a throwaway stage and copies only the result in, so the running container has no Node in it. The app serves that build at /docs, and mounts nothing if it is absent, which is why a local uvicorn with no docs build still starts.

The build runs minify.py over public/assets after copying it in, so the image serves compressed CSS and JS while the repository keeps readable source. The compressors are uninstalled in the same layer and never reach the running container.


The container listens on 8000. Point your proxy at it and terminate TLS there.


Point DB at Postgres. The schema builds itself on startup: create_all adds missing tables, then add_missing_columns adds columns the models have gained, printing each ALTER as it runs.

There is no migration tool and no migration step. Dropping, renaming or retyping a column is not covered and has to be done by hand. See Architecture for what that trade buys.

One consequence worth knowing before you wire anything up to read this database: a new table, or a column add_missing_columns fills in, only exists once the app has started with the model that declares it. So a schema change reaches anything querying the database directly - a dashboard, a reporting tool - one deploy later, never sooner. That is the only way a new table ever gets there.

Back the database up yourself. Nothing in the application does it for you.


Two bootstraps exist because a brand-new database cannot be used without them, and neither should stay set.

Terminal window
# Grants admin to one account, creating it if it has never logged in.
BOOTSTRAP_ADMIN_STEAM64=76561198000000000
# Allow-lists game servers: address=region,address=region
BOOTSTRAP_SERVERS=203.0.113.10=paris,203.0.113.11=utah

Every /server/v1/* route returns 401 to an address not in AuthorizedServers, and nothing in the site adds one, so without BOOTSTRAP_SERVERS no game server can ever talk to your instance. Regions come from regions in config.yaml. Addresses already present are left alone, so changing a region here does not overwrite the stored one.

Unset both once they have done their job.


  1. Put the compiled plugin in the server’s Rocket/Plugins/<name>/.
  2. Point its configuration at your site’s base URL.
  3. Add the server’s public IP to BOOTSTRAP_SERVERS, with a region that exists in [regions.names].
  4. Restart the site once so the allow-list entry is created.

The server then polls GET /server/v1/session and the match lifecycle takes over.


One image serves all of them. Three environment variables override the app block in config.yaml:

Variable Overrides
DOMAIN app.domain, the public hostname with no scheme
PROTOCOL [app].protocol
BUILD [app].build, the label the site shows

A staging environment should also leave BOT_TOKEN, LOG_WEBHOOK and QUEUE_LOG_CHANNEL unset. Every one of those callers no-ops on an empty value, so staging can never DM real players or post into production Discord channels. That is the mechanism, not a convention, and it is worth preserving.


Dokploy, with Traefik in front, on a single host, with production and staging as two separate applications off the same branch. Nothing in the application depends on any of that, and any Docker host will do.

Two lessons from running it that are cheaper to read than to rediscover:

  • Never let two applications claim the same hostname. Traefik will hold two routers with the same rule, pick one deterministically, and a redeploy can flip which one wins. A healthy container and a 502 at the same time usually means this.
  • A valid certificate proves nothing about your backend. Traefik answers the ACME challenge itself and never touches the container, so a fresh certificate alongside a 502 is exactly the signature of a live router pointed at a dead service.