Running an instance
What an operator runs: the compose file, its containers and volumes, first start, HTTPS, updates, backups.
Each instance is a checkout of the monorepo on the operator’s machine, run with its docker-compose.yml.
There is no hosted version, no shared environment and no published image: Docker builds the server from the
checkout, and developers run the same stack on their laptops. The operator’s steps are in
Getting started; this page is how it works.
The stack
| Service | Image | What it does |
|---|---|---|
secrets |
waterflow:local, built from the Dockerfile |
First start only: writes the built-in database’s password into the secrets volume, then exits |
db |
postgres:17 |
The built-in database. Reads its password from the file. Not published on any port |
waterflow |
waterflow:local |
app/launch.py: the API, the web app’s files and the daily jobs. Published on BIND:PORT (127.0.0.1:8080) |
caddy |
caddy:2 |
Profile https: HTTPS for DOMAIN, certificates automatic |
updater |
docker:29-cli with git, built inline |
Profile updater: one-click updates from the web app (see below) |
Volumes: secrets (database passwords), db, backups (nightly dumps and PURGE_LOG), updates (the
updater’s request and status files), caddy. Every setting is optional, with a safe default; the operator’s are in
.env.example and Settings, and all of them in Environment variables.
First start
secretsmakes the database password;dbstarts with it.app/launch.pymakes one password per login (api_login,purge_login,fx_login), kept in thesecretsvolume, waits for the database, then runs backend/app/jobs/migrate.py: every migration not applied yet (recorded inwaterflow.migrations), the logins, and, while there’s no admin, a new setup code printed in the log.- It starts uvicorn with only
api_loginin its environment, and the scheduler with the jobs’ logins and the owner’s (for the backup). - The operator opens the address, enters the setup code, and creates the admin. See Sign-in and accounts.
Own database
ADMIN_DATABASE_URL (the owner of any Postgres 15+, or a Supabase project through its session pooler) replaces
the built-in one; start with --scale db=0. The server creates its logins there with the same host, port, database
and options, and the owner’s suffix after a dot (Supabase’s pooler: postgres.PROJECTREF gives
api_login.PROJECTREF). On Supabase the baseline takes away everything Supabase grants its browser roles.
HTTPS and reaching it
The server listens on 127.0.0.1 until the operator chooses: Caddy with their domain (profile https),
Tailscale (tailscale serve --bg 8080, a private HTTPS address, no open ports), Cloudflare Tunnel, or BIND=0.0.0.0
on a trusted home network over plain HTTP. The mobile app needs HTTPS. uvicorn trusts forwarded headers from any
proxy, so the client address and X-Forwarded-Host work behind each of these.
Updates
git pull && docker compose up -d --build. On start, the launcher sees migrations not applied yet, takes a backup, then applies them.- The admin’s page shows the running version (the
VERSIONfile the image was built from) and the newest release from the project’s GitHub releases (backend/app/core/updates.py, checked at most every 6 hours, only when the admin opens the page;UPDATE_CHECK=offstops it). - One-click (opt-in): with
COMPOSE_PROFILES=updater, theupdaterhelper runs. It mounts the Docker socket and the checkout (writable) and touches/updates/updater_aliveevery 5 seconds. Update now makes the API write/updates/requested; the helper runsgit pull --ff-onlyas the checkout’s owner (so the files stay the operator’s), thendocker compose up -d --build --no-depsfor the services already running except itself, and writesrunning,doneorfailedwith the time to/updates/status. The tab that started the update polls it every 5 seconds and shows a notice on every page until it readsdoneorfailedfrom after the start. The API reports that result only if it’s from after the server started (SERVER_STARTED_AT, set bylaunch.pybefore the backup and migrations, with a minute’s grace), since theupdatesvolume outlives the containers: a failure fixed by updating by hand doesn’t stay on the admin’s page. It skips itself because compose inside the helper would mount the helper’s/project, not the folder on the host; it skips stopped services so a database left off with--scale db=0stays off. Only the helper can reach Docker and the checkout; the API can only leave the request file. Off by default because the Docker socket is root-level access to the machine. - A server whose checkout is on a release tag (pinned) stays there:
git pulland the helper refuse, and the helper says so. - Within
/v1the API only adds, so phones keep working with older and newer servers.
Backups
Nightly and before every update, into backups, about 6 days kept, owner-only files. From the web app the admin
uploads one to restore: the API leaves it in backups/restore/ and stops the server, and app/jobs/restore.py
restores it as the server starts, before the migrations. By hand: the docker
commands in Backups and restore (tested by hand) and the $ lines in backend/app/jobs/backup.py
(run by the tests), then python -m app.admin repurge. See Background jobs.
Developers
The same compose file (docker compose up -d --build from a checkout), or backend/scripts/dev.sh for the API
with reload against the compose database (published on 127.0.0.1:5433 by docker-compose.dev.yml) and
npm run dev in web/, which proxies /v1. Tests use a throwaway Postgres. See
Run it locally.