Self-hosting over Tailscale
Run the Loams 0.1.0 stack with Docker or Podman Compose on your laptop or your own VM, reachable only over your Tailscale tailnet, and use it from console.loams.dev.
Loams 0.1.0 ships one self-hosting path: you run the stack, on your own Tailscale tailnet, and use the console we host at console.loams.dev. The engine never has a public address. Your browser reaches it through the Tailscale client on your own device, and nothing in the stack binds a host port on 0.0.0.0.
| Piece | Who runs it | Where |
|---|---|---|
The engine stack: loams, TiDB on TiKV and PD, the House, the Postgres stack | You | Docker or Podman Compose on your laptop, or on your own VM with byoc/install.sh over SSH |
| The private network between your devices and the stack | You | Your own Tailscale tailnet |
| The console (static files, no data, no secrets) | Ostrium | https://console.loams.dev |
The auth service, only for oidc mode | Ostrium | https://auth.loams.dev |
The stack's tailscale service is a sidecar in the engine's network namespace. It joins your tailnet as the node loams and serves https://loams.<tailnet>.ts.net with tailscale serve, proxying to the engine's dedicated Tailscale listener (127.0.0.1:7781). Funnel stays off. With --profile house, a second sidecar joins as loams-house and serves Loams House's ClickHouse HTTP interface.
What you need
- Docker with the Compose plugin, v2.24.0 or later (for
includeand the optionalenv_fileentries the compose file uses), or Podman with a compose provider; and several GB of free memory. On macOS and Windows, Docker Desktop or Podman Desktop. The patched TiDB image is amd64 only and runs emulated on Apple silicon. - A Tailscale account. The tailnet is yours; Ostrium is not in it.
- The Tailscale client on the device whose browser you will use.
Podman
The whole stack runs under podman compose. Every docker compose … command on this page has
a podman compose … equivalent with the same arguments. Point the compose provider at your
user's Podman socket first, and turn the socket on if it is off:
systemctl --user enable --now podman.socket
export DOCKER_HOST=unix:///run/user/$UID/podman/podman.sock
podman compose up -d1. Create a tailnet
Sign up at tailscale.com and install the client on your own device; your first login creates the tailnet. Your tailnet's DNS name (the <tailnet>.ts.net part) is on the DNS page of the admin console (Tailscale: tailnet name).
2. Turn on MagicDNS and HTTPS certificates
In the admin console's DNS page:
- Enable MagicDNS, so
loams.<tailnet>.ts.netresolves on your devices (Tailscale: MagicDNS). - Under HTTPS Certificates, select Enable HTTPS, so the sidecar can get a certificate for that name (Tailscale: enabling HTTPS).
The hosted console is served over HTTPS, so the browser can only call an https:// engine. Without HTTPS certificates the console cannot reach your stack.
Certificate Transparency
Enabling HTTPS certificates publishes your machine names, such as loams.<tailnet>.ts.net,
in the public Certificate Transparency logs, so the hostname becomes discoverable. It stays
unreachable outside your tailnet. If your tailnet name itself is sensitive, Tailscale lets you
select a randomized tailnet DNS name (like yak-bebop.ts.net) instead
(Tailscale: enabling HTTPS).
3. Add tags and an access policy
Tag the stack's nodes so your policy can name them, and allow only your own devices to reach them, on port 443 only. In the admin console's Access controls page, add the tags' owners and a rule. This example uses the classic ACL syntax (Tailscale: tags, ACLs, policy syntax):
{
"tagOwners": {
"tag:loams": ["autogroup:admin"],
"tag:loams-house": ["autogroup:admin"]
},
"acls": [
// Keep the rules your tailnet already needs here, each followed by a comma.
// A new tailnet's default policy has an allow-all rule; remove it, or every
// device reaches every port.
// Your own devices reach the engine and the House on HTTPS, nothing else.
{ "action": "accept", "src": ["you@example.com"], "dst": ["tag:loams:443", "tag:loams-house:443"] }
]
}To admit a team, use a group ("group:data": ["alice@example.com", "bob@example.com"] under groups, then "src": ["group:data"]) or autogroup:member. To let a CI runner on the tailnet call the engine, tag it (for example tag:ci) and add { "action": "accept", "src": ["tag:ci"], "dst": ["tag:loams:443"] }; it will need an API key, as step 8 explains. Entries in acls are separated by commas; the policy editor refuses a file with one missing.
4. Create an auth key
In the admin console, go to Settings → Keys and Generate auth key (Tailscale: auth keys):
| Setting | Choose | Why |
|---|---|---|
| Tags | tag:loams | The node is owned by the tag, not by your user, and your policy names it. Tagged nodes have key expiry turned off by default |
| Ephemeral | Off | The sidecar keeps its node state in the tailscale-state volume and should stay the same node across restarts; an ephemeral node is removed when it goes offline |
| Reusable | Off | The key is used once, on first start (TS_AUTH_ONCE). Make it reusable only if the House's sidecar will share it |
| Pre-approved | On, if your tailnet requires device approval | So the node does not wait for an admin |
For the House, create a second key tagged tag:loams-house for HOUSE_TS_AUTHKEY, or leave that empty and make the first key reusable.
5. Configure and start the stack
Download the 0.1.0 compose bundle from the GitHub release, with the release's checksums and their signature, verify both, and unpack it:
base=https://github.com/ostrium-labs/loams/releases/download/v0.1.0
curl -fLO $base/loams-compose-0.1.0.tar.gz -fLO $base/SHA256SUMS -fLO $base/SHA256SUMS.asc
# the release signing key; check that its fingerprint is
# 333A C945 99D5 A1EB 2A41 6D1E 83DC 134D 8CAF 895D
curl -fsSL https://raw.githubusercontent.com/ostrium-labs/loams/main/docs/release/loams-release-key.asc | gpg --import
gpg --verify SHA256SUMS.asc SHA256SUMS # "Good signature from Loams Release Signing Key"
sha256sum --ignore-missing -c SHA256SUMS # macOS: shasum -a 256 --ignore-missing -c SHA256SUMS
tar -xzf loams-compose-0.1.0.tar.gz
cd loams-compose-0.1.0/release
cp .env.example .envThe bundle is the release's deploy/ directory, with images pinned to LOAMS_VERSION=0.1.0, so use it rather than a plain git clone of the default branch. A git clone --branch v0.1.0 checkout also matches the images, but its compose file predates the setup-token fix: there, LOAMS_SETUP_TOKEN works only from secrets/engine.env (step 7).
Edit .env:
TS_AUTHKEY=tskey-auth-… # from step 4
TS_HOSTNAME=loams # the node name; keep it unless you run two stacks
TS_LOGIN_SERVER= # empty: Tailscale
LOAMS_AUTH_MODE=tailscale # the default
LOAMS_TAILSCALE_ALLOWED_USERS=you@example.com,alice@example.com
LOAMS_SETUP_TOKEN= # the output of: openssl rand -hex 32 (step 7)LOAMS_TAILSCALE_ALLOWED_USERS is a comma-separated list of Tailscale logins, exactly as the admin console's Users page shows them (for example you@example.com, or you@github for a GitHub sign-in), with no quotes. Matching ignores case and spaces around commas. Empty admits every user your access policy lets reach the node, and the engine logs a warning. If your .env.example has no such line (a v0.1.0 git checkout), add it; the compose file passes it to the engine either way.
Setting LOAMS_SETUP_TOKEN now saves a restart in step 7.
Then start it:
docker compose up -d
docker compose ps # the services, and their state
docker compose exec tailscale tailscale status # the stack is on your tailnetEvery long-running service is listed as running. The one-shot setup jobs, create-bucket, migrate and pg-bootstrap, exit 0 by design once their work is done, so plain docker compose ps does not list them (docker compose ps -a shows them as exited). pg-bootstrap creates the Postgres stack's tenant and timeline, and compute1 starts after it. To attach compute1 to ids of your own, set both TENANT_ID and TIMELINE_ID in .env. They win over the generated ones, and setting only one of them is refused.
A git clone --branch v0.1.0 checkout predates pg-bootstrap: there, set TENANT_ID and TIMELINE_ID by hand as in deploy/loams-postgres-dev/README.md.
If TS_AUTHKEY is empty, the sidecar logs a login URL instead (docker compose logs tailscale). The engine is now at https://loams.<tailnet>.ts.net. To stop, docker compose down; your data stays in the compose volumes, and docker compose down -v deletes it.
6. Connect the console
On a device that is on the same tailnet, open console.loams.dev and enter https://loams.<tailnet>.ts.net as your engine. In tailscale mode you do not sign in with a password on every visit: Tailscale tells the engine who you are. A new engine has no organization yet, though, so the first time you also run first-run setup, once.
Your browser may ask for permission the first time a page on a public origin calls an address on your tailnet (Local Network Access). Chrome, Edge and Firefox all do this. Allow it for the console's origin. If the console still cannot reach the engine, the prompt may have been answered (or auto-allowed) while the request stayed blocked; Firefox then logs "CORS request did not succeed" in the console. Open the site settings for console.loams.dev (the icon left of the address), set Local network access to Allow, and reload the page.
Browser requests are limited by CORS to https://console.loams.dev; set LOAMS_CORS_ORIGINS only if you serve a console of your own.
7. First-run setup
A new engine has no organization. First-run setup creates it, with you as its owner, and runs once. With LOAMS_AUTH_MODE=tailscale (or oidc or apikey) authentication is on, and setup needs a setup token: without LOAMS_SETUP_TOKEN the console does not offer setup, and a setup call fails with "first-run setup on an engine with authentication on needs LOAMS_SETUP_TOKEN; set it and restart".
-
Generate a token:
openssl rand -hex 32. -
Give it to the engine. Put it in
.envasLOAMS_SETUP_TOKEN=<the token>, or insecrets/engine.env(oneKEY=valueper line; create the file with mode 0600 if it does not exist). When both set it,secrets/engine.envwins. In av0.1.0git checkout onlysecrets/engine.envworks, because that compose file does not pass the variable from.env. -
Recreate the engine if it was already running, so it reads the token:
docker compose up -d loams -
Create the organization in the console. Open console.loams.dev with your engine URL (step 6). While the engine has no organization and a token is set, the console opens on Set up this install (
#/setup). Enter the setup token, an organization name, your name, your email and a password of 12 characters or more; the console signs you in as the owner. Use your Tailscale login as the email, so that when Tailscale identifies you the engine maps you to this owner; a different email leaves your tailnet identity without a role.Or from the command line. The
loamsCLI has no setup command; make the same call from inside the engine's container, over its loopback port, reading the token from the engine's environment so it never appears on your command line:# Is setup still to do? Prints "setupRequired":true until it has run. docker compose exec loams curl -sS http://127.0.0.1:8080/loams.instance.v1.InstanceService/GetInstance \ -H 'content-type: application/json' -d '{}' docker compose exec -e ORG='Acme' -e NAME='Your Name' -e EMAIL='you@example.com' \ -e PASSWORD='at-least-12-characters' loams sh -c ' curl -sS http://127.0.0.1:8080/loams.auth.v1.AuthService/CompleteSetup \ -H "content-type: application/json" \ -d "{\"setupToken\":\"$LOAMS_SETUP_TOKEN\",\"orgName\":\"$ORG\",\"name\":\"$NAME\",\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}"'A wrong token is refused with
permission_denied. -
Remove the token afterwards (optional). Once setup has run, every further attempt is refused with "setup has already run", whatever the token. Empty
LOAMS_SETUP_TOKENwhere you set it and rundocker compose up -d loamsagain.
For a VM installed with byoc/install.sh, the installer has already generated LOAMS_SETUP_TOKEN in ~/loams/release/.env on the VM (mode 0600, not printed); read it there for the console's setup page.
8. Allowed users and machines
People. tailscale serve adds the caller's identity in Tailscale-User-Login and related headers. The engine trusts them only on its dedicated Tailscale listener, which is the sidecar's serve target; a forged header on any other listener gets a 401. LOAMS_TAILSCALE_ALLOWED_USERS is a comma-separated list of Tailscale logins to admit. Empty admits every user your access policy lets reach the node, and the engine logs a warning.
Machines. Tailscale's identity headers are empty for requests from tagged devices (Tailscale Serve). A CI runner or a server cannot use tailscale mode. Give it an API key instead. With the loams CLI from the release, in the compose directory:
loams auth new-key --label ci --append secrets/api-keys # prints the key once
# in .env:
LOAMS_AUTH_MODE=tailscale,apikey
docker compose up -d loamsThe CLI and SDKs then use the same tailnet URL with Authorization: Bearer lk_…. A 0.1.0 key has the form lk_<26-character ULID>_<32 base62 characters>; the file holds only its hash.
BYOC: your own VM over SSH
The stack is the same on a VM. The VM needs SSH and outbound internet only; no inbound port is opened, and SSH itself can move onto the tailnet once the stack is up. From the unpacked bundle on your own machine (or deploy/release/byoc/install.sh in a v0.1.0 checkout):
cd loams-compose-0.1.0
release/byoc/install.sh -i ~/.ssh/id_ed25519 you@your-vminstall.sh installs Docker if it is missing, copies the compose bundle (with deploy/loams-postgres-dev) to ~/loams, writes .env with mode 0600, asking for your Tailscale auth key (or take it from --authkey or $TS_AUTHKEY), and starts the stack.
| Option | Effect |
|---|---|
| (default) | Starts loams, tailscale and tidb |
--all | Starts every service, the Postgres stack included |
--services "loams tailscale tidb house house-tailscale" | Adds the House |
--version V | The image tag (default 0.1.0) |
--reconfigure | Rewrites an existing .env on the host |
-p, --dir, -y | SSH port, install directory, no prompts |
byoc/uninstall.sh removes it, and byoc/upgrade.sh you@your-vm --to <version> runs loams upgrade on the VM. Your device reaches the engine at https://loams.<tailnet>.ts.net, exactly as with a local stack.
The House on the tailnet
docker compose --profile house up -d
docker compose exec house cat /var/lib/loams-house/house.key # user default's password
curl -u "default:$PASSWORD" https://loams-house.<tailnet>.ts.net/ --data-binary 'SELECT version()'Loams House is Experimental. Any ClickHouse HTTP client works the same way (port 443, TLS, user default). To reach it from the engine and the console's House page, set LOAMS_HOUSE_ENDPOINT and HOUSE_PRINCIPALS as in the deploy README.
What is not on the tailnet
Only the engine's HTTP API (and, with the profile, the House) is served over the tailnet. The Qdrant, Elasticsearch, Flight SQL, Postgres-wire and MySQL-wire listeners, Live's own port and Resonate stay on loopback inside the stack. TiDB starts with a passwordless root and the Postgres stack with default credentials; neither is forwarded. Do not publish these in 0.1.0.
Headscale: coming soon
Planned The compose file has a headscale profile and TS_LOGIN_SERVER, but Headscale is unsupported in 0.1.0: it issues no *.ts.net HTTPS certificates, so the hosted console (HTTPS) cannot call an engine on it; the browser blocks it as mixed content. Use Tailscale.
Troubleshooting
loams.<tailnet>.ts.net does not resolve. MagicDNS is off, or your device is not on the tailnet. Enable MagicDNS (step 2), check that the Tailscale client is connected, and that tailscale status on your device lists loams. Off the tailnet the name resolves to nothing, by design.
The console says it cannot reach the engine, or the browser shows a certificate error. HTTPS certificates are not enabled for the tailnet (step 2), or the engine URL starts with http://. Enable HTTPS and use https://loams.<tailnet>.ts.net. docker compose logs tailscale shows certificate errors.
The request times out from one device but works from another. Your access policy does not let that device reach tag:loams:443. Check the rule in step 3; the admin console's access-controls preview shows what a user can reach.
401 or 403 from the engine. You are reaching it, but it does not admit you. A 403 saying "this tailnet user is not allowed on this engine" means your login is not in LOAMS_TAILSCALE_ALLOWED_USERS (step 5). A 401 means the request carried no identity: requests from tagged devices carry none, so use an API key there.
Setup fails with "needs LOAMS_SETUP_TOKEN", or the console never offers setup. The engine has no setup token. Set it in .env or secrets/engine.env (only secrets/engine.env in a v0.1.0 git checkout) and recreate the engine (step 7). "setup_token is not this engine's LOAMS_SETUP_TOKEN" means the token you entered differs from the file's.
The sidecar never joins, or joins under another name. Check docker compose logs tailscale. An expired or already used single-use key fails; create a new one. A node named loams that already exists makes Tailscale pick loams-1; remove the old node in the admin console, then recreate the sidecar with an empty state (docker compose down, remove the tailscale-state volume, docker compose up -d).
Podman: recreating the engine after an .env change fails with "has dependent containers". The sidecar shares the engine's network namespace. Run docker compose rm -sf tailscale && docker compose up -d loams tailscale.
More in Running Loams 0.1.0 and the deploy README.