Self-hosting LakehouseBox

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 lakehousebox.com is this shape, run by us. Ask hello@lakehousebox.com 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 four containers the laptop and al-lake1 run, from stack/docker-compose.yaml plus the production override:

container image role
weed-initweed our private mirror's build of SeaweedFS 4.47-tm.4 (Apache-2.0 upstream 4.47 plus seven patches, listed in platform/upstream/seaweedfs/PATCHES.md; pinned by digest in the compose file) 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), the OAuth 2.1 authorization server for the MCP server
mcp built from mcp/ (the official mcp Python SDK) the remote MCP server (Streamable HTTP at https://mcp.<domain>/mcp), a resource server that forwards the caller's token to the control plane
gateway caddy:2.11-alpine TLS on api., catalog., s3. + *.s3. (virtual-hosted buckets), mcp. 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: A records for api, catalog, s3, *.s3 and mcp at their DNS (plus the website's, if the gateway serves it; the wildcard wants a DNS-01 certificate, path-style s3. works without it). Size the store by bytes, not slots: weed mini's default caps the volume COUNT at data disk / AL_VOLUME_SIZE_LIMIT_MB (every bucket that has ever been written holds one volume, two per catalog — the default 256 MB gave 76 slots on a 20 GB disk and ran out on us), but the stack sets -volume.max (AL_VOLUME_MAX, default 2000), which lifts that cap; what binds is disk-free, so alert on it (the deploy's alerts warn at 80 % used and page at 90 %; deploy/README.md, "The volume-slot cap"). A Hetzner Volume grows online. Measurements: research/27 §2.1–2.2.
  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.

Storage settings (optional)

key what
AL_VOLUME_MAX volume-count cap of the store (default 2000; the disk binds instead)
AL_VOLUME_SIZE_LIMIT_MB volume size (default 64 MB; seals early so volumes can move to the cold tier)
AL_ENCRYPT_VOLUME_DATA chunk encryption at rest, default 1; the per-chunk keys live in the filer metadata, so the metadata backup is the keyring
HETZNER_S3_* the Object Storage bucket used as the backup tier and the default cold-tier backend s3.hetzner
AL_TIER_BACKENDS_SPEC extra cold-tier buckets rendered into master.toml as s3.<name> (name|bucket|endpoint|key|secret;…); one per ~500 catalogs
AL_TIER_BACKENDS which backends the tiering job may use (comma list of s3.<name>; a catalog hashes to one)
AL_TIER_QUIET_FOR, AL_TIER_FULL_PERCENT the tiering job's quiet period (default 168h) and fill threshold (default 50)
AL_TIER_MIRRORS src@endpoint>dst@endpoint;… pairs the nightly mirror syncs to another location

Every other variable the stack reads, with its default and its reader, is listed in the repository note platform/docs/configuration.md (2026-09-22; grep the code when in doubt).

Two timers are optional and installed disabled: the tiering job (deploy/schedule_tier.sh <host>, then systemctl enable --now agentlake-tier.timer once encryption at rest is on) and the mirror of the cold buckets (deploy/schedule_tier_sync.sh <host>). Without them every byte stays on the data disk. What the tier does and costs: /docs/architecture/ and research/27.

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 lhbox 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 lhbox 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":"lhbox","audience":"lhbox"}]'

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.