Loams v0.1.0 is out: run the whole stack yourself with Docker Compose. See the roadmap

Blog/Research

Why the Loams engine never faces the public internet

Self-hosting a data platform on your own tailnet. The threat model, what zero trust and WireGuard actually give you, a decade of exposed Elasticsearch, MongoDB, Redis and ClickHouse instances, the four auth modes, the hosted console that talks to your engine over your tailnet, BYOC over SSH, and what is still not hardened in 0.1.0.

Why the Loams engine never faces the public internet
On this page
  1. The threat model
  2. How self-hosted data tools get exposed
  3. Zero trust, without the marketing
  4. The one door
  5. Authentication on every request
  6. The hosted console over your tailnet
  7. BYOC over SSH
  8. What is still not hardened
  9. What comes next
  10. Sources

Loams 0.1.0 has exactly one supported deployment shape. You run the engine stack, on your laptop or on your own VMs. It has no public address. Your browser reaches it over your own Tailscale tailnet. We host two things: the console's static files at console.loams.dev and, for one optional sign-in mode, an OIDC issuer at auth.loams.dev. Neither ever connects to your stack.

People ask why. Why not a port and a password like every other database? Why should a page on a public origin talk to a private network? Why Tailscale and not Headscale or an identity-aware proxy? This post answers from primary sources, and is specific about where the design is still weak.

Facts about Loams below come from the release guide (docs/release/preview.md), the compose bundle (deploy/release), SECURITY.md, PRIVACY.md and the decision log. Everything else is linked to where it was published, with its date.

The threat model

Start with what a preview release of a new data platform actually looks like from an attacker's side.

  • It speaks many protocols. The engine serves its own API on the main port, and it also has Postgres-wire, MySQL-wire, Qdrant, Elasticsearch and Flight SQL listeners. Every protocol is a parser, an authentication path and a set of defaults that has to be right.
  • Authorization is coarse. In 0.1.0 a valid credential can do anything the engine can do. Per-namespace roles and tenant quotas are designed but not shipped.
  • Nobody outside the team has reviewed it. The release guide says so in as many words: "No external security review has been done."
  • It holds the things attackers want. Documents, embeddings, logs, and the credentials for your object store.

The attacker we care most about is not a targeted adversary. It is the scanner: internet-wide scanning is cheap, continuous and automated. The question for a new system is not "is the authentication good?" but "what happens on the day a default is wrong, or someone turns authentication off to debug something and forgets?"

So the threat model for 0.1.0 has three parts:

  1. Unsolicited connections from the internet. The defence is to have nothing listening there.
  2. Connections from your own network that you did not intend. A tailnet is a network too, and by default a permissive one. The defence is a tailnet policy plus authentication on every request.
  3. The services we host. If console.loams.dev or auth.loams.dev were compromised, what could they reach? The design answer is: they never connect to your stack, and the console holds no data or secrets.

Out of scope, as SECURITY.md puts it, are "findings that need a compromised host or bucket". If someone owns the VM, they own the stack.

How self-hosted data tools get exposed

None of this is hypothetical. Exposed data stores are among the most consistently documented failures in the industry, and the pattern has barely changed in ten years.

MongoDB, 2015. On 18 July 2015, Shodan's founder John Matherly published a count of nearly 30,000 MongoDB instances reachable from the internet with no authorization enabled, exposing 595.2 TB of data. He noted Redis had similar problems, and that MySQL and PostgreSQL had fairly secure defaults.

MongoDB, 2017. In January 2017 attackers began wiping exposed MongoDB databases and leaving ransom notes. The Next Web reported on 8 January 2017 that over 10,500 servers had been hit within a week, tracked by researchers Niall Merrigan and Victor Gevers, and that in every case the administrator account had no password. MongoDB's own response was to change a default: starting in MongoDB 3.6, released that November, mongod and mongos bind to localhost by default. Before 3.6, only the official RPM and DEB packages did.

