<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"><title>Fabien Schwob</title><link href="https://schwob.dev/" rel="alternate"/><link href="https://schwob.dev/feeds/all.atom.xml" rel="self"/><id>https://schwob.dev/</id><updated>2026-10-07T00:00:00+02:00</updated><entry><title>Turning a NUC into a Home Dev Server for Projects and AI Agents</title><link href="https://schwob.dev/blog/nuc-home-dev-server/" rel="alternate"/><published>2026-10-07T00:00:00+02:00</published><updated>2026-10-07T00:00:00+02:00</updated><author><name>Fabien Schwob</name></author><id>tag:schwob.dev,2026-10-07:/blog/nuc-home-dev-server/</id><summary type="html">&lt;p&gt;Omarchy (or Omaterm), herdr, Docker Compose, Caddy, Tailscale and Cloudflare Tunnel&lt;/p&gt;</summary><content type="html">&lt;p&gt;I wanted one small box at home that does three things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Runs my coding agents&lt;/strong&gt; around the clock, so they keep working when my laptop is closed and I can check on them from anywhere, including my phone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hosts every project's Docker Compose stack&lt;/strong&gt; at a proper internal URL with a real HTTPS certificate, such as &lt;code&gt;https://podium.nuc.example.dev&lt;/code&gt;, reachable only from my own devices.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Publishes a few selected services&lt;/strong&gt; on the public internet, such as a client demo, without opening a single port on my router.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;An Intel NUC (or any mini PC) is ideal for this: quiet, low power, always on. This article covers the whole setup, from OS install to the first project online.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The architecture in one picture&lt;/h2&gt;
&lt;p&gt;&lt;img alt="Architecture: laptop and phone reach the NUC over the private tailnet via ssh/herdr and https://*.nuc.example.dev. On the NUC, the Docker network &amp;quot;proxy&amp;quot; holds caddy, which routes to podium-web:8000 and weplay-web:8000, and cloudflared, which exposes weplay-web publicly as demo.example.dev." src="https://schwob.dev/images/nuc-architecture.png"&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tailscale&lt;/strong&gt; puts the NUC on a private network with all my devices. SSH, herdr and internal web apps travel over it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caddy&lt;/strong&gt; is the only container that publishes ports on the host. It routes each internal hostname to the right container and gets Let's Encrypt certificates through the Cloudflare DNS-01 challenge, so nothing needs to be reachable from the internet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt; (&lt;code&gt;cloudflared&lt;/code&gt;) makes an outbound connection to Cloudflare and exposes only the public hostnames I explicitly configure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;herdr&lt;/strong&gt; keeps the agents' terminals alive on the NUC, and I attach to it from wherever I am.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Step 0: Omarchy or Omaterm?&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://omarchy.org"&gt;Omarchy&lt;/a&gt; is an opinionated Arch Linux + Hyprland desktop. It is a great choice if the NUC will sometimes have a screen and keyboard plugged in.&lt;/p&gt;
&lt;p&gt;If the box will run headless, look at &lt;strong&gt;&lt;a href="https://learn.omacom.io/4/the-omaterm-manual/108/welcome"&gt;Omaterm&lt;/a&gt;&lt;/strong&gt;, its sibling made for servers, agent environments and dev boxes. You install plain Arch with &lt;code&gt;archinstall&lt;/code&gt;, then run its installer, which sets up the usual developer tooling and offers to log in to GitHub and Tailscale during setup.&lt;/p&gt;
&lt;p&gt;Everything below works on either one, since both are Arch.&lt;/p&gt;
&lt;p&gt;Two Omarchy defaults to keep in mind:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;OpenSSH is installed but the service is &lt;strong&gt;disabled&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The firewall (ufw) opens &lt;strong&gt;nothing&lt;/strong&gt; except LocalSend.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We'll use Tailscale SSH and allow the Tailscale interface through the firewall, so both points are handled.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;BIOS tip:&lt;/strong&gt; set the NUC to &lt;em&gt;power on after AC loss&lt;/em&gt;. A server that stays off after a power cut isn't much of a server.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Step 1: Bootstrap the host&lt;/h2&gt;
&lt;p&gt;This script installs Docker and Tailscale, keeps the machine awake, joins the tailnet and installs herdr. It is safe to run more than once.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="ch"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-euo&lt;span class="w"&gt; &lt;/span&gt;pipefail

