Serve from a laptop over Tailscale
bun run serve runs the app on one machine, a laptop or a Mac mini, without Docker: the API, the
node build of the web app and a small proxy that puts both behind one address. It keeps them
running, and Tailscale publishes that address over HTTPS to your own
devices, phones included:
https://<machine>.<tailnet>.ts.netThis page sets it up once: the machine, Tailscale, a build for that address, the checks, the start, and a unit that starts it at login or at boot.
Serve runs on macOS and Linux only. Windows cannot send a process SIGTERM, so there serve could stop what it runs only by killing it, without the drain of Stopping, and it has no unit that starts it at login.
What bun run serve runs
Section titled “What bun run serve runs”your devices, and this machine's browser │ HTTPS, HTTP/2, a Tailscale certificate ▼tailscale serve │ http://127.0.0.1:8787 ▼origin proxy (bun run serve) ├─ /api/* /rpc/* /healthz /readyz → API 127.0.0.1:3000 └─ everything else → web server 127.0.0.1:3001/uploads/*, the bucket of the local storage driver, goes to the API too.
| Process | What runs | Listens on |
|---|---|---|
| API | apps/api/src/index.ts |
127.0.0.1:3000 |
| Worker | apps/api/src/worker.ts, the job worker as a process of its own (WORKER_ENABLED=false on the API), as Compose runs it |
nothing |
| Your processes | what scripts/serve/product.ts returns (below) |
yours |
| Web server | the node build in apps/web/build-serve/ |
127.0.0.1:3001 |
| Origin proxy | inside the launcher | 127.0.0.1:8787 |
Everything listens on 127.0.0.1 only, so nothing on the Wi-Fi you share can reach it, and
Tailscale is the only way in. A process that exits is started again after a second, then after
two, four and so on up to 30 seconds. On a Mac, serve also keeps the machine awake while it runs.
The proxy does what the Compose stack’s Caddy does in single-origin mode
(Serve anywhere): it routes, caps request bodies, compresses and adds
the security headers, from one table (scripts/serve/single-origin.ts) that a test compares with
infra/caddy/single-origin.Caddyfile. What the Compose stack has and serve does not: the
documentation at /docs, the marketing site and the backup service. Back the database up yourself
with pg_dump, as Operations describes. Nor does the proxy carry
WebSockets, which the kit does not use: event streams pass through, but a request to upgrade the
connection reaches its upstream as a plain request.
The proxy caps request bodies as Caddy does, 1 MB by default, and lets the prefixes of
apps/api/src/product-body-limits.ts through at the caps declared there, as the API does.
/uploads/* it caps at MAX_UPLOAD_BYTES plus 1 MiB, the API’s own cap, where Caddy allows 32 MB.
A body without a length under a cap of 32 MiB or less is read whole before the API sees it; under a
larger one it streams, counted, and is cut off at the cap. A backslash in a path is a character, as
Caddy reads it: /\api/x goes to the web server.
Serve and bun run dev:app cannot run at once on their default ports: both put the API on 3000.
Pick other ports for one of them (--api-port, below).
Before you start
Section titled “Before you start”-
Install Bun and a native Postgres 17, and start Postgres. On a Mac with Homebrew:
Terminal window brew install postgresql@17 oven-sh/bun/bunbrew link --force postgresql@17brew services start postgresql@17The paid edition needs the pgvector extension there too (
brew install pgvector). -
Create the database with the C locale, which compares names byte by byte and sorts faster:
Terminal window createdb -h 127.0.0.1 -T template0 --locale=C <name> -
From the repository root, install the dependencies and write the
.envfiles, then pointDATABASE_URLin the root.envat the database (postgres://<you>@127.0.0.1:5432/<name>):Terminal window bun installbun run setupbun run doctor -- --database=externalbun run db:migrate
1. Install Tailscale
Section titled “1. Install Tailscale”Install Tailscale on this machine and on each device that should reach it, and sign in with the same account, or invite the other people to your tailnet.
On a Mac there are two variants, and both can serve: the standalone app, whose Settings → CLI
integration installs the tailscale command, and the Mac App Store app, whose command is
/Applications/Tailscale.app/Contents/MacOS/Tailscale. Install one, not both. Either connects only
while you are logged in on the Mac.
Once, in the admin console’s DNS page:
- Turn on MagicDNS, which gives the machine its
<machine>.<tailnet>.ts.netname. - Under HTTPS Certificates, choose Enable HTTPS.
Pick the name first. A Tailscale certificate records the machine’s name and your tailnet’s name in the public Certificate Transparency logs, where anyone can read them. Rename the machine now if you want to (Machines → the machine → Edit machine name): the address is built into the app in step 3, and changing it later means building again and signing in again everywhere.
2. Let only your devices reach it
Section titled “2. Let only your devices reach it”Tailscale’s default policy lets every device of the tailnet reach every other. In Access
controls, admit only your people, and only on HTTPS. bun run serve tailscale prints a policy
of this shape, named after your product; replace the addresses and the machine’s tailnet IP:
{ "groups": { "group:<product>": ["you@example.com", "colleague@example.com"] }, "hosts": { "<product>-host": "100.x.y.z" }, "acls": [ { "action": "accept", "src": ["group:<product>"], "dst": ["<product>-host:443"] }, { "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self:*"] } ]}The second rule keeps each person’s own devices reaching each other. Merge these into a policy you already have rather than replacing it.
3. Build for the address
Section titled “3. Build for the address”bun run serve build --origin https://<machine>.<tailnet>.ts.netThis builds the web app with the node adapter into apps/web/build-serve.next/ and writes a marker
into it, .serve-build.json, with the address it was built for, the commit and whether the
checkout had uncommitted changes. The address is built into the page’s Content-Security-Policy, so
the build is made for one address. It is not apps/web/build/, which bun run build and
test:e2e rewrite.
Building while serve runs is safe: the running web server keeps the build it started with, and the
next start, or a restart, moves the new one into apps/web/build-serve/. build prints the
command that restarts the serve you have running. Build again after you update the code or rename
the machine; start warns when the build is from another commit or for another address.
4. Check, then start
Section titled “4. Check, then start”bun run serve preflightbun run serve startThe other commands take the address from the latest build; --origin names another.
preflight changes nothing. It reports what in .env contradicts serving one address, whether
there is a build and what it was built for, your product’s own checks, the ports, and what the API
would refuse in the environment serve gives it: it runs the API’s own validation of its environment
in a child process, so a production setup that cannot start is refused here, in the API’s words,
before anything runs.
start makes the same checks and stops on any error. Then it moves a waiting build into place,
applies the migrations (--no-migrate skips them) and starts the processes in the order of the
table above. It waits for the API to answer /healthz and checks that it answers on 127.0.0.1
only, then starts the rest, waits for the web server, and opens the proxy.
kill with the launcher’s process id (status shows it), or stopping the unit below, stops the
proxy first, then the web server, your processes, the worker and the API, one after the other, each
given 15 seconds. Ctrl+C in the terminal reaches every process at once: each shuts down on its own,
and serve waits for all of them.
Logs go to ~/Library/Logs/<product>/ on a Mac and to ~/.local/state/<product>/logs/ elsewhere
($XDG_STATE_HOME when set), one file per process (api.log, web.log, …), each kept to 10 MB
plus three older files; --logs <folder> moves them. Every start makes that folder and serve’s
files in it readable by your account alone, also when you created the folder yourself: in
development mode the API’s log holds the emails it would have sent, sign-in links included. A
folder serve cannot make private (/tmp, one shared with others) is refused before anything
starts.
Options, when a port is taken: --port (the proxy, 8787), --api-port (3000) and --web-port
(3001). Pass the same options to status, and to launchd or systemd, which write them into the
unit.
The environment each process gets
Section titled “The environment each process gets”Serve reads the root .env and apps/web/.env itself, and gives each process an environment of its
own; bun run serve env prints the rows it sets and why:
- The API gets the root
.envwith the process environment over it, then:API_URLandWEB_URLset to the address,COOKIE_DOMAINempty (one address, a host-only cookie),HOST=127.0.0.1,PORT, andTRUST_PROXY=true, so its rate limits count each device by the address the proxy passes on.NODE_ENVstays as.envhas it,developmentwhen unset.WORKER_ENABLED=false, since the worker runs beside it. - The worker gets the same, without
WORKER_ENABLED. - The web server gets only the rows
apps/web/src/env.tsdeclares, from.env,apps/web/.envand the process environment, plusBODY_SIZE_LIMITandPATH,HOME,TMPDIR,LANG,LC_ALLandTZ. Then:NODE_ENV=production, as the kit’s image runs the built app,PUBLIC_API_URLandPUBLIC_WEB_URLset to the address,API_URL=http://127.0.0.1:3000, adapter-node’sPROTOCOL_HEADER,ADDRESS_HEADERandXFF_DEPTH=1(so a page’s origin and the visitor’s address come from the proxy),HOST,PORTandSHUTDOWN_TIMEOUT=10.
Why the web server sees so little. The libraries bundled into its server read process.env
directly, whatever src/env.ts declares: Better Auth’s code reads BETTER_AUTH_SECRET. Given the
whole .env it would hold the database URL and every secret of the API. A row your product declares
in src/env.ts reaches it with no list to edit.
Why API_URL differs. To the API, API_URL is its public origin: Better Auth’s base URL, the
links in emails. To the web app’s server it is the API’s internal address for server-side loads, so
they skip the proxy. One .env cannot hold both, so serve sets each.
Each device’s address. The proxy passes on the address of the device a request came from, and
the web server signs it with FORWARDED_CLIENT_SECRET on the calls it makes to the API while it
renders a page, so the API’s rate limits count each device rather than the web server. The API reads
the secret from .env, the web server from apps/web/.env over .env, and bun run setup writes
one value to both. For any address but localhost, preflight warns when the secret is missing or
the web server’s is empty: every page load would then count against the web server’s own address.
It warns on any address when the two differ, as bun run doctor does.
Development mode or production. In development mode (NODE_ENV unset or development), Better
Auth does not slow down repeated sign-in attempts, and emails go to the API’s log instead of an
inbox. preflight says so for any address but localhost. The access policy of step 2 keeps
everyone else out. With NODE_ENV=production the API needs everything production needs, an email
provider included, and preflight names what is missing.
Who can sign up. In either mode, anyone who reaches the address can create an account unless
ALLOW_SIGN_UP=false, and preflight warns about it for any address but localhost. Create your
own account first, then set it in .env and start again.
An address you invite can still sign up, from its invitation’s link.
5. Publish it to your tailnet
Section titled “5. Publish it to your tailnet”Once, on the machine:
tailscale serve --bg http://127.0.0.1:8787Open https://<machine>.<tailnet>.ts.net on any of your devices. Tailscale keeps this setting
across restarts. To see it or remove it:
tailscale serve statustailscale serve --https=443 offbun run serve tailscale prints these commands, the admin-console steps and the access policy.
Never
tailscale funnelthis address while it runs in development mode or anyone who reaches it can sign up. Funnel publishes it to the whole internet.
6. Start at login or at boot
Section titled “6. Start at login or at boot”Serve prints the unit and the commands that install it; it writes nothing outside the repository.
The unit is on the standard output and the steps on the standard error, so a redirect captures the
unit alone. Stop a start running in a terminal before you install one: both want the same ports.
macOS, a LaunchAgent that starts at login and restarts serve if it stops:
bun run serve launchd > ~/Library/LaunchAgents/<label>.plistThe printed steps name the label (the Tauri shell’s identifier with serve as its last part:
com.example.desktop gives com.example.serve) and load it with launchctl bootstrap. Serve’s own
messages then go to launcher.log in the logs folder. A LaunchAgent runs while you are logged in:
after a restart, log in once, or turn on automatic login, before other devices can reach it.
If your product reads removable or network volumes, give the bun binary itself Full Disk
Access (System Settings → Privacy & Security → Full Disk Access): macOS asks on behalf of
the binary launchd starts, and nobody is there to answer. The printed steps name the path, which
changes when Bun is updated.
Linux, a systemd user service:
bun run serve systemd > ~/.config/systemd/user/<product>-serve.servicesystemctl --user daemon-reloadsystemctl --user enable --now <product>-serveloginctl enable-linger "$USER"The last line starts your user services at boot and keeps them running after you log out. The
unit stops serve alone first (KillMode=mixed), so it stops its processes one after the other, and
gives it the time all of them need.
7. Staying awake
Section titled “7. Staying awake”On a Mac, serve holds caffeinate -ims while it runs: the machine does not go to sleep on its own
and its disks keep spinning, as long as it is on power. --no-keep-awake turns that off. Closing
the lid still puts a laptop to sleep, unless it is on power with an external display, and staying
up on battery is your choice in the energy settings (pmset); serve never changes them.
On Linux, keeping the machine awake is yours to arrange: systemd-inhibit, or logind’s
HandleLidSwitch and idle settings in /etc/systemd/logind.conf.
While the machine sleeps, your devices show the app’s “Can’t reach the server” page.
8. Status and troubleshooting
Section titled “8. Status and troubleshooting”bun run serve statusIt shows the launcher and each process with its uptime and restarts, the build it runs, the health
checks through the proxy, whether the API answers anywhere but 127.0.0.1 (it must not), and
whether the address resolves and answers from this machine. It exits 1 when anything is wrong.
bun run doctor -- --served checks the setup without starting anything; for a serve started with
--logs <folder>, pass it the same --logs <folder>, or it looks for one in the default folder.
“Bad gateway: the API is not answering” (or the app). The proxy is up but that process is not:
it is probably restarting. A request without a body waits up to 15 seconds for it. If it stays,
status shows how often it restarted, and the end of its log says why.
“Can’t reach the server” in the app. The machine is asleep, serve is stopped, or the device lost its tailnet connection.
A Content-Security-Policy violation in the browser console, or signed-in pages that cannot reach
the server although status is fine. The app was opened through another address than the one it
was built for (an IP address, 127.0.0.1, localhost). Use the .ts.net address, and build again
if start warned that the build is for another one.
preflight says a port is in use. Another program, or an earlier serve, holds it:
lsof -nP -iTCP:<port> -sTCP:LISTEN names it. If an earlier serve was killed without stopping its
processes, they may still run: stop them, or choose other ports.
start stops with “the API answers on … not only on loopback”. The API can be reached from
your network although serve gives it HOST=127.0.0.1, so something in its start-up ignores HOST.
Trusting the client addresses the proxy passes on would then be unsafe: anyone there could choose
the address the API limits by. As a stopgap, --no-trust-proxy starts anyway, behind a firewall,
with one rate limit for every device.
A procedure called with curl answers 403 CSRF_TOKEN_MISMATCH. Calls under /rpc/ carry the
header x-csrf-token: orpc, which the app’s own client adds: add -H 'x-csrf-token: orpc' (and
your session cookie, for anything but a health check). The REST routes under /api/v1/ do not ask
for it.
What tailscale serve does with a request
Section titled “What tailscale serve does with a request”Serve depends on a few details of how tailscale serve forwards a request. They were checked in
Tailscale’s documentation and source (its main branch in October 2026):
- Paths are forwarded unchanged when mounted at
/. A proxy mounted at a path has that prefix stripped (--set-path /apiturns/api/xinto/x), so serve is mounted at/only and does all routing itself, where tests see it. Source:serveWebHandlerin ipn/ipnlocal/serve.go. X-Forwarded-Forcarries the device’s tailnet address, and only that. Its reverse proxy removes anyForwardedandX-Forwarded-*headers a client sent, then setsX-Forwarded-Forto the connecting device’s address andX-Forwarded-Proto: https. Serve’s proxy passes that one address on. Source:addProxyForwardedHeadersin serve.go, and Go’s httputil.ReverseProxy.- Event streams are not held back. Go’s reverse proxy flushes a
text/event-streamresponse, or one of unknown length, after every write. Source:flushIntervalin reverseproxy.go. - Devices get HTTP/2, and the proxy behind it gets HTTP/1.1. Source:
serveTLSNextProtosin ipn/ipnlocal/cert.go. - The command.
tailscale serve --bg http://127.0.0.1:8787publishes the proxy over HTTPS on port 443 and keeps it across restarts;statusshows it,--https=443 offremoves it andresetremoves everything. This is the syntax since version 1.52. Sources: kb/1242 and cmd/tailscale/cli/serve_v2.go. - Certificates need MagicDNS and HTTPS certificates switched on, and the names are published in Certificate Transparency logs. Source: kb/1153.
Your product’s part
Section titled “Your product’s part”scripts/serve/product.ts is yours; kit releases never change it. Its four exports are empty until
you fill them, and serve checks what they return: start refuses on anything wrong there, and
preflight and bun run doctor -- --served list it.
| Export | What serve does with it |
|---|---|
productApiPathPrefixes |
Path prefixes outside /api/ that the proxy sends to the API, such as '/media/', for a raw route of yours (mountProductRoutes). A route under /api/ needs nothing here. The Compose stack’s Caddyfile routes the kit’s prefixes only, so keep raw routes under /api/ where you can. A prefix the API refuses for a quiet path or a body cap (one that covers or sits under a kit route) is refused here too. |
productServedEnv(context) |
Rows of your own for each process, laid over what serve passes on from .env. A row serve sets itself cannot be replaced. |
productServePreflight(context) |
Checks of your own. An error stops start. Name rows, never print their values. |
productServedProcesses(context) |
Processes of yours that serve runs, restarts and stops: started after the API, before the web server, stopped in reverse. Each gets exactly the environment you give it, and its own log file. |
context holds the repository root, the address, the ports, the root .env with the process
environment over it (base) and the Bun that runs serve; productServedProcesses also gets each
kit process’s environment (env). The file imports ./types and nothing else of serve’s, since
serve imports it: ./types also holds normalizeOrigin, which reduces a URL to its origin or
answers null, and isLoopbackOrigin, true for an address on this machine alone (localhost,
127.0.0.1, [::1]), which no other device reaches. A row that must be the address, a check that
.env agrees, and a process of your own:
import { join } from 'node:path';import { normalizeOrigin, type ProductServedEnv, type ServeContext, type ServedEnvironments, type ServedProcess, type ServeIssue,} from './types';
export const productApiPathPrefixes: readonly string[] = [];
export function productServedEnv({ origin }: ServeContext): ProductServedEnv { return { api: { MEDIA_ORIGIN: origin } };}
export function productServePreflight({ base, origin }: ServeContext): ServeIssue[] { const media = base.MEDIA_ORIGIN?.trim(); if (!media || normalizeOrigin(media) === origin) return []; const message = 'MEDIA_ORIGIN must be empty or the served address.'; return [{ level: 'error', name: 'MEDIA_ORIGIN', message }];}
export function productServedProcesses({ root, bun,}: ServeContext & { env: ServedEnvironments }): ServedProcess[] { return [ { name: 'thumbnails', cmd: [bun, '--no-env-file', 'src/index.ts'], cwd: join(root, 'services/thumbnails'), env: { PORT: '3005', HOST: '127.0.0.1' }, readyUrl: 'http://127.0.0.1:3005/healthz', }, ];}The AI service is such a process, should you want serve to run it: cmd is
[join(root, 'services/ai/.venv/bin/uvicorn'), 'ai.main:app', '--host', '127.0.0.1', '--port', '8000'] with cwd services/ai, its env the rows of services/ai/.env (read it with
parseEnv from node:util), and readyUrl http://127.0.0.1:8000/health. Serve does not run it
by itself: it needs uv sync first, and models and keys of its own.
cmd[0] and cwd are absolute: nothing is looked up on PATH. A process name is lower case and
not one of the kit’s (api, worker, web, launcher, serve). Serve stops the process it
started and nothing else: a shell script or a package runner in cmd must pass SIGTERM on, or
exec the server, or what it started keeps running.
Why a proxy of its own
Section titled “Why a proxy of its own”A launcher of a few hundred lines needs no Caddy or Docker on the machine, keeps the routing in a
table its tests read, and supervises the processes too. Bun’s own servers had traps for a reverse
proxy, each pinned by a test in scripts/serve/proxy.test.ts; the decision record “Serving without
Docker” in docs/DECISIONS.md lists them.
bun run bench -- serve/proxy measures what the proxy costs: responses of 44 KB, 6 and then 32 at
a time, directly and through it. Its budget is 5,000 responses a second through the proxy and 1 ms
added at the median; on an Apple M5 Pro it serves about 20,000 a second and adds about 0.1 ms at 6
in flight.