MongoDB, 2026. The default change helped and did not end it. On 26 January 2026 Flare published a new survey: more than 200,000 MongoDB servers publicly discoverable through Shodan, about 3,100 with no access restrictions, and 1,416 of those 3,100 (45.6%) already wiped and replaced with a ransom note of about $500 in Bitcoin. One wallet appeared in more than 98% of the notes. Flare's conclusion is the sentence this whole post rests on: "misconfiguration—not exploitation—continues to be the critical enabling factor."

Elasticsearch and MongoDB, 2020. In July 2020 the "Meow" attacks deleted exposed databases outright, with no ransom demand. BleepingComputer's coverage, first published 22 July 2020 and updated 25 July, counted almost 4,000 deleted databases, more than 97% of them Elasticsearch and MongoDB. Bob Diachenko, who found one of the first victims, described an automated script that "overwrites or destroys the data completely." Elastic had already been moving the same way as MongoDB: in its 8.0 preview of 9 November 2021 it announced that "in Elastic 8.0, security is on by default."

Redis. Redis's security documentation is candid that it "is designed to be accessed by trusted clients inside trusted environments," and that "many instances are simply left exposed on the internet with public IPs." Since 3.2.0 Redis enters a protected mode when run with the default configuration and no password, replying only on loopback. On 19 July 2023, Palo Alto Networks' Unit 42 reported the P2PInfect worm and counted over 307,000 Redis systems communicating publicly in the preceding two weeks. Only 934 looked vulnerable to that worm; the worm tried the rest anyway.

ClickHouse, 2025. On 29 January 2025, Wiz Research reported a ClickHouse database belonging to DeepSeek on ports 8123 and 9000 of two of its hostnames, "accessible without any authentication at all." It held a table of more than a million log lines with chat history, API keys and backend details, and ClickHouse's HTTP interface let anyone run SQL against it from a browser. DeepSeek secured it promptly once told. ClickHouse's shipped config.xml carries the warning in a comment beside listen_host: if you listen on a wildcard address, make sure the server is firewalled, users are restricted to networks, or passwords are strong, and it links a Shodan search for ClickHouse as a reminder.

Read together, these incidents say three things.

  • Defaults matter more than features. Every one of these products had authentication. The incidents happened where it was off or weak.
  • Fixing the default is not enough. MongoDB has bound to localhost by default since 2017, and in 2026 thousands of servers are still open. People change a default to make something work, and the change outlives the reason.
  • The port is the problem. Every incident starts with a scanner reaching a port. No port, no incident of this kind.
Left: the shape behind most of the incidents cited below. Right: the 0.1.0 compose stack. The public address on the right answers nothing but SSH, and SSH can move onto the tailnet too.

Zero trust, without the marketing

"Zero trust" has become a sales category, so it is worth going back to the sources.

Google's BeyondCorp paper, by Rory Ward and Betsy Beyer in ;login: in December 2014, started from the observation that perimeter firewalls fail badly: once the perimeter is breached, "an attacker has relatively easy access to a company's privileged intranet." Google's answer was to remove the privileged intranet entirely and move corporate applications to the internet, behind an access proxy, so that access depends on the user's and the device's credentials, not on which network they are on. NIST later generalized the idea in SP 800-207 (August 2020), whose abstract defines zero trust as granting "no implicit trust ... to assets or user accounts based solely on their physical or network location."

Notice that BeyondCorp's answer was to put applications on the internet. That works when you have an identity-aware proxy in front of every application and a team running it. Google Cloud's Identity-Aware Proxy is the productized version: a central place to enforce user-based access to web apps, instead of relying on network firewalls alone. Pomerium, oauth2-proxy and Cloudflare Access fill the same role for self-hosters.

We did not choose that shape for 0.1.0, for two reasons. An identity-aware proxy protects HTTP, while Loams also speaks Postgres, MySQL and other binary protocols; a proxy in front of the HTTP port does nothing for a wire listener someone publishes by mistake. And it puts a public endpoint back into the design, which is where every incident above starts. A proxy misconfiguration is a public data store.