&lt;span class="c1"&gt;# Packages&lt;/span&gt;
sudo&lt;span class="w"&gt; &lt;/span&gt;pacman&lt;span class="w"&gt; &lt;/span&gt;-Syu&lt;span class="w"&gt; &lt;/span&gt;--needed&lt;span class="w"&gt; &lt;/span&gt;--noconfirm&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;docker-compose&lt;span class="w"&gt; &lt;/span&gt;docker-buildx&lt;span class="w"&gt; &lt;/span&gt;tailscale&lt;span class="w"&gt; &lt;/span&gt;git&lt;span class="w"&gt; &lt;/span&gt;github-cli

&lt;span class="c1"&gt;# Services&lt;/span&gt;
sudo&lt;span class="w"&gt; &lt;/span&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;enable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--now&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;tailscaled
sudo&lt;span class="w"&gt; &lt;/span&gt;usermod&lt;span class="w"&gt; &lt;/span&gt;-aG&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;

&lt;span class="c1"&gt;# Never sleep: it&amp;#39;s a server now&lt;/span&gt;
sudo&lt;span class="w"&gt; &lt;/span&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;mask&lt;span class="w"&gt; &lt;/span&gt;sleep.target&lt;span class="w"&gt; &lt;/span&gt;suspend.target&lt;span class="w"&gt; &lt;/span&gt;hibernate.target&lt;span class="w"&gt; &lt;/span&gt;hybrid-sleep.target

&lt;span class="c1"&gt;# Let background processes (herdr server, user units) live without an open login session&lt;/span&gt;
sudo&lt;span class="w"&gt; &lt;/span&gt;loginctl&lt;span class="w"&gt; &lt;/span&gt;enable-linger&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;

&lt;span class="c1"&gt;# Firewall: trust the tailnet interface&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-v&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;allow&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;on&lt;span class="w"&gt; &lt;/span&gt;tailscale0
&lt;span class="w"&gt;  &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;reload
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c1"&gt;# Join the tailnet with Tailscale SSH enabled (prints a login URL the first time).&lt;/span&gt;
&lt;span class="c1"&gt;# If already connected (Omaterm&amp;#39;s installer does it), `tailscale up` would refuse&lt;/span&gt;
&lt;span class="c1"&gt;# unless every current flag is repeated: `tailscale set` changes only these two.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tailscale&lt;span class="w"&gt; &lt;/span&gt;status&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;tailscale&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--ssh&lt;span class="w"&gt; &lt;/span&gt;--hostname&lt;span class="o"&gt;=&lt;/span&gt;nuc
&lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;tailscale&lt;span class="w"&gt; &lt;/span&gt;up&lt;span class="w"&gt; &lt;/span&gt;--ssh&lt;span class="w"&gt; &lt;/span&gt;--hostname&lt;span class="o"&gt;=&lt;/span&gt;nuc
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c1"&gt;# Shared Docker network for the reverse proxy&lt;/span&gt;
sudo&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;network&lt;span class="w"&gt; &lt;/span&gt;inspect&lt;span class="w"&gt; &lt;/span&gt;proxy&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;network&lt;span class="w"&gt; &lt;/span&gt;create&lt;span class="w"&gt; &lt;/span&gt;proxy

