til

Caddy and Tailscale on reboot

Getting Caddy and Tailscale to serve multiple local apps on reboot without thinking about it.

I run Argus on my Mac Mini to manage AI coding sessions, and I need to reach its dashboard from my laptop while things are running. I also have a kanban board (a static HTML file) that I check on my phone. Both apps run on the Mini on different ports. I wanted each one at a clear, memorable URL—/jobs and /argus—accessible from any device on my tailnet, and I wanted it all to survive a reboot.

Tailscale is a mesh VPN built on WireGuard. You install it on your devices and they form a private network called a tailnet. Any device on your tailnet can reach any other device by name, with automatic TLS, without opening ports to the internet.

This builds on two earlier posts: Caddy as a quick file server and Caddy + Tailscale for remote file serving. This time I needed path-based routing to multiple backends, not just serving a single directory.

The problem #

Two things made this harder than it looks:

  1. Argus has a Node server for its API and WebSocket connections, plus a Vite-built SPA for the frontend. In production I don’t want to run a Vite dev server—I want to serve the built static files and proxy API requests to the Node server.
  2. The Vite build hardcodes asset paths at the root (/assets/index-xxx.js), not under the subpath (/argus/assets/...). So hitting /argus in the browser loads index.html, but every subsequent request for JS, CSS, API calls, and WebSocket connections arrives at a root-level path. You can’t just use a single /argus* handler in Caddy—you need separate handlers for /assets, /api, and /socket.io at the root.

The fix: let Caddy own the routing #

Caddy handles all the path-based routing on a single port. Tailscale just points at that one port.

The Caddyfile:

Caddyfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# Global options — apply to all sites
{
	# Only log warnings and errors; skip per-request access logs
	log default {
		level WARN
	}
}

# Direct local access to the kanban board on port 8080.
# No Tailscale involved — just http://localhost:8080
:8080 {
	root * "/Users/bitsbyme/Library/CloudStorage/GoogleDrive-dvhthomas@gmail.com/My Drive/Career/Job Search/Kanban"
	file_server
}

# Tailscale points here via: tailscale serve --bg 8777
#
# Why so many handlers instead of just /argus*?
#   The Vite build hardcodes asset paths at the root: /assets/index-xxx.js,
#   not /argus/assets/index-xxx.js. Same for /api/* and /socket.io/*.
#   So when the browser loads /argus and gets index.html, every subsequent
#   request (JS, CSS, API, WebSocket) arrives at a root-level path.
#   Each needs its own handler. The only way around this would be building
#   Vite with base: '/argus/', but that breaks local dev.
:8777 {
	# /jobs — static kanban board files.
	# handle_path strips /jobs so the file server sees / as root.
	handle_path /jobs* {
		root * "/Users/bitsbyme/Library/CloudStorage/GoogleDrive-dvhthomas@gmail.com/My Drive/Career/Job Search/Kanban"
		file_server
	}

	# /argus — entry point for the Argus dashboard.
	# Strips /argus prefix, then tries to serve the requested file
	# from the Vite build. Falls back to index.html for client-side
	# routing (React SPA).
	handle_path /argus* {
		root * /Users/bitsbyme/projects/code-orchestrator/client/dist
		try_files {path} /index.html
		file_server
	}

	# /assets — Vite's hashed build output (JS, CSS).
	# The built index.html references these at root level, e.g.
	# /assets/index-CB3iNx55.js, so they bypass the /argus handler.
	handle /assets/* {
		root * /Users/bitsbyme/projects/code-orchestrator/client/dist
		file_server
	}

	# /api and /socket.io — proxy to the Express server on port 5400.
	# The Argus client fetches these at root level, not under /argus.
	handle /api/* {
		reverse_proxy localhost:5400
	}
	handle /socket.io/* {
		reverse_proxy localhost:5400
	}
}

Port 8080 keeps the jobs board accessible locally. Port 8777 is what Tailscale sees. The /jobs handler strips the prefix and serves static files. The /argus handler strips the prefix and serves the Vite build output, falling back to index.html for client-side routing. Then /assets, /api, and /socket.io each get their own root-level handler because that’s where the browser actually requests them.

Tailscale setup is one command:

1
tailscale serve --bg 8777

That’s it. Tailscale terminates TLS and proxies everything to Caddy on 8777.

Surviving a reboot #

I keep the actual plist files and scripts in ~/tools alongside other local config, then symlink the plists into ~/Library/LaunchAgents/:

1
2
ln -s ~/tools/com.user.caddy.plist ~/Library/LaunchAgents/
ln -s ~/tools/com.user.argus.plist ~/Library/LaunchAgents/

This way the plists live somewhere I’ll actually find them again, not buried in a LaunchAgents directory I never look at.

The Caddy plist is straightforward—just run the binary with the config file:

com.user.caddy.plist

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.user.caddy</string>
    <key>ProgramArguments</key>
    <array>
        <string>/opt/homebrew/bin/caddy</string>
        <string>run</string>
        <string>--config</string>
        <string>/Users/bitsbyme/tools/Caddyfile</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/tools-caddy.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/tools-caddy.log</string>
</dict>
</plist>

Argus uses a build script that compiles the Vite frontend then starts the Node server. Rather than running the Vite dev server in production, Caddy serves the static files directly from client/dist—no Vite process needed:

argus-build.sh

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/bin/bash
set -e

# Set up PATH so launchd services find homebrew, ASDF, etc.
eval "$(/opt/homebrew/bin/brew shellenv)"
export PATH="/opt/homebrew/opt/libpq/bin:$PATH"
export PATH="$HOME/.local/bin:$PATH"
export PATH="$HOME/.asdf/shims:$PATH"

cd /Users/bitsbyme/projects/code-orchestrator

# Build the client (tsc + vite)
npm run build -w client

# Start the API server (port 5400)
exec /opt/homebrew/bin/npx tsx server/src/index.ts

And its plist:

com.user.argus.plist

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.user.argus</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/bitsbyme/tools/argus-build.sh</string>
    </array>
    <key>WorkingDirectory</key>
    <string>/Users/bitsbyme/projects/code-orchestrator</string>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/argus.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/argus.log</string>
</dict>
</plist>

You might notice the Argus plist has no EnvironmentVariables block for PATH. That’s deliberate—the build script sets up its own environment. I wrote up the saga of getting there separately.

Load them once:

1
2
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.caddy.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.argus.plist

Tailscale Serve with --bg persists its config across restarts automatically.

The result #

After a reboot, everything comes up on its own:

For development, npm run dev runs the Vite dev server on port 5173 with its own proxy to the API server—completely independent of the production setup.

Gotchas #

  • launchctl load caches the plist. If you edit a plist, you must launchctl bootout then bootstrap again—just restarting the process doesn’t re-read the file.
  • Vite builds hardcode root-level asset paths. You can’t serve a Vite SPA at a subpath with a single Caddy handle_path rule. You need separate handlers for the assets, API, and WebSocket paths that the built HTML references. The alternative—building with base: '/argus/'—would break local dev.

See Also

View page source