A mesh VPN with identity gets most of the zero-trust properties without a public endpoint. WireGuard (Jason Donenfeld, NDSS 2017) is built on "an association between a peer public key and a tunnel source IP address": a packet from an IP is accepted only if it was authenticated with that peer's key. It is designed to allocate no resources in response to unauthenticated packets, so a scanner probing a WireGuard port gets silence. Tailscale adds key distribution, identity and policy on top. In Avery Pennarun's description of how it works (20 March 2020), the coordination server "just exchanges a few tiny encryption keys and sets policies," and "the private key never, ever leaves its node." When two devices cannot connect directly, DERP relays "blindly forward already-encrypted traffic."

That gives Loams the property it actually needs: every connection that reaches the stack comes from a device with a key that your identity provider enrolled and your tailnet policy admits. And it keeps the BeyondCorp rule that the network alone confers no trust, because the engine still authenticates every request it receives, as the next sections show.

What you trust instead is worth naming. You trust Tailscale's coordination server to distribute the right public keys, and your identity provider to sign the right people in. Tailscale offers Tailnet Lock, off by default, under which you "don't need to trust the Tailscale coordination server for distributing public node keys"; it is a tailnet setting outside Loams' release docs. Headscale, an "open source, self-hosted implementation of the Tailscale control server" for a single tailnet, removes Tailscale the company from the picture. More on why 0.1.0 does not support it yet below.

The one door

The compose file in deploy/release/compose.yaml is short on this point, and that is the point.

  • The loams engine runs with --listen=127.0.0.1:8080. Every other engine listener is on loopback too, including Flight SQL, which standalone would otherwise put on 0.0.0.0; the compose file overrides it with a comment saying why.
  • A tailscale sidecar runs with network_mode: service:loams. It shares the engine's network namespace and is the only other container there. It uses userspace networking (TS_USERSPACE: "true"), so it needs no extra capabilities and no /dev/net/tun.
  • The sidecar's tailscale serve configuration, tailscale/serve-https.json, proxies https://<TS_HOSTNAME>.<tailnet>.ts.net on port 443 to http://127.0.0.1:7781, and sets "AllowFunnel": false. Funnel is Tailscale's feature for publishing a node to the public internet; it stays off.
  • Nothing binds 0.0.0.0 on the host by default. The optional services that publish a port (a local console, Headscale, the Postgres control plane) publish it on 127.0.0.1.

The release guide's summary: "Only the tailscale service (and, with --profile house, house-tailscale) is reachable from outside the compose network, and only over your tailnet."

Two Tailscale settings matter here. MagicDNS and HTTPS certificates must be on, so the sidecar can serve a real *.ts.net certificate; Tailscale gets it from Let's Encrypt with a DNS-01 challenge, and the private key stays on the node. That has a side effect Tailscale's HTTPS documentation states plainly: certificates are recorded in public Certificate Transparency logs, including "the fully qualified domain name of your devices." The name loams.<tailnet>.ts.net is therefore public. Knowing it does not let anyone connect, but do not put anything sensitive in TS_HOSTNAME.

The second setting is the tailnet policy. Tailscale's access control documentation notes that "the default tailnet policy file enables communication between all devices within the tailnet." On a fresh tailnet, every device you own can reach every other one. The release guide asks for a tagged auth key (for example tag:loams) and a policy rule that lets your own devices reach tag:loams on port 443, "and nothing else reach it." That rule is the network half of the model. Without it, a compromised smart TV on your tailnet can open a connection to the engine, and only authentication stands in the way.

The decision log records the reasoning behind keys (D588): join keys are tagged, single-use and as short-lived as the provider allows, because "a leaked key is worth minutes, not months."

Authentication on every request

Getting through the tailnet gets a packet to the engine. It does not get a request served. LOAMS_AUTH_MODE picks one of four modes, and the engine applies it to every request.

