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