remote-setup · git:20261031.6b32d79 · 2026-10-31 · sha256 f5892f504b344afc
remote-setup git:20261031.6b32d79B
Immutable. This exact content never changes and is served at /api/v1/blob/f5892f504b344afc.
---
name: remote-setup
description: Use when setting up Niles phone access through a Tailscale relay, including Caddy, host tags, shared push and Wake-on-LAN. Fresh installs only; not for migrating existing remote deployments or configuring project setups.
---
# Remote setup
Set up an always-on relay that also runs Niles. The request path is phone → Tailscale
Serve HTTPS → localhost Caddy → each Niles host's tailnet IPv4 listener. Serve cannot
proxy directly to other tailnet IPs: its dialer bypasses Tailscale routes. No CORS,
Funnel, public listeners or extra authentication headers are needed.
## Inventory before changing anything
1. Collect the relay and host SSH/access details, OS users running Niles, host display
names and routing IDs for the manifest, permitted phone users, and whether push/WoL
are wanted. For push collect a mailto: or https: contact URI. Include the relay as
a host. For WoL collect target MACs and LAN broadcasts from the operator/network.
Ask only for information you cannot discover through authorized access.
2. Read the deployment's existing config and service state. Inspect
`tailscale serve status --json`, Funnel status, Caddy configuration and listeners,
`~/.niles/config.yaml`, and Niles user services on every machine.
Existing remote deployments need a separate migration plan: stop and report the
conflict instead of overwriting services, Serve mounts, push keys or tailnet policy.
An occupied Serve root, chosen port or service path is also a conflict, even if unrelated.
Preserve unrelated config. Do not use `tailscale serve reset`.
3. Verify `type -a niles tailscale`, `niles --version`, `niles remote --help`,
`tailscale version --daemon --json`, `tailscale status --json`, and
`tailscale ip -4` on each machine. Require a remote-enabled Niles binary, matching
Tailscale CLI/daemon versions >=1.96.2, a running authenticated daemon, and stable
tailnet IPv4 addresses. Record the relay's DNS name, Self.ID and IPv4; confirm its
stable node ID with `tailscale whois --json <relay-tailnet-ip>` → Node.StableID.
Do not use a node key, machine key, DNS name or IP as `remote.relay_node`.
4. On the relay enable MagicDNS and HTTPS certificates in the tailnet DNS settings.
Verify its DNS name is listed in CertDomains. The phone joins the same tailnet
as a user-owned device: tagged clients have no Serve login header.
Use open-source Homebrew tailscaled on macOS. Check the LaunchDaemon's PATH
includes the directory containing the tailscale CLI; Homebrew service regeneration
can undo this. Check Tailscale operator privileges for the service account; if
needed, give the operator the exact `sudo tailscale set --operator=<user>` fix.
Report privileged prerequisites that authorized access cannot complete.
5. Install matching remote-enabled Niles binaries on all hosts and built PWA assets
on the relay. Use the release's `niles-web.tar.gz` with
`cargo install niles --locked --version <version> --features remote`, or a trusted
checkout's `scripts/install-remote.sh` (Node 24, pnpm 10.8.1, Rust 1.89+, OpenSSL).
Confirm `index.html` is at the asset root. Serve only this dedicated built directory
plus hosts.json; never serve a checkout, home directory or `~/.niles/`.
Install Caddy on the relay and record `caddy version`.
## Restrict the tailnet
6. Merge tags and grants into the existing tailnet policy. Substitute actual owners
and users; this fragment is not a replacement policy:
```json
{
"tagOwners": {
"tag:niles-relay": ["autogroup:admin"],
"tag:niles-host": ["autogroup:admin"]
},
"grants": [
{"src": ["you@example.com"], "dst": ["tag:niles-relay"], "ip": ["tcp:443"]},
{"src": ["tag:niles-relay"], "dst": ["tag:niles-host"], "ip": ["tcp:8765"]},
{"src": ["tag:niles-relay"], "dst": ["tag:niles-relay"], "ip": ["tcp:8765"]}
]
}
```
Assign only the relay tag to the relay node; assign the host tag to other hosts.
The third grant lets the relay reach its own backend. Adapt ports if needed.
Inspect all existing grants/ACLs: permissions are additive, so a broad allow rule defeats these restrictions. Preserve needed
SSH/admin access; verify policy tests permit chosen phone users on relay:443 and
relay on every backend port, and deny unwanted users/devices. Apply tags through
the admin console or `tailscale set --advertise-tags=...`, preserving existing tags.
Recheck the relay's stable ID after tagging. Tags control network reachability;
the backend additionally pins one exact relay node, not every node with its tag.
Tailnet policy reference: https://tailscale.com/docs/reference/syntax/policy-file
## Configure hosts and push
7. Merge this into each host user's `~/.niles/config.yaml`, including the relay:
```yaml
remote:
relay_node: nYOUR_RELAY_STABLE_NODE_ID
```
`remote.allowed_users` no longer exists. Every login admitted by Serve and the
tailnet policy has shell authority on every configured host. The backend binds
`tailscale ip -4`, not localhost; Caddy must connect to that address even for the
relay's own backend so WhoIs sees the relay node.
8. For push, generate one key with
`niles remote init --subject mailto:you@example.com` on the relay as the Niles user.
Securely copy that same `~/.niles/remote/vapid.json` to every host user's same path.
Keep its parent directory mode 0700 and file mode 0600, owned by the service user.
Compare files without printing private contents. Do not generate independent keys,
overwrite an existing key, put keys in the web root, or copy subscriptions.
If push is declined, skip initialization and report push as unconfigured.
9. If WoL is requested, keep the relay awake on the targets' LAN. Enable WoL in
each target's firmware and OS, determine the correct NIC MAC and subnet broadcast, and add this
server-side map only on the relay, alongside its relay_node:
```yaml
wake_hosts:
desktop:
mac: "01:23:45:67:89:ab"
broadcast: 192.168.1.255
```
IDs must match hosts.json. Invalid targets stop backend startup. No MAC or
broadcast address belongs in hosts.json, JavaScript or a phone request.
If a target cannot support WoL, leave it out and report that limitation.
10. Create a user service for `niles remote --port 8765` on each host, using the
absolute binary path and an explicit PATH containing tailscale, tmux and the
agent CLIs. Preserve the user's HOME and credential environment; shell startup
files are not loaded. On Linux use `~/.config/systemd/user/niles-remote.service`,
with After=network-online.target, Restart=on-failure, RestartSec=5 and
WantedBy=default.target, then daemon-reload and
enable --now; enable user lingering if it must survive logout. On macOS use
`~/Library/LaunchAgents/dev.niles.remote.plist`, Label=dev.niles.remote,
ProgramArguments as an array, RunAtLoad=true, KeepAlive=true and stdout/stderr logs,
then `launchctl bootstrap gui/<uid> <plist>`. Escape paths for the service format;
validate with systemd-analyze verify or plutil -lint before loading.
Create log directories first. Check startup logs and listener address. Restart after config or key changes.
## Configure the relay origin
11. Write hosts.json in the built PWA root. IDs are unique, match
`[A-Za-z0-9][A-Za-z0-9_-]*`, and stay stable; names are nonempty display text.
Choose lowercase IDs: Caddy path matching cannot distinguish IDs by case alone.
Example (replace with the inventory):
```json
{"hosts":[{"id":"relay","name":"Relay"},{"id":"desktop","name":"Desktop"}]}
```
12. Write a dedicated Caddyfile outside the web root, e.g.
`~/.config/niles/Caddyfile`. Replace example IPs with the recorded tailnet IPv4s,
and extend one explicit handle_path per manifest host. NILES_WEB_DIR must be an
absolute path to the built PWA root, set in Caddy's service environment:
```caddyfile
{
admin off
auto_https off
}
http://127.0.0.1:8080 {
bind 127.0.0.1
handle /api/hosts/*/wake {
reverse_proxy 100.64.0.1:8765
}
handle_path /h/relay/* {
reverse_proxy 100.64.0.1:8765
}
handle_path /h/desktop/* {
reverse_proxy 100.64.0.2:8765
}
@unknown path /api /api/* /h /h/*
handle @unknown {
respond 404
}
handle {
root * "{$NILES_WEB_DIR}"
header /hosts.json Cache-Control no-store
file_server
}
}
```
handle_path strips /h/<id>, retaining /api/... and query strings. The wake route
retains its full path and always targets the relay. Caddy preserves Serve's
Tailscale-User-Login header by default; do not synthesize or append an identity.
Caddy streams text/event-stream automatically. Use its normal HTTP transport,
not a userspace Tailscale transport. Reference:
https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
13. With NILES_WEB_DIR set, run `caddy validate --config <Caddyfile> --adapter caddyfile`.
Install a separate Caddy service as the relay user, with absolute binary/config
paths and that environment, using the same OS service manager as step 10.
Check that port 8080 listens only on 127.0.0.1. Start Caddy before Serve.
Run `tailscale serve --bg --https=443 http://127.0.0.1:8080` as the configured
operator. Check Serve status and confirm Funnel is disabled. Serve owns TLS and
strips forged identity headers; Caddy is reachable only locally.
Reference: https://tailscale.com/docs/features/tailscale-serve
## Verify and hand back
14. From an allowed user-owned tailnet device, require 200 HTML at the relay HTTPS
root and a valid, noncached hosts.json. Fetch every `/h/<id>/api/sessions` and
`/h/<id>/api/projects`; require 200 JSON with api_version=1, the corresponding
arrays and Cache-Control: no-store. Check an SSE feed
emits its initial snapshot promptly. Confirm an unknown /h/ ID returns 404.
A direct request to a backend from a different tailnet node, even with a forged
Tailscale-User-Login, must be denied by policy or return 403. Also test an unwanted
user cannot access relay HTTPS. Do not treat local spoofed-header curls as proof
of end-to-end identity. On failures inspect service logs, policy and proxy paths;
stop and report the failed layer rather than claiming installation succeeded.
15. Open the relay URL on the phone, send a harmless message and verify it at the desk.
Install/Add to Home Screen (iOS push requires 16.4+ and the installed app). With all
hosts awake and push configured, Enable notifications must register one shared
subscription through every host; fix any named failure and retry. Check each public VAPID key matches,
trigger a notification per host, and confirm it opens the correct host/session.
Rerun registration after adding hosts. Opening notifications needs Tailscale.
16. If WoL is configured, test `POST /api/hosts/desktop/wake` through relay HTTPS with
Content-Type: application/json and body `null`; require 204, then check actual
wake/reachability and the phone's asleep-host Wake button. A sent packet is not
proof of waking; unknown IDs return 404. Never accept a phone-supplied destination.
17. Record exactly which mounts, files and services this run created. Report the relay
URL, host/route inventory, changed files and policy, service/log
locations, restart commands, verification results and anything untested. For
recovery remove only files/services and the Serve mount created in this run
(`tailscale serve --bg --https=443 --set-path=/ off`), after rechecking ownership;
preserve keys, config and
unrelated mounts. Upgrade binary and PWA together and close/reopen app windows.