Serve anywhere
Three ways to run the same code. Pick per environment. Nothing in the apps changes.
| Where | What runs there | Notes | |
|---|---|---|---|
| Cloud | Cloudflare | apps/site, apps/docs (static assets), apps/web (Workers, SSR) |
bun run --cwd apps/<app> deploy:cloudflare per app; wrangler.jsonc included |
| One container host (Fly.io, Railway, Hetzner + Coolify, …) | apps/api and Postgres |
Dockerfiles in each app; managed Postgres or the compose one | |
| Self-hosted | Your VPS / home server | everything, via infra/compose.yml |
Caddy in front; Tailscale for private or public access |
| Local | Your machine | bun run dev + compose.dev.yml (Postgres) |
Tauri shell via bun run dev:desktop |
The targets
Section titled “The targets”| Target | How | Notes |
|---|---|---|
| Website | apps/site + apps/docs → Cloudflare (or Caddy). apps/web → Cloudflare Workers (ADAPTER=cloudflare) or a container (ADAPTER=node) |
The API runs on a container host (Bun.SQL, long-lived connections) |
| Mobile site / PWA | Same apps/web: manifest, theme-color, service worker with a prerendered offline page |
Registered in production browsers only |
| Desktop app | apps/native → bun run build:desktop builds apps/web with ADAPTER=static and bundles installers |
Auth switches to bearer tokens automatically. Needs PUBLIC_API_URL and PUBLIC_WEB_URL (below), or PUBLIC_SERVER_CHOICE |
| Mobile app | Same Tauri project: android:init / ios:init, then android:build / ios:build |
Or point frontendDist at the hosted app URL for a thin client |
| Self-hosted (VPS / home server) | docker compose up -d --build from the repository root (COMPOSE_FILE is in .env) |
Caddy in subdomains (public TLS) or single-origin mode (path routing) |
| Anywhere access, incl. your phone | Tailscale on the host: tailscale serve --bg 80 (tailnet) or tailscale funnel --bg 80 (public) |
No open ports, HTTPS handled |
| One machine, no Docker | bun run serve, published with tailscale serve --bg http://127.0.0.1:8787 |
Serve from a laptop over Tailscale: the API, the web app and an origin proxy on 127.0.0.1, no Caddy |
One web build, three adapters
Section titled “One web build, three adapters”apps/web builds for Node (adapter-node), Cloudflare Workers (adapter-cloudflare) or as a
static SPA (adapter-static) with one variable. These are the web app’s own scripts. Run them from
apps/web, or with --cwd from the repository root:
| Command | Adapter | Use |
|---|---|---|
bun run --cwd apps/web build / build:node |
adapter-node |
Self-hosted container (apps/web/Dockerfile) |
bun run --cwd apps/web build:cloudflare |
adapter-cloudflare |
Cloudflare Workers (wrangler.jsonc) |
bun run --cwd apps/web build:static |
adapter-static (SPA) |
Any static host. Writes to build/ |
bun run --cwd apps/web build:static:desktop |
adapter-static (SPA) |
What the Tauri shell builds: the same SPA with STATIC_OUT_DIR=build-static, so it gets a directory of its own |
The root bun run build builds every app through Turborepo, apps/web with its default adapter.
The static build sets ssr = false and switches auth from cookies to bearer tokens. The app holds
no business logic, so every route works as SSR and as SPA.
Desktop and mobile: Tauri 2, no IPC
Section titled “Desktop and mobile: Tauri 2, no IPC”apps/native is a Tauri 2 project for Windows, macOS, Linux, Android and iOS. The shell loads the
static build of apps/web, and the frontend talks to the API over HTTPS exactly like the browser.
The Rust side is small and only grows for native capabilities the web platform lacks (tray,
autostart, deep links, updater, notifications, biometrics), as Tauri plugins, never as a data layer.
# root scriptsbun run dev:desktop # starts the SvelteKit dev server and opens the shellbun run build:desktop # builds apps/web into build-static, then bundles installersbun run smoke:desktop # runs the shell against a stub API, then closes itbun run smoke:desktop -- --server-choice # the same for a shell that asks for its server
# the mobile targets and the icon generator live in apps/native onlybun run --cwd apps/native android:init # once, then android:dev / android:buildbun run --cwd apps/native ios:init # once (macOS), then ios:dev / ios:buildbun run --cwd apps/native icons # regenerate platform icons from ../web/static/icons/icon-512.pngThe mobile dev scripts need HOST=0.0.0.0 in the root .env: the device calls the API at this
machine’s network address, and a development API listens on 127.0.0.1 alone.
bun run smoke:desktop checks that a shell starts and reaches its API without one: it builds the
shell against a stub API on 127.0.0.1, opens it, and passes once the page has asked for the session
from the shell’s own origin and loaded the sign-in page. .github/workflows/shells.yml runs it on
Linux, macOS and Windows when you start it by hand, on Linux on every pull request and push to
main once you set SHELL_SMOKE=true, and on all three with SHELL_SMOKE_ALL_OS=true
(Tests, tooling and CI). What a person has to look
at (sign-in, reloads, links, the release build) is the checklist in apps/native/README.md,
“Checking a shell by hand”.
A shell is built for one server unless PUBLIC_SERVER_CHOICE=true where it is built. Then it
opens on /connect, where its user enters a server’s address; the shell keeps that origin, names
it alone in each page’s Content-Security-Policy, and clears what the webview stored when it
changes, so one installer serves every deployment of your product. Each server must list
tauri://localhost and http://tauri.localhost in TRUSTED_ORIGINS, and PUBLIC_STORAGE_ORIGIN
and productCsp() stay fixed at build time. apps/native/README.md, “One installer for many
servers”, has the details; D48 in docs/DECISIONS.md the reasons.
Two ways to ship the frontend:
- Bundled (default).
frontendDist: ../../web/build-static, which the shell’sbeforeBuildCommandfills by runningbun run --cwd ../web build:static:desktop. It is a directory of its own (gitignored), separate from thebuild/the node and Cloudflare adapters write to. The API URL is baked in viaPUBLIC_API_URLat build time. - Remote (thin client). Set
frontendDisttohttps://app.example.comand grant that origin in a capability (remote.urls). Ship the app once, update the frontend by deploying the web app.
Prerequisites: the Rust toolchain (rustup), the platform dependencies listed by Tauri, and for
mobile Android Studio + NDK or Xcode (iOS, macOS only).
build:desktop refuses to start unless PUBLIC_API_URL and PUBLIC_WEB_URL are set to
something that is not a placeholder (a shell built with PUBLIC_SERVER_CHOICE=true needs neither).
PUBLIC_WEB_URL is where the shell’s emailed links and payment returns point, because the
webview’s own origin (tauri://localhost or http://tauri.localhost) is not an address a browser
can open. The shells sign in with email and password only; apps/native/README.md lists what else
they lack. Add PUBLIC_STORAGE_ORIGIN whenever uploads go to S3/R2: the shell’s connect-src is
generated from exactly these values (apps/native/scripts/tauri-config.ts reads apps/web/.env,
.env.local, .env.production and .env.production.local in Vite’s own order, process
environment last). Without the bucket origin every presigned upload fails the policy.
Releasing. Bump version in the root package.json. The generator writes
src-tauri/Cargo.toml, apps/native/package.json and the Tauri config from it, so Cargo.lock
moves with it. Then push a v<version> tag. .github/workflows/desktop.yml builds Windows, macOS
(both architectures) and Linux, and a final job that needs the whole matrix collects the artifacts
into one draft release, so a partial installer set can never reach a release. The tag must equal
the root version exactly, or the run fails before anything is built. Its repository variables are
the same PUBLIC_* names.
When a user reports “it opens and closes again”, ask for the log:
%LOCALAPPDATA%\dev.starterdough.native\logs\Starterdough.log on Windows,
~/Library/Logs/dev.starterdough.native on macOS, $XDG_DATA_HOME/dev.starterdough.native/logs on
Linux, plus dev.starterdough.native-startup-error.txt in the temp directory when the failure came
before the window existed.
Self-hosting with Docker Compose
Section titled “Self-hosting with Docker Compose”# from the repository root. COMPOSE_FILE=infra/compose.yml is in .env, so no -fcp .env.example .env # DOMAIN, CADDY_MODE, secrets; or WEB_URL + API_URL for a tailnet hostdocker compose up -d --buildMigrations run on every up (migrate one-shot). Two Caddy modes, selected with CADDY_MODE:
subdomains(public):DOMAIN,docs.DOMAIN,app.DOMAIN,api.DOMAINwith automatic HTTPS. SetCOOKIE_DOMAIN=.DOMAINso the session cookie spansapp.andapi..single-origin(tailnet/LAN): path routing on one host, TLS terminated by Tailscale. SetWEB_URLandAPI_URLto that same origin, so there is no CORS and cookies are plain same-site. Docs live at/docs.
The Astro sites are built into the Caddy image by infra/docker/Dockerfile.static. The runbook
(backups, deploy, traces, secrets) is Operations and infra/README.md.
Tailscale: reach it from anywhere, including your phone
Section titled “Tailscale: reach it from anywhere, including your phone”Install Tailscale on the server (host, not container) and on your devices, then:
# private: only devices on your tailnettailscale serve --bg 80# public: no open ports, no DNS, Let's Encrypt handled by Tailscaletailscale funnel --bg 80Both give you https://<machine>.<tailnet>.ts.net. Use CADDY_MODE=single-origin and set WEB_URL
and API_URL to https://<machine>.<tailnet>.ts.net (compose derives the app’s PUBLIC_API_URL
from API_URL). On the phone, install the Tailscale app once. The PWA or the Tauri build then
works on the go.
Without Docker, bun run serve runs the app on one machine behind one origin of its own, which
tailscale serve publishes the same way: Serve from a laptop over
Tailscale.
Alternatives: Cloudflare Tunnel (cloudflared) for public access without ports, or a Tailscale
sidecar container (tailscale/tailscale image with TS_SERVE_CONFIG) if you prefer everything in
compose.
Cloudflare notes
Section titled “Cloudflare notes”- Astro sites: static assets on Workers (
wrangler.jsoncinapps/siteandapps/docs;bun run --cwd apps/site deploy:cloudflare, same forapps/docs). Each build needs that app’s ownSITE_URL. A build without one fails, anddeploy:cloudflarealso refuses a localhost one, the valuebun run setupgives local builds. - SvelteKit:
ADAPTER=cloudflare→@sveltejs/adapter-cloudflare(Workers static assets + SSR). - The API stays on a container host: it uses Bun-native
Bun.SQL, long-lived Postgres connections and Better Auth’s full feature set, which do not fit Workers well. To run the API at the edge, use Hyperdrive for Postgres pooling and swapdrizzle-orm/bun-sqlfordrizzle-orm/postgres-js.
Backups
Section titled “Backups”Profiles are selected in .env, never on the command line. infra/scripts/deploy.sh runs
up --remove-orphans, which would drop the containers of every profile not named in that one
invocation:
echo 'COMPOSE_PROFILES=backup' >> .envdocker compose up -ddocker compose run --rm backup bun src/cli.ts backupdocker compose run --rm backup bun src/cli.ts restore latest --drillKeep the .env with the backups, and above all its BETTER_AUTH_SECRET: restored under another
one, the database accepts no earlier session and locks out every account with two-factor sign-in.
The backup service writes a custom-format pg_dump plus, with the local storage driver, a
tarball of the uploads. It copies both to S3/R2 when a bucket is set, prunes old sets and pings
BACKUP_HEARTBEAT_URL. Details: Operations and infra/backup/README.md.
A real restore (restore <stamp> --yes) re-hashes the dump against its manifest, refuses a database
name that does not match the target, and refuses to run while other sessions are connected. Stop
the writers (docker compose stop api worker) or pass --terminate-connections. list exits 1
when the newest set is stale. Operations → Backups has the whole
sequence and every flag.