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. Add PUBLIC_STORAGE_ORIGIN when uploads go to a bucket |
| 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 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 installers
# 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.pngTwo 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. PUBLIC_WEB_URL is where external round trips return to,
because the webview’s own origin (tauri.localhost) is not an address any provider can redirect
to. With it unset those buttons are hidden. 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.
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. - 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 --drillThe 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.