&lt;span class="c1"&gt;# herdr&lt;/span&gt;
&lt;span class="nb"&gt;command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-v&lt;span class="w"&gt; &lt;/span&gt;herdr&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl&lt;span class="w"&gt; &lt;/span&gt;-fsSL&lt;span class="w"&gt; &lt;/span&gt;https://herdr.dev/install.sh&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;sh

&lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Tailscale IP:&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tailscale&lt;span class="w"&gt; &lt;/span&gt;ip&lt;span class="w"&gt; &lt;/span&gt;-4
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Then, in the Tailscale admin console:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Disable key expiry&lt;/strong&gt; for the NUC, so it doesn't drop off the tailnet after a few months.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check your SSH policy.&lt;/strong&gt; The default rule for your own devices may use &lt;code&gt;check&lt;/code&gt; mode, which asks you to re-authenticate in a browser regularly. That breaks background reconnects from herdr. For your own machines, &lt;code&gt;accept&lt;/code&gt; is the practical choice.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Log out and back in so the &lt;code&gt;docker&lt;/code&gt; group applies, run &lt;code&gt;gh auth login&lt;/code&gt;, and install your agents (Claude Code, Codex, opencode…).&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Step 2: Internal domains with real HTTPS&lt;/h2&gt;
&lt;p&gt;Tailscale's MagicDNS gives the machine a name like &lt;code&gt;nuc.your-tailnet.ts.net&lt;/code&gt;, but no subdomains per project. To get &lt;code&gt;podium.nuc.example.dev&lt;/code&gt;, &lt;code&gt;weplay.nuc.example.dev&lt;/code&gt; and so on, I use a domain I own on Cloudflare.&lt;/p&gt;
&lt;h3&gt;DNS&lt;/h3&gt;
&lt;p&gt;In Cloudflare DNS, add one record:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;Proxy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*.nuc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the NUC's &lt;code&gt;100.x.y.z&lt;/code&gt; IP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;DNS only&lt;/strong&gt; (grey)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Every &lt;code&gt;something.nuc.example.dev&lt;/code&gt; now resolves to the NUC's Tailscale IP. Devices on your tailnet can reach it; everyone else gets an unreachable address.&lt;/p&gt;
&lt;p&gt;Then create a Cloudflare &lt;strong&gt;API token&lt;/strong&gt; with the permission &lt;em&gt;Zone → DNS → Edit&lt;/em&gt;, scoped to that one zone. Caddy uses it to write the temporary TXT records for the DNS-01 challenge.&lt;/p&gt;
&lt;h3&gt;The edge stack&lt;/h3&gt;
&lt;p&gt;All of this lives in &lt;code&gt;~/nuc-edge&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Dockerfile&lt;/code&gt;&lt;/strong&gt;: Caddy built with two plugins. &lt;code&gt;caddy-docker-proxy&lt;/code&gt; reads its configuration from container labels; &lt;code&gt;caddy-dns/cloudflare&lt;/code&gt; handles DNS-01.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;caddy:2-builder&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;RUN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;xcaddy&lt;span class="w"&gt; &lt;/span&gt;build&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;--with&lt;span class="w"&gt; &lt;/span&gt;github.com/lucaslorentz/caddy-docker-proxy/v2&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;--with&lt;span class="w"&gt; &lt;/span&gt;github.com/caddy-dns/cloudflare

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;caddy:2-alpine&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--from&lt;span class="o"&gt;=&lt;/span&gt;builder&lt;span class="w"&gt; &lt;/span&gt;/usr/bin/caddy&lt;span class="w"&gt; &lt;/span&gt;/usr/bin/caddy
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;caddy&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;docker-proxy&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Caddyfile&lt;/code&gt;&lt;/strong&gt;: global options only. Everything else comes from labels.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;{
    email {env.ACME_EMAIL}
    acme_dns cloudflare {env.CF_API_TOKEN}
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;compose.yml&lt;/code&gt;&lt;/strong&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;edge&lt;/span&gt;

&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;caddy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;unless-stopped&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;80:80&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;443:443&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;443:443/udp&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;CADDY_INGRESS_NETWORKS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;proxy&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;CADDY_DOCKER_CADDYFILE_PATH&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/etc/caddy/Caddyfile&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;CF_API_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${CF_API_TOKEN}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;ACME_EMAIL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${ACME_EMAIL}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;./Caddyfile:/etc/caddy/Caddyfile:ro&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;caddy_data:/data&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;caddy_config:/config&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;proxy&lt;/span&gt;&lt;span class="p p-Indicator"&gt;]&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c1"&gt;# Smoke test&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;whoami&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;traefik/whoami&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;unless-stopped&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;proxy&lt;/span&gt;&lt;span class="p p-Indicator"&gt;]&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;whoami.${INTERNAL_DOMAIN}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy.reverse_proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;{{upstreams&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;80}}&amp;quot;&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;cloudflared&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;cloudflare/cloudflared:latest&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;unless-stopped&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;tunnel --no-autoupdate run&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;TUNNEL_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${CF_TUNNEL_TOKEN}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;proxy&lt;/span&gt;&lt;span class="p p-Indicator"&gt;]&lt;/span&gt;

