For the agent reading this: You are running on the always-on Linux box. Work through these phases with the user, one at a time. Confirm each phase succeeds before moving on. Stop and ask whenever a value is user-specific (domain name, ports, folders). Never invent or commit secrets.
This guide turns a fresh Ubuntu install into a secure, internet-reachable workspace using Syncthing, Cloudflare Tunnel, and Caddy.
Placeholders — replace everywhere they appear:
example.com→ the user's Cloudflare-managed domain<user>→ the box's Linux username- ports like
3001,6006→ the user's actual dev-server ports
sudo apt update && sudo apt -y upgrade
# Automatic security updates
sudo apt -y install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
# Basic tools
sudo apt -y install curl git tmux ufwSSH keys, not passwords. Ensure the user's public key is in
~/.ssh/authorized_keys, then disable password auth:
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl restart sshThe firewall (
ufw) can stay default-deny for inbound — the Cloudflare Tunnel is outbound only, so you do not need to open any ports for remote access.
✅ Checkpoint: sudo apt upgrade is clean and SSH key login works.
Install and run Syncthing as a per-user service so it survives reboots.
sudo apt -y install syncthing
# Run as the logged-in user, enabled on boot
sudo systemctl enable --now syncthing@<user>.service
systemctl status syncthing@<user>.serviceThe Web UI binds to 127.0.0.1:8384 (localhost only — good). The user will reach
it from the Mac via an SSH tunnel (covered in the Mac guide). To get the box's
Device ID for pairing:
syncthing --device-idHand this Device ID to the Mac setup. In the UI, create/share a folder (e.g.
~/remote-projects) — the Mac will accept the share on its side.
Exclude noise. Add a .stignore in each synced folder:
node_modules
.git
dist
build
*.log
.DS_Store
✅ Checkpoint: Syncthing service is active (running) and you have the Device ID.
Install cloudflared and authenticate it to the user's Cloudflare account.
# Install (Debian/Ubuntu)
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb
# Log in — opens a browser URL; user picks the example.com zone
cloudflared tunnel loginCreate a named tunnel (call it something memorable like ai-box):
cloudflared tunnel create ai-box
# note the Tunnel UUID and the credentials JSON path it printsWrite the config at /etc/cloudflared/config.yml. The wildcard web ingress
means future apps need no tunnel edits — just DNS + Caddy:
tunnel: ai-box
credentials-file: /etc/cloudflared/<TUNNEL-UUID>.json
ingress:
# SSH route — lets the Mac reach the box's sshd through Cloudflare Access
- hostname: "ssh-dev.example.com"
service: ssh://localhost:22
# All web apps -> Caddy on :80, which routes by hostname to the right port
- hostname: "*.example.com"
service: http://localhost:80
- service: http_status:404Install it as a system service:
sudo cloudflared service install
sudo systemctl enable --now cloudflared
systemctl status cloudflaredAdd the SSH DNS route (web routes come per-app later):
cloudflared tunnel route dns ai-box ssh-dev.example.comProtect everything with Cloudflare Access. In Cloudflare Zero Trust → Access → Applications, add
ssh-dev.example.com(self-hosted / SSH) and your web hostnames, each with a policy allowing only the user's email. Without this, these hostnames are public.
✅ Checkpoint: systemctl status cloudflared is running; the SSH route resolves.
sudo apt -y install debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt -y install caddyReplace /etc/caddy/Caddyfile. Caddy only serves plain HTTP on :80 because
Cloudflare already terminates HTTPS at the edge:
{
# Cloudflare handles public HTTPS; Caddy serves local plain HTTP on :80.
auto_https off
}
# The http:// prefix forces Caddy to listen on :80 (where the tunnel forwards).
http://review-dev.example.com {
reverse_proxy localhost:3001
}Gotcha:
auto_https offalone still makes Caddy bind:443. Thehttp://prefix on each site address is what forces plain HTTP on:80.
Validate and reload:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy✅ Checkpoint: caddy validate says Valid configuration and the service reloads.
This is the loop you'll run for every new dev server. Example: an app on :6006
(e.g. Storybook) at storybook-dev.example.com.
1. Run the dev server bound to all interfaces so Caddy can reach it:
cd ~/remote-projects/<project>
npm run dev -- --host 0.0.0.0 --port 6006For Vite, also allow the public Host header in vite.config.js:
export default {
server: { host: '0.0.0.0', port: 6006, allowedHosts: ['.example.com'] },
}2. Add the DNS route (points the hostname at the tunnel):
cloudflared tunnel route dns ai-box storybook-dev.example.com3. Add a Caddy block to /etc/caddy/Caddyfile:
http://storybook-dev.example.com {
reverse_proxy localhost:6006
}4. Reload Caddy:
sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy5. (Recommended) Protect it in Cloudflare Zero Trust → Access → Applications:
add storybook-dev.example.com with a policy allowing only the user's email.
Visit https://storybook-dev.example.com. No tunnel restart needed thanks to
the wildcard ingress.
Keep a port map somewhere (a comment in the Caddyfile or a
PORTS.md) so URLs stay stable:3001 → review,6006 → storybook, etc.
Background processes die with your session. Run dev servers and agents inside
tmux (or a systemd unit for permanent ones):
tmux new -s review
cd ~/remote-projects/review && npm run dev -- --host 0.0.0.0 --port 3001
# detach: Ctrl-b then d | reattach: tmux attach -t reviewThis is what makes "close the Mac, work keeps running" actually true.
# Cloudflare Tunnel
systemctl status cloudflared
sudo systemctl restart cloudflared # after editing /etc/cloudflared/config.yml
journalctl -u cloudflared -f
# Caddy
sudo systemctl reload caddy # after editing the Caddyfile
journalctl -u caddy -f
# Syncthing
systemctl status syncthing@<user>.service- TLS handshake fails at edge → hostname is two levels deep (
*.dev.*), not covered by free SSL. Use a single-level<app>-devname. - 502 / can't reach app → dev server not running or wrong port. Check
curl http://localhost:<port>on the box. - Vite "host not allowed" (403) → add
allowedHosts: ['.example.com']. - Caddy not answering on :80 → make sure each site address has the
http://prefix. - App reachable but unprotected → you forgot the Cloudflare Access policy.
➡️ Once the box is up, set up the Mac: setup-mac.md.