Skip to content

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.net

This 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.

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).

  1. Install Bun and a native Postgres 17, and start Postgres. On a Mac with Homebrew:

    Terminal window
    brew install postgresql@17 oven-sh/bun/bun
    brew link --force postgresql@17
    brew services start postgresql@17

    The paid edition needs the pgvector extension there too (brew install pgvector).

  2. 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>
  3. From the repository root, install the dependencies and write the .env files, then point DATABASE_URL in the root .env at the database (postgres://<you>@127.0.0.1:5432/<name>):

    Terminal window
    bun install
    bun run setup
    bun run doctor -- --database=external
    bun run db:migrate

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:

  1. Turn on MagicDNS, which gives the machine its <machine>.<tailnet>.ts.net name.
  2. 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.

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.

Terminal window
bun run serve build --origin https://<machine>.<tailnet>.ts.net

This 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.

Terminal window
bun run serve preflight
bun run serve start

The 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.

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 .env with the process environment over it, then: API_URL and WEB_URL set to the address, COOKIE_DOMAIN empty (one address, a host-only cookie), HOST=127.0.0.1, PORT, and TRUST_PROXY=true, so its rate limits count each device by the address the proxy passes on. NODE_ENV stays as .env has it, development when 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.ts declares, from .env, apps/web/.env and the process environment, plus BODY_SIZE_LIMIT and PATH, HOME, TMPDIR, LANG, LC_ALL and TZ. Then: NODE_ENV=production, as the kit’s image runs the built app, PUBLIC_API_URL and PUBLIC_WEB_URL set to the address, API_URL=http://127.0.0.1:3000, adapter-node’s PROTOCOL_HEADER, ADDRESS_HEADER and XFF_DEPTH=1 (so a page’s origin and the visitor’s address come from the proxy), HOST, PORT and SHUTDOWN_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.

Once, on the machine:

Terminal window
tailscale serve --bg http://127.0.0.1:8787

Open https://<machine>.<tailnet>.ts.net on any of your devices. Tailscale keeps this setting across restarts. To see it or remove it:

Terminal window
tailscale serve status
tailscale serve --https=443 off

bun run serve tailscale prints these commands, the admin-console steps and the access policy.

Never tailscale funnel this address while it runs in development mode or anyone who reaches it can sign up. Funnel publishes it to the whole internet.

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:

Terminal window
bun run serve launchd > ~/Library/LaunchAgents/<label>.plist

The 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:

Terminal window
bun run serve systemd > ~/.config/systemd/user/<product>-serve.service
systemctl --user daemon-reload
systemctl --user enable --now <product>-serve
loginctl 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.

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.

Terminal window
bun run serve status

It 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.

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 /api turns /api/x into /x), so serve is mounted at / only and does all routing itself, where tests see it. Source: serveWebHandler in ipn/ipnlocal/serve.go.
  • X-Forwarded-For carries the device’s tailnet address, and only that. Its reverse proxy removes any Forwarded and X-Forwarded-* headers a client sent, then sets X-Forwarded-For to the connecting device’s address and X-Forwarded-Proto: https. Serve’s proxy passes that one address on. Source: addProxyForwardedHeaders in serve.go, and Go’s httputil.ReverseProxy.
  • Event streams are not held back. Go’s reverse proxy flushes a text/event-stream response, or one of unknown length, after every write. Source: flushInterval in reverseproxy.go.
  • Devices get HTTP/2, and the proxy behind it gets HTTP/1.1. Source: serveTLSNextProtos in ipn/ipnlocal/cert.go.
  • The command. tailscale serve --bg http://127.0.0.1:8787 publishes the proxy over HTTPS on port 443 and keeps it across restarts; status shows it, --https=443 off removes it and reset removes 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.

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.

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.