{"slug": "show-hn-doormouse-a-reverse-proxy-that-wakes-sleeping-servers-via-wake-on-lan", "title": "Show HN: Doormouse – a reverse proxy that wakes sleeping servers via Wake-on-LAN", "summary": "Doormouse, a new open-source reverse proxy from developer darksworm, wakes sleeping servers via Wake-on-LAN when requests arrive, then suspends them after inactivity, cutting power use for homelab machines like NAS, GPU boxes, and game servers. The tool, available as a Docker image on GitHub Container Registry, runs on an always-on host like a Raspberry Pi, sends magic packets to boot targets, and supports HTTP and raw TCP, with configuration via config.toml.", "body_md": "**A reverse proxy that wakes your servers when someone knocks.**\n\nMost homelabs grow one machine that costs real money to run: a NAS with a stack of spinning disks, a box with a GPU in it for LLM inference or transcoding, an old workstation kept around for backups. You need it a few hours a day. It draws power for all twenty-four. Turning it off saves that power, but then the services on it are gone exactly when you want them.\n\ndoormouse closes that gap. Run it on the small machine you already keep on all the time — a Raspberry Pi, a mini PC, whatever hosts your lighter services — and put it in front of the expensive one. When a request arrives for a sleeping machine, doormouse sends a Wake-on-LAN magic packet, waits for the host to boot, and forwards the request. The client sees one slow response instead of a connection error. Once the machine has been idle long enough, doormouse suspends it again.\n\nIt handles HTTP and raw TCP, so the same proxy can front a web app and `ssh`\n\n. A\nfew things it fronts well:\n\n**A NAS** serving photos, media or backups in bursts, idle the rest of the day.**A GPU box** for local LLMs or transcoding, awake only while you are asking it something.**A game server** that sleeps until the first player connects.**A build or CI machine** you hit a handful of times a week.\n\n**A target host with Wake-on-LAN enabled.** Most wired NICs support it, but it is usually off by default in the BIOS and sometimes in the OS as well.**An always-on host to run doormouse.** A Raspberry Pi is enough.**Both hosts in the same broadcast domain.** Magic packets do not route.\n\nNote\n\n\"Sleep\" is whatever your `shutdown_command`\n\ndoes, so suspend, hibernate and\nfull poweroff all work. Poweroff wakes the most reliably. Suspend is faster but\nsome boards will not resume from it. Test yours before you depend on it.\n\nWrite `config.toml`\n\n:\n\n```\nport = \":8080\"\ntimeout = \"1m\"\npoll_interval = \"5s\"\nhealth_check_interval = \"30s\"\nhealth_cache_duration = \"10s\"\n\n[[machines]]\nname = \"nas\"\nmac_address = \"7c:8b:ad:da:be:51\"\nbroadcast_ip = \"10.0.0.255\"\nhealth_check = \"tcp://nas.local:22\"\ninactivity_threshold = \"1h\"\n\nssh_host = \"nas.local:22\"\nssh_user = \"doormouse\"\nssh_key_path = \"/app/ssh_key\"\nshutdown_command = \"sudo systemctl suspend\"\n\n[[routes]]\nmachine = \"nas\"\nhostname = \"photos.example.com\"\ndestination = \"http://nas.local:2283\"\n```\n\nThen bring it up:\n\n```\nservices:\n  doormouse:\n    image: ghcr.io/darksworm/doormouse:latest\n    network_mode: host\n    restart: unless-stopped\n    volumes:\n      - ./config.toml:/app/config.toml\n      - ./ssh_key:/app/ssh_key\ndocker compose up -d\n```\n\nPoint `photos.example.com`\n\nat the doormouse host, then open\n`http://photos.example.com:8080`\n\n. The NAS wakes.\n\nImportant\n\nHost networking is required. Wake-on-LAN needs to broadcast, which Docker's default bridge network will not carry.\n\nBecause host networking does not remap ports, the proxy is reachable on\nwhatever `port`\n\nsays. Set `port = \":80\"`\n\nif you want the URL without a port, or\nput doormouse behind a front proxy that terminates TLS.\n\n- A request arrives for a configured hostname or listen port.\n- doormouse checks cached machine health. If the host is up, it forwards immediately.\n- Otherwise it sends magic packets every 500ms and polls until the host answers\nor\n`timeout`\n\nexpires. - It then waits for the\n*route*to be ready, which is a separate check. - It forwards the request and streams the response back.\n- When no route has seen traffic for\n`inactivity_threshold`\n\nand no connection is open, it runs the shutdown command.\n\nA **machine** is a host. doormouse wakes and suspends it as one unit.\n\nA **route** is one way in to that machine. One machine can carry many routes: a\nNAS might serve photos, files and `ssh`\n\n. That is one machine and three routes.\nTraffic on any route keeps the whole machine awake.\n\nA route is reached one way or the other, never both.\n\n| Matched on | `destination` |\nShares `port` |\n|\n|---|---|---|---|\nHTTP |\n`hostname` (Host header) |\n`http://nas.local:2283` |\nYes |\nTCP |\n`listen_port` (own socket) |\n`nas.local:22` |\nNo |\n\nHTTP routes multiplex on the Host header, so any number of them share one port. Raw TCP carries no such header. Each TCP route therefore needs its own listener.\n\n```\n# HTTP route, matched on the Host header.\n[[routes]]\nmachine = \"nas\"\nhostname = \"photos.example.com\"\ndestination = \"http://nas.local:2283\"\n\n# TCP route, own port: ssh -p 2222 you@doormouse-host\n[[routes]]\nmachine = \"nas\"\nlisten_port = 2222\ndestination = \"nas.local:22\"\n```\n\nTCP connections are spliced byte for byte. The upstream host key reaches your client unmodified, so there is no man-in-the-middle and no key substitution.\n\nNote\n\nYour client still sees a different address. It connects to\n`doormouse-host:2222`\n\n, not `nas.local:22`\n\n, so OpenSSH looks up a different\n`known_hosts`\n\nentry and prompts on first use. To keep one entry for both paths,\nset `HostKeyAlias`\n\n:\n\n```\nHost nas-via-doormouse\n    HostName doormouse-host\n    Port 2222\n    HostKeyAlias nas.local\n```\n\nWarning\n\nA TCP route is a new way in to that service. The example above exposes the\nNAS's `sshd`\n\non every interface the doormouse host listens on. Authentication\nis unaffected, since `sshd`\n\nstill authenticates every connection. But a service\nthat was previously reachable only from your LAN now depends on where you run\nthe proxy. Bind it somewhere you trust.\n\ndoormouse deals with two kinds of name, resolved in two different places.\n\n**A route's hostname is what the client asks for.** It has to resolve to the\ndoormouse host. Not to the machine you want woken, which is asleep and cannot\nanswer.\n\n**A route's destination is what doormouse dials.** It resolves on the doormouse\nhost, on your LAN.\n\nSo `photos.example.com`\n\npoints at your Raspberry Pi, and doormouse forwards to\n`nas.local:2283`\n\nbehind it.\n\nUse whichever you already run:\n\n**A local DNS server**, such as Pi-hole, AdGuard Home, dnsmasq or your router. Add an A record for the hostname with the doormouse host's IP. A wildcard like`*.home.example.com`\n\nsaves you a record per route.Enough to try it out, tedious past that.`/etc/hosts`\n\non one client.**Public DNS**, if you own the domain. Point the A record at the doormouse host.\n\nTCP routes are matched on a port, not a name, so any name that reaches the doormouse host will do, including the bare IP.\n\nWarning\n\nA public A record holding a private address such as `10.0.0.5`\n\nis dropped by\nsome resolvers as DNS rebinding. Use a local override instead.\n\n`destination`\n\nand `health_check`\n\nhave to resolve while the machine is asleep. A\nname that only exists while the host is up cannot be used to wake it. Give the\nmachine a DHCP reservation and a static DNS entry, or write the IP straight into\nthe config.\n\nThere are two `health_check`\n\nfields and they answer different questions.\n\n** machines[].health_check is liveness: is the host up?** It drives Wake-on-LAN\nand the wake wait. Point it at something that is always running:\n\n```\nhealth_check = \"tcp://nas.local:22\"\n```\n\nDo not point it at a single application. If that application crashes, doormouse concludes the host is down and fires magic packets at a machine that is already awake.\n\n** routes[].health_check is readiness: can this route serve?** It gates\nforwarding for that route alone. It defaults to dialling\n\n`destination`\n\n, inferring\n`:80`\n\nor `:443`\n\nfrom the URL scheme when the port is implicit.Point it at a real health endpoint when a service takes noticeably longer to come up than the host does:\n\n```\nhealth_check = \"http://nas.local:2283/api/server/ping\"\n```\n\nOtherwise a successful wake still yields a 502, because the host is up but the service is not yet listening.\n\nThree forms are accepted: `tcp://host:port`\n\n, `http://...`\n\nand `https://...`\n\n.\n\nNote\n\nReadiness is polled only while the machine is live. A machine that sleeps all day therefore generates no per-route traffic.\n\n```\ninactivity_threshold = \"1h\"\n```\n\ndoormouse suspends a machine when both conditions hold:\n\n- No route has seen traffic for\n`inactivity_threshold`\n\n. - No forwarded connection is still open.\n\nThe second condition matters. An `ssh`\n\nsession is idle by nature, and a large\nupload can outlast the threshold. Neither should be cut off mid-flight.\n\nOmit the field, or set `\"0s\"`\n\n, to never shut the machine down.\n\nTip\n\nAn open `ssh`\n\nsession holds the machine awake even while nothing is typed, so a\nforgotten terminal keeps it up all night. Let `sshd`\n\nclose idle sessions itself\nand the threshold can do its job. In `sshd_config`\n\non the target:\n\n```\nChannelTimeout *=10m\nUnusedConnectionTimeout 1m\n```\n\nRequires OpenSSH 9.3 or newer.\n\nConfigure exactly one per machine.\n\n**Over SSH:**\n\n```\nssh_host = \"nas.local:22\"\nssh_user = \"doormouse\"\nssh_key_path = \"/app/ssh_key\"\nshutdown_command = \"sudo systemctl suspend\"\n```\n\n**Over HTTP:**\n\n```\nshutdown_http_url = \"http://nas.local/api/shutdown\"\nshutdown_http_method = \"POST\"   # optional, defaults to POST\nshutdown_http_ok_status = 202   # optional, defaults to any 2xx\n```\n\ndoormouse validates the config at startup and refuses to run on a bad one, rather than starting and misbehaving later. It rejects:\n\n**A machine with no** Every check would fail, the machine would look permanently dead, and every request would try to wake it.`health_check`\n\n.**A** TCP routes need`destination`\n\nthat cannot work.`host:port`\n\n. HTTP routes need an absolute`http://`\n\nor`https://`\n\nURL. A schemeless value such as`nas.local:2283`\n\nparses as a URL scheme named`nas.local`\n\nand would otherwise 502 every request with nothing in the logs.**A** It must start with`health_check`\n\nthe checker cannot dispatch on.`tcp://`\n\n,`http://`\n\nor`https://`\n\n.**Any unrecognised key.** Typos used to decode silently to a zero value, which is the usual route to a missing health check.\n\nCaution\n\nThe last rule can break an upgrade. A config carrying a key from an older version will now fail to start. Read the error, drop the key, restart.\n\n`SIGINT`\n\nand `SIGTERM`\n\ntrigger a graceful shutdown. doormouse stops accepting,\ngives in-flight HTTP requests up to 15 seconds to finish, closes the TCP\nlisteners and exits 0. In-flight TCP connections are dropped rather than drained,\nso a forwarded `ssh`\n\nsession ends when the proxy stops.\n\nEarly versions used one `[[targets]]`\n\nblock per server. That format still loads,\nbut it will be removed in a future release.\n\nOn startup doormouse translates an old config, logs what it did, and writes the\nresult beside the original as `<name>.migrated.toml`\n\n. Review that file, then swap\nit in. Your original is never modified, since it is often bind-mounted read-only\nor checked into a config repo.\n\nA config may use one format or the other, never both.\n\nEvery release publishes an image to `ghcr.io/darksworm/doormouse`\n\n, built for\n`linux/amd64`\n\nand `linux/arm64`\n\n. Four tags point at it:\n\n| Tag | Points at |\n|---|---|\n`0.4.1` |\nthat exact release, and never moves |\n`0.4` |\nthe newest patch in the 0.4 line |\n`0` |\nthe newest release in the 0.x line |\n`latest` |\nthe newest release |\n\n`latest`\n\nis fine for trying doormouse out. Once it is proxying something you\ncare about, pin the exact version or the minor line, so an upgrade happens when\nyou choose it.\n\n```\ngo build -o doormouse .\ngo test -race ./...\ndocker build -t doormouse .\n```\n\n[traefik-wol](https://github.com/MarkusJx/traefik-wol), a Traefik plugin.[caddy-wol](https://github.com/dulli/caddy-wol), a Caddy plugin.\n\nPrefer those if you already run Traefik or Caddy. doormouse runs standalone and also forwards raw TCP.\n\nPull requests welcome. See [CONTRIBUTING.md](/darksworm/doormouse/blob/main/CONTRIBUTING.md) for setup and\ncommit conventions.\n\nDocs aim for a Flesch-Kincaid grade of 9 or below. Many readers do not speak\nEnglish as a first language, so short sentences and plain phrasing help. Keep\nthe technical vocabulary, though: `liveness`\n\n, `Host header`\n\nand `broadcast domain`\n\nare shorter and clearer than talking around them.", "url": "https://wpnews.pro/news/show-hn-doormouse-a-reverse-proxy-that-wakes-sleeping-servers-via-wake-on-lan", "canonical_source": "https://github.com/darksworm/doormouse", "published_at": "2026-08-28 14:41:47+00:00", "updated_at": "2026-08-28 14:48:13.941356+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["Doormouse", "darksworm", "GitHub Container Registry", "Raspberry Pi", "Docker"], "alternates": {"html": "https://wpnews.pro/news/show-hn-doormouse-a-reverse-proxy-that-wakes-sleeping-servers-via-wake-on-lan", "markdown": "https://wpnews.pro/news/show-hn-doormouse-a-reverse-proxy-that-wakes-sleeping-servers-via-wake-on-lan.md", "text": "https://wpnews.pro/news/show-hn-doormouse-a-reverse-proxy-that-wakes-sleeping-servers-via-wake-on-lan.txt", "jsonld": "https://wpnews.pro/news/show-hn-doormouse-a-reverse-proxy-that-wakes-sleeping-servers-via-wake-on-lan.jsonld"}}