tailscale (the default). When tailscale serve proxies a request, it adds headers naming the tailnet user who made it, such as Tailscale-User-Login. Tailscale's Serve documentation says it strips those headers if a client sends them, and recommends that the backend listen only on localhost, because otherwise "any user that can call your service directly" could supply their own values.

The engine takes that advice one step further. It trusts identity headers on exactly one listener, LOAMS_TAILSCALE_LISTEN (127.0.0.1:7781), which is the sidecar's serve target and nothing else's. Every other listener ignores them, so a forged header on the main port gets a 401. LOAMS_TAILSCALE_ALLOWED_USERS narrows who is admitted by login; empty, it admits every user your tailnet policy lets reach the node. Day to day, this mode needs no extra sign-in: Tailscale already knows who you are. The first run still needs one step, once: an engine with authentication on creates its organization and owner only when you set LOAMS_SETUP_TOKEN (in .env or secrets/engine.env) and enter it in the console's setup page; see first-run setup. Use your Tailscale login as the owner's email, and the tailnet identity maps to that owner.

One detail from Tailscale's documentation shapes how you use this mode: identity headers "are not populated for traffic originating from tagged devices." A CI runner or a server joined with a tagged key has no user identity to pass along. Machines should use the next mode.

apikey. For the CLI and the SDKs. loams auth new-key creates a key, of the form lk_<ULID>_<secret> in 0.1.0; the key file (LOAMS_API_KEYS_FILE) holds only hashes. You call the same tailnet URL.

oidc. Bearer JWTs from an issuer, https://auth.loams.dev or your own, signed with RS256 or EdDSA and verified against the issuer's JWKS, which the engine fetches outbound and caches. The engine refuses to start in this mode unless you set one of LOAMS_OIDC_ALLOWED_EMAILS, _SUBJECTS or _GROUPS. That refusal exists because "any valid token from this issuer" is the OIDC equivalent of a database with no password: a hosted issuer will sign tokens for people who are not you. The console signs in with Authorization Code and PKCE and keeps tokens in memory, never in localStorage.

off. No authentication, and accepted only from loopback clients: "the engine refuses every request that does not come from loopback, so this mode cannot be reached over the tailnet." It is for a laptop-only trial with the optional local console. It is the Redis protected-mode idea applied to an explicit setting: turning auth off does not turn the network on.

Browsers get one more check. CORS limits browser requests to https://console.loams.dev, or the origins you list in LOAMS_CORS_ORIGINS. Loams Live apps list their own allowed page origins, and a Live call from any other origin is refused with live_origin_not_allowed.

If you do want the main listener beyond loopback, the engine supports it, with conditions: a certificate (LOAMS_TLS_CERT_FILE, LOAMS_TLS_KEY_FILE), optional client certificates (LOAMS_TLS_CLIENT_CA_FILE), or a declared TLS-terminating proxy with the addresses it connects from; connections from anywhere else are closed. Loams Live refuses to start on a non-loopback listener without TLS. The compose stack does not do this; it relies on the tailnet's encryption for the hop from your device.

The hosted console over your tailnet

The part that surprises people is the console. It is served from a public origin, console.loams.dev, and it manages an engine that has no public address. How?

The console is static files. PRIVACY.md describes it as "a static web app served from Cloudflare. Your browser downloads the app and then talks directly to your own engine." The engine URL you enter, a tailnet name such as https://loams.<tailnet>.ts.net, is kept in your browser's local storage; nothing about it is sent to us. When the console calls the engine, the request leaves your browser through your device's Tailscale client, crosses WireGuard to the stack's sidecar, and reaches the engine's identity listener. "Off the tailnet the URL does not resolve and nothing answers."

What Ostrium runs (top) serves files and, optionally, sign-ins. Your data moves only along step 3, inside WireGuard, from your device to your stack. Solid lines are the default tailscale mode; dashed lines exist only in oidc mode.