&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;external&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;

&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;caddy_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;caddy_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.env&lt;/code&gt;&lt;/strong&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;INTERNAL_DOMAIN=nuc.example.dev
CF_API_TOKEN=...
ACME_EMAIL=you@example.dev
CF_TUNNEL_TOKEN=...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Start it with &lt;code&gt;docker compose up -d --build&lt;/code&gt;, then open &lt;code&gt;https://whoami.nuc.example.dev&lt;/code&gt; from a device on your tailnet. A valid certificate and a whoami page mean DNS, certificates and routing all work.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Step 3: Plugging in a project&lt;/h2&gt;
&lt;p&gt;This is where the setup pays off. Adding a project takes three changes to its Compose file:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;No &lt;code&gt;ports:&lt;/code&gt;.&lt;/strong&gt; Nothing publishes ports on the host except Caddy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Join the external &lt;code&gt;proxy&lt;/code&gt; network&lt;/strong&gt; (the database stays on the project's private network).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Declare the hostname in labels.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here's a Django example:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;weplay&lt;/span&gt;

&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;web&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;.&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;python manage.py runserver 0.0.0.0:8000&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;env_file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;.env&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;depends_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;db&lt;/span&gt;&lt;span class="p p-Indicator"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;redis&lt;/span&gt;&lt;span class="p p-Indicator"&gt;]&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;aliases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;weplay-web&lt;/span&gt;&lt;span class="p p-Indicator"&gt;]&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="c1"&gt;# stable name for cloudflared routes&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;weplay.nuc.example.dev&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy.reverse_proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;{{upstreams&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;8000}}&amp;quot;&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;postgres:17&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;postgres&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pgdata&lt;/span&gt;&lt;span class="p p-Indicator"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;/var/lib/postgresql/data&lt;/span&gt;&lt;span class="p p-Indicator"&gt;]&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;redis:7-alpine&lt;/span&gt;

&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;external&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;

