Self-hosting Tablemere

This is the engineering note from the repository, published as written on 2026-09-19. Paths (stack/, deploy/, scripts/) are relative to the platform directory of the source tree, which is not yet published: the licence for the control plane and the CLI is an open decision, as the note says. The managed service at tablemere.com is this shape, run by us. Ask hello@tablemere.eu if you want to run it.

Written 2026-09-19 to answer "are we blocking ourselves for a self-hosted version?" The answer today: no, because the managed service is the self-hosted shape run by us. Nothing in the stack knows it is ours. Revised 2026-09-20: the identity provider left the stack; login is the control plane's own, and an external OpenID Connect issuer is the self-hosting option (below).

What a self-hoster runs

The same three containers the laptop and al-lake1 run, from stack/docker-compose.yaml plus the production override:

container image role
weed-initweed chrislusf/seaweedfs:4.47 (Apache-2.0) object store, Iceberg REST catalog, IAM/STS, vending, maintenance
control-plane built from control_plane/ (FastAPI, SQLite) tenants, recipes, quotas, organisations, signup, login (email + password, sessions, the CLI's device flow, our mails)
gateway caddy:2.11-alpine TLS on api., catalog., s3. and the website

State is one directory (/data), which is also the backup set (deploy/README.md). Identity is pluggable by configuration (AL_OIDC_ISSUERS, research/18): a company with Okta, Entra, Keycloak or Authentik names its issuer and gets SSO with it, beside or instead of the form login (docs/login.md). Mail for the signup, set-password and reset links is any SMTP sender (AL_SMTP_*). Placement is whatever they set (AL_PROVIDER, AL_REGION).

The path, as it exists

  1. A host with Docker and a data disk mounted at /data (deploy/host_setup.sh does it for a Hetzner Volume; any disk works).
  2. deploy/render_env.sh writes the server .env; AL_DOMAIN=lake.example.com gives real hostnames, three A records at their DNS (plus the website's, if the gateway serves it).
  3. deploy/push.sh (rsync, ./al up, ./al bootstrap, TLS health) — or, without our laptop scripts, cp .env.example .env, edit, ./al up && ./al bootstrap on the host itself.
  4. scripts/test.sh phase1 against their hostnames is the acceptance test they can run.

Bringing your own identity provider (AL_OIDC_ISSUERS)

The control plane trusts the issuers named in AL_OIDC_ISSUERS, a JSON list (single-quoted in .env, or compose strips the quotes) of

{"issuer": "<the iss claim>", "client_id": "<the client the CLI and the account page use>",
 "audience": "<optional aud to enforce>",
 "discovery_url": "<where the control plane fetches discovery, when the public issuer URL is not
                   reachable from inside the docker network>",
 "trust_email": <true for issuers that never set email_verified>}

A verified token from a listed issuer is accepted wherever ours is: the first appearance of an address creates the principal (or activates the pending organisation a signup form reserved for it), /v1/onboard names the organisation, and POST /v1/api-keys mints keys for the person's agents. GET /v1/auth/config lists the issuers, so the account page shows a "Log in with" button for each beside the form (or instead of it, with AL_PASSWORD_LOGIN=false), and tablemere login --issuer <iss> runs the OpenID Connect device flow against it instead of ours.

What the IdP side needs, whatever the product: a public client (no secret; PKCE S256), the grants authorization_code (the account page), urn:ietf:params:oauth:grant-type:device_code (the CLI) and refresh_token; redirect_uris with <site>/app/ and the loopback prefix http://127.0.0.1:* for the CLI; <site> as an allowed origin (the page calls the token endpoint with fetch); RS256 tokens that carry email and email_verified (or trust_email: true here).

Worked example, Rauthy 0.36 (what the managed service ran until 2026-09-20; the scripts that provisioned it, scripts/rauthy_setup.sh and deploy/render_rauthy_env.sh, are in git history before that date). Rauthy behind the gateway on https://auth.<domain>, PROXY_MODE=true, PUB_URL the public hostname; a client tablemere created over its admin API with the flows above and access_token_alg: RS256. The control plane's line, discovery fetched in-cluster because the public name does not resolve inside the compose network:

AL_OIDC_ISSUERS='[{"issuer":"https://auth.example.com/auth/v1","discovery_url":"http://rauthy:8080/auth/v1","client_id":"tablemere","audience":"tablemere"}]'

Two things learnt running it: Rauthy emits its issuer with a trailing slash in some places (the control plane matches modulo that slash), and probing paths under /docs/ on it blacklists the source IP for 24 hours. Its registration page and mails cannot be made yours (colours yes, structure and hostname no), which is why the managed service moved the login into the control plane.

What would block a self-hoster today, honestly

  • Our deploy scripts assume Hetzner for provisioning (provision.sh) and for the backup tier (HETZNER_S3_*, master.toml). The compose stack does not; the backup upload does (boto3 to any S3-compatible endpoint, so a one-line change).
  • The Hetzner Volume assumption in docker-compose.prod.yaml is just bind mounts under /data.
  • weed mini is single-node. HA (PLAN §8) is the multi-process SeaweedFS topology; when we do it for ourselves the self-hosted shape follows.
  • No packaging: no Helm chart, no single installer. Not needed until someone asks.
  • License: the control plane and CLI have no licence file yet; that is a decision for the human (the platform's value is the operated service, so a permissive licence on the code costs little).

Operating a single host: what the deploy scripts now guarantee (2026-09-20)

  • Every long-running container restarts after a reboot or an OOM kill (restart: unless-stopped); the control plane and the gateway run capability-less on a read-only rootfs (docker-compose.yaml).
  • The daily backup (deploy/on_host_backup.sh) checks free space before it stops anything, restarts weed from an EXIT trap whatever fails in between, prunes its own archives by count and size, and drops old fs.meta dumps. A restore (deploy/restore.sh) hardens the host, installs the backup, maintenance and self-update timers, and prints a checklist of what it did not do. An archive from before 2026-09-20 carries the retired identity provider's /data/rauthy; it is restored as inert data and nothing is started for it.
  • Logs our timers write under /data rotate weekly (deploy/on_host_logrotate.sh).
  • Destroying cloud resources keeps the data Volume unless AL_DELETE_VOLUMES=yes (scripts/cloud_nuke.sh); ./al nuke asks first.

Keep it that way

Rules that keep self-hosting cheap: no hard-coded hostnames (everything through .env), no cloud API in the data path, no dependency on a Hetzner-only service inside the containers, identity through standard OIDC only, and the acceptance suite runnable against any hostname.