This split has properties worth spelling out.

  • We never see your data. Queries and results travel between your browser and your engine. The console origin serves JavaScript; it is not in the data path.
  • The auth service never reaches your stack. In oidc mode the engine fetches the issuer's keys outbound. The release guide: "the auth service sees sign-ins, not data." In the default tailscale mode, auth.loams.dev is not involved at all.
  • A compromise of the console origin is bounded, not zero. If someone could change the JavaScript served at console.loams.dev, that code would run in your browser with your tailnet reachability and your session. That is the same trust you place in any web app you use, and it is the reason the console on your own Vercel or Cloudflare project is on the roadmap: then you control the origin, add it to LOAMS_CORS_ORIGINS, and the hosted one is out of the picture. The optional console compose profile can already serve a local copy for trials, but only a console you build yourself: in 0.1.0 both a git checkout and the release's compose bundle carry a placeholder console/dist, so build the console from a checkout (corepack enable, pnpm install --frozen-lockfile, then pnpm build in web/) and point LOAMS_CONSOLE_DIR at web/apps/console/dist. The hosted console over your tailnet needs no build.
  • HTTPS everywhere, which is why the certificates matter. A console served over HTTPS cannot call an http:// engine; the browser blocks it as mixed content. The tailnet's *.ts.net certificates are what make the hosted console possible at all. Decision D593 states the rule underneath: credentials never travel over plain HTTP to a non-loopback origin, even inside WireGuard.

There is one browser wrinkle. Chrome has been adding a Local Network Access permission prompt (announced 9 June 2025) for public sites that make requests to local and private addresses, to protect routers and other devices from cross-site request forgery. Firefox does the same. A tailnet address is a private address, so your browser may ask the first time the console calls your engine. The release guide's advice is to allow it for the console's origin. If the console still cannot reach the engine (Firefox logs "CORS request did not succeed"), the permission can look granted while the request stays blocked: set Local network access to Allow in the site settings for console.loams.dev, then reload. The prompt is doing its job: it is the same protection that stops a random site from probing your engine.

BYOC over SSH

On your own VM the stack is the same, and the installer is deliberately boring. From a checkout on your own machine:

deploy/release/byoc/install.sh -i ~/.ssh/id_ed25519 you@your-vm

byoc/install.sh connects over SSH, installs Docker if it is missing, copies the compose bundle, writes .env (asking for your Tailscale auth key, or taking it from --authkey or TS_AUTHKEY) and starts the stack. By default it starts loams, tailscale and tidb; --all starts everything, and byoc/uninstall.sh removes it. To do it by hand, copy deploy/release to the VM, cp .env.example .env, set TS_AUTHKEY and run docker compose up -d.

The release guide's two sentences on the network are the whole design: the VM "needs no inbound port other than SSH, and SSH itself can move onto the tailnet once the stack is up," and "nothing on the VM listens on its public address." Close port 22 in your cloud firewall once you reach the VM over Tailscale, and a scan of its public address finds nothing to talk to.

Why SSH and not an agent or a hosted control plane? Every BYOC design with a vendor service on the path asks you to trust that service with something. The decision log records the longer-term options (D64, D587): a managed BYOC mode with an outbound-only agent, and a tailnet mode in which "a customer brings their own Tailscale key" and "the operator never holds a customer's key." For 0.1.0, SSH from your own machine is the smallest thing that works, and the only party that touches the VM is you.

What is still not hardened

The release guide has a section with this title, and it is worth reading in full before you put real data in the stack. Here is the list, with the reason each item matters.