&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;pgdata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Run &lt;code&gt;docker compose up -d&lt;/code&gt; and, a few seconds later, Caddy has picked up the labels, obtained a certificate and started routing. Run &lt;code&gt;docker compose down&lt;/code&gt; and the route disappears. No proxy config file to edit, no port numbers to juggle, no collisions between projects that all want port 8000.&lt;/p&gt;
&lt;p&gt;Django needs to know it sits behind a TLS-terminating proxy:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;ALLOWED_HOSTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;weplay.nuc.example.dev&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;demo.example.dev&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;CSRF_TRUSTED_ORIGINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://weplay.nuc.example.dev&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;https://demo.example.dev&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;SECURE_PROXY_SSL_HEADER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;HTTP_X_FORWARDED_PROTO&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;https&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;hr&gt;
&lt;h2&gt;Step 4: Public access with Cloudflare Tunnel&lt;/h2&gt;
&lt;p&gt;Some things need to be public: a demo for a client, a webhook endpoint, a staging site to share.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;In the Cloudflare Zero Trust dashboard, go to &lt;strong&gt;Networks → Tunnels&lt;/strong&gt; and click &lt;strong&gt;Create a tunnel&lt;/strong&gt;. Choose &lt;strong&gt;Cloudflared&lt;/strong&gt; and give it a name, such as &lt;code&gt;nuc&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;On the "Install and run a connector" screen, pick &lt;strong&gt;Docker&lt;/strong&gt;. Cloudflare shows a command like this one:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token eyJhIjoi...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Don't run it: the edge stack already runs &lt;code&gt;cloudflared&lt;/code&gt;. Copy only the long &lt;code&gt;eyJ...&lt;/code&gt; string after &lt;code&gt;--token&lt;/code&gt; into &lt;code&gt;.env&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;CF_TUNNEL_TOKEN=eyJhIjoi...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;If you lose it, the tunnel's settings show the same screen and token again.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Restart the edge stack so &lt;code&gt;cloudflared&lt;/code&gt; connects. The tunnel shows as &lt;strong&gt;Healthy&lt;/strong&gt; in the dashboard.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;Add a &lt;strong&gt;public hostname&lt;/strong&gt; to the tunnel, for example &lt;code&gt;demo.example.dev&lt;/code&gt; → &lt;code&gt;http://weplay-web:8000&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Because &lt;code&gt;cloudflared&lt;/code&gt; is on the same &lt;code&gt;proxy&lt;/code&gt; network, it reaches containers by their network alias. That's why the template sets &lt;code&gt;aliases: [weplay-web]&lt;/code&gt;: it stays stable whatever Compose names the container.&lt;/p&gt;
&lt;p&gt;Anything that isn't meant to be fully public should sit behind a &lt;strong&gt;Cloudflare Access&lt;/strong&gt; policy (email one-time PIN is the easiest). This way only the people you choose can reach the demo, and you never open a port on your router.&lt;/p&gt;
&lt;p&gt;Keep internal and public names apart. Internal hosts live under &lt;code&gt;*.nuc.example.dev&lt;/code&gt;; public ones use different names that the tunnel manages.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Step 5: Agents with herdr&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://herdr.dev"&gt;herdr&lt;/a&gt; is a terminal multiplexer built for coding agents. It works like tmux (workspaces, tabs, panes, detach and reattach) but it also detects which panes run agents and shows whether each one is working, blocked waiting for input, idle or done. One glance at the sidebar tells you which agent needs you.&lt;/p&gt;
&lt;p&gt;It uses a client/server model: the server owns the terminals and keeps running when no one is attached. That's what makes it a good fit for an always-on box.&lt;/p&gt;
&lt;h3&gt;On the NUC&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;herdr&lt;span class="w"&gt; &lt;/span&gt;integration&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;claude&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# and codex, opencode… as needed&lt;/span&gt;
herdr&lt;span class="w"&gt;                              &lt;/span&gt;&lt;span class="c1"&gt;# start a session, create workspaces per project&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;With the official integration installed, herdr can resume Claude Code conversations after a server restart. When the server stops (after a reboot, for example), herdr restores the layout: workspaces, tabs, panes and their directories. Running processes don't survive a restart, but agents with a supported integration are resumed in their previous session.&lt;/p&gt;
&lt;p&gt;Detach with &lt;code&gt;ctrl+b q&lt;/code&gt;. Everything keeps running.&lt;/p&gt;
&lt;h3&gt;From the laptop&lt;/h3&gt;
&lt;p&gt;Add an entry to &lt;code&gt;~/.ssh/config&lt;/code&gt; if you like (with Tailscale SSH, &lt;code&gt;ssh nuc&lt;/code&gt; already works through MagicDNS), then:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;herdr&lt;span class="w"&gt; &lt;/span&gt;machine&lt;span class="w"&gt; &lt;/span&gt;add&lt;span class="w"&gt; &lt;/span&gt;nuc
herdr
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The NUC now appears in the herdr sidebar next to your local machine, with its workspaces and agents. You switch between machines without opening another client, and if the connection drops the agents keep running on the NUC. herdr reconnects on its own.&lt;/p&gt;
&lt;p&gt;For a one-off session, &lt;code&gt;herdr --remote nuc&lt;/code&gt; also works.&lt;/p&gt;
&lt;h3&gt;From the phone&lt;/h3&gt;
&lt;p&gt;Install the Tailscale app and an SSH client (Termius is the easy pick on iOS and Android), connect to &lt;code&gt;nuc&lt;/code&gt; and run &lt;code&gt;herdr&lt;/code&gt;. You get the same agents, the same state, and you can answer the one that's blocked waiting for a permission.&lt;/p&gt;
&lt;h3&gt;A typical loop&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Open a herdr workspace for a project on the NUC.&lt;/li&gt;
&lt;li&gt;Start an agent in one pane and &lt;code&gt;docker compose up&lt;/code&gt; in another.&lt;/li&gt;
&lt;li&gt;The agent changes code and the dev server reloads; check the result at &lt;code&gt;https://podium.nuc.example.dev&lt;/code&gt; from your laptop or phone.&lt;/li&gt;
&lt;li&gt;Close the laptop. The agent keeps going. Come back later from wherever you are.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;Caveats worth knowing&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Docker bypasses ufw.&lt;/strong&gt; Ports published by Docker (here, only Caddy's 80/443) are reachable from your LAN even if ufw says otherwise. On a home network that's usually fine. To restrict them to the tailnet, bind them to the Tailscale IP, such as &lt;code&gt;"100.x.y.z:443:443"&lt;/code&gt;, and make sure Tailscale is up before Docker starts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Certificate Transparency logs are public.&lt;/strong&gt; Every certificate Caddy obtains, and therefore every internal hostname, appears in CT logs. The names are visible, but they resolve to a Tailscale IP nobody else can reach. If even the names are sensitive, use a single wildcard certificate instead of one certificate per host.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secrets.&lt;/strong&gt; &lt;code&gt;.env&lt;/code&gt; holds a DNS-edit token and a tunnel token. Keep it out of git, and scope the Cloudflare token to a single zone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agents with Docker access are powerful.&lt;/strong&gt; An agent that can run &lt;code&gt;docker&lt;/code&gt; on the host effectively has root on it. Keep this box for development, not for anything sensitive.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;The result is a quiet box under the desk that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;runs my agents around the clock, reachable from my laptop and my phone through herdr;&lt;/li&gt;
&lt;li&gt;gives every project a clean HTTPS URL on my private network, by adding two labels to its Compose file;&lt;/li&gt;
&lt;li&gt;publishes exactly the services I choose, behind Cloudflare Access, with no open ports.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Adding a project takes two labels and a network. Adding an agent means opening a pane. The rest of the setup doesn't need to change.&lt;/p&gt;
&lt;h3&gt;Further reading&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://learn.omacom.io/4/the-omaterm-manual/108/welcome"&gt;Omaterm manual&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://omarchy.org"&gt;Omarchy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://herdr.dev/docs/"&gt;herdr documentation&lt;/a&gt;: see &lt;em&gt;Connecting machines&lt;/em&gt; and &lt;em&gt;Session state and restore&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lucaslorentz/caddy-docker-proxy"&gt;caddy-docker-proxy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/caddy-dns/cloudflare"&gt;caddy-dns/cloudflare&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tailscale.com/kb/1193/tailscale-ssh"&gt;Tailscale SSH&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/"&gt;Cloudflare Tunnel&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content></entry></feed>