Only the top-left path is reachable from your tailnet. Everything in the lower half of the namespace is loopback-only on purpose: those listeners do not check credentials the way the main port does, so publishing them would undo the design (release guide, “What is not hardened yet”).
  • Authorization is coarse. A valid token or key can do anything the engine can do. There are no per-namespace roles or tenant quotas yet. LOAMS_TAILSCALE_ALLOWED_USERS and the OIDC allow-lists decide who gets in, not what they may do.
  • The other listeners must stay inside the stack. The Qdrant (6333, gRPC 6334), Elasticsearch (9200), Flight SQL (8082), Postgres-wire (5432) and MySQL-wire (3306) listeners are on loopback in the engine's network namespace. The Postgres and MySQL wire listeners authenticate nobody: any user name and password is accepted, and they serve the default namespace. That is exactly why they are on loopback, and why the release guide says not to publish any of them in 0.1.0: the auth modes are specified for the main port, and the other listeners are not all reviewed against them. The sidecar's README shows how to add a TCPForward for TiDB or a Postgres compute after setting their passwords; do not do it for the engine's wire listeners. Publishing one would recreate, inside your tailnet, the exact shape of the incidents above.
  • Traffic inside the compose network is plain text, between loams, TiDB, TiKV, PD and the Postgres stack. The tailnet encrypts the hop from your device to the stack, not the hops inside it. On one VM that is the host's problem; across VMs, put them on the same tailnet.
  • The Postgres development stack uses default credentials and runs its compute with fsync = off. It is a development stack.
  • Headscale is coming soon, not supported. The headscale profile and TS_LOGIN_SERVER exist in the compose file, but Headscale issues no *.ts.net HTTPS certificates, so the hosted console, served over HTTPS, cannot call an engine reached through it. Your trust in Tailscale's coordination server is therefore part of the 0.1.0 model.
  • Desktop and CLI builds on macOS and Windows are not signed by a trusted certificate. On macOS they are ad-hoc signed and not notarized, and you clear the quarantine attribute once; on Windows they are unsigned and SmartScreen warns. Verify downloads against the published SHA-256 checksums. The server stack itself is Linux containers from ghcr.io/ostrium-labs, pinned to 0.1.0.
  • Upgrade snapshots hold your data and secrets. loams upgrade snapshots every store before it changes anything; the snapshots stay on the host with mode 0700. Treat them like the data they contain. Other backups are yours to make.
  • No external security review has been done. Loams is 0.x. SECURITY.md says "Loams has no stable release yet" and that fixes land on main.

The network design exists because of these items, not to hide them. A tailnet-only engine with coarse authorization is reasonable to run for yourself and a small team. The same engine on a public address would not be.

What comes next

The designed next steps, from the decision log and the security model page:

  • Roles and authorization behind an Authorizer: role-based access on namespaces and collections first, then fine-grained grants (D66, D67), and a separate control store for organizations, API keys and quotas (D65). D65 also plans a new key format, loams_<id>_<secret>; the keys 0.1.0 issues today are lk_<ULID>_<secret>.
  • Headscale networks, once the certificate question has an answer the hosted console can use. D593 lists the options: a name in your own DNS with a DNS-01 certificate, a private CA with the key pin in the pairing payload, or a DNS-01 wildcard for the MagicDNS domain.
  • The console on your own Vercel or Cloudflare project, self-service OIDC clients, and one-click OAuth deploy.
  • Customer-managed keys per namespace (D96) and an audit stream (D100).

Until those land, the rule for 0.1.0 is simple: keep the engine on your tailnet, write a tailnet policy that admits only your devices, pick an auth mode that names who may use it, and publish nothing else.

If you find something we got wrong, please report it privately through GitHub's private vulnerability reporting or security@ostriumlabs.org, as SECURITY.md describes. We acknowledge reports within three business days and default to publishing an advisory within 90 days.

Sources

Loams (all on the dev branch of ostrium-labs/loams): the release guide (security model, auth modes, BYOC, the tailnet, known limitations), compose.yaml and .env.example, the tailscale/ serve configurations, byoc/install.sh, SECURITY.md, PRIVACY.md, and the decision log (D64, D65, D580 to D593).

Incidents and scans, by date:

Zero trust and networking:

More from the blog