You built the lab one piece at a time. That's how it always goes.
A NAS in the closet. An old ThinkPad that became "the Docker box." A Raspberry Pi wired into the garden solenoids, watering the tomatoes on a cron schedule. Something running Home Assistant. Something running Pi-hole. A Grafana you set up in a burst of enthusiasm and now check twice a year.
Half of it is installed natively. Half is in containers. Every single one listens on a port that made sense at 11pm on the Sunday you deployed it, and on no other occasion since.
So you end up with a browser bookmark folder full of entries like 192.168.1.117:9443 and 192.168.1.104:3000, and a nagging suspicion that :8123 is Home Assistant but it might be Node-RED. You want the one thing that fixes this permanently:
A single page. A list of links. Click and forget the port ever existed.
The obvious solution, and why we're not doing it
Spin up an nginx container, mount a folder with an index.html, done. Ten minutes.
And that's a perfectly fine answer. But look at what you've actually signed up for: a container image to pull and re-pull, a restart policy, a daemon resident in RAM around the clock, a config file in a syntax you'll have to re-learn each time, and one more thing on the list of software that needs updating when a CVE lands.
All of that to return the same 2 KB of HTML, unchanged, maybe forty times a day.
We're minimalists. Let's do it with zero resident processes and about thirty lines of configuration — using nothing that isn't already installed on the machine.
The idea: let systemd be the web server
Here's the trick. systemd can open a listening socket on your behalf, hold it from boot, and start a program only when a connection actually arrives, handing that program the connection as plain stdin/stdout.
This is socket activation, and it's the modern descendant of inetd. It means your "web server" can be a shell script that reads a request from stdin and prints a response to stdout. Between requests, nothing runs at all. No daemon, no memory, no process.
HTTP for a single static page is genuinely simple enough for this to work:
GET / HTTP/1.1 ← the client sends this
Host: 192.168.1.104 ← plus some headers
← then a blank line
HTTP/1.0 200 OK ← you send this back
Content-Type: text/html
Content-Length: 1234
<!doctype html>... ← and the page
That's the entire protocol surface we need. Let's build it.
Assume the server lives at 192.168.1.104 and we want the page on port 80, so that http://192.168.1.104/ just works.
Step 1: the page
Nothing clever here — one self-contained file, no external CSS, no fonts, no JavaScript. It should load instantly on the worst phone in the house.
Save it as /var/www/homelab/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Home Lab</title>
<link rel="icon" href="data:,">
<style>
body { font-family: system-ui, sans-serif; background:#14171c; color:#e6e6e6;
margin:0; padding:2rem; }
h1 { font-weight:500; font-size:1.4rem; margin:0 0 1.5rem; }
h2 { font-weight:500; font-size:.85rem; text-transform:uppercase;
letter-spacing:.08em; color:#7d8794; margin:2rem 0 .75rem; }
ul { list-style:none; margin:0; padding:0;
display:grid; gap:.5rem;
grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); }
a { display:block; padding:.7rem .9rem; background:#1e232b; border-radius:6px;
color:#e6e6e6; text-decoration:none; border:1px solid #2a313b; }
a:hover { background:#252c36; border-color:#3d8bfd; }
small { display:block; color:#7d8794; font-size:.75rem; margin-top:.15rem; }
</style>
</head>
<body>
<h1>Home Lab</h1>
<h2>Infrastructure</h2>
<ul>
<li><a href="http://192.168.1.104:9090/">Cockpit<small>server · 104</small></a></li>
<li><a href="http://192.168.1.117:9443/">Portainer<small>docker · 117</small></a></li>
<li><a href="http://192.168.1.110/">Pi-hole<small>dns · 110</small></a></li>
</ul>
<h2>Home</h2>
<ul>
<li><a href="http://192.168.1.112:8123/">Home Assistant<small>pi · 112</small></a></li>
<li><a href="http://192.168.1.112:1880/">Node-RED<small>irrigation · 112</small></a></li>
</ul>
<h2>Media & Storage</h2>
<ul>
<li><a href="http://192.168.1.120:5000/">NAS<small>synology · 120</small></a></li>
<li><a href="http://192.168.1.117:8096/">Jellyfin<small>docker · 117</small></a></li>
</ul>
<h2>Monitoring</h2>
<ul>
<li><a href="http://192.168.1.104:3000/">Grafana<small>server · 104</small></a></li>
</ul>
</body>
</html>
That <link rel="icon" href="data:,"> is doing real work: it stops browsers requesting /favicon.ico on every visit, which halves your traffic and keeps the logs clean.
Make sure it's world-readable — the handler runs under a throwaway UID, as we'll see in Step 4:
sudo mkdir -p /var/www/homelab
sudo chmod 755 /var/www /var/www/homelab
sudo chmod 644 /var/www/homelab/index.html
Step 2: the "web server"
All twenty lines of it. Save as /usr/local/bin/serve-home.sh and chmod +x it:
#!/bin/bash
# A minimal HTTP responder. stdin/stdout are the client socket.
set -u
INDEX=/var/www/homelab/index.html
# Request line, e.g. GET / HTTP/1.1
read -r method path _ || exit 0
# Drain the headers. We ignore them, but we must consume them:
# exiting with unread data in the socket makes the client see a reset
# instead of our response.
while IFS= read -r line; do
[ "$line" = $'\r' ] && break
done
if [ "$method" = "GET" ] && { [ "$path" = "/" ] || [ "$path" = "/index.html" ]; }; then
printf 'HTTP/1.0 200 OK\r\n'
printf 'Content-Type: text/html; charset=utf-8\r\n'
printf 'Content-Length: %d\r\n' "$(stat -c%s "$INDEX")"
printf 'Cache-Control: no-cache\r\n'
printf 'Connection: close\r\n\r\n'
cat "$INDEX"
else
printf 'HTTP/1.0 404 Not Found\r\n'
printf 'Content-Length: 0\r\n'
printf 'Connection: close\r\n\r\n'
fi
Note what it does not do: it never touches $path as a filename. There is no file lookup, so there is no path traversal, no directory listing, no MIME guessing, no attack surface worth the name. One document, one code path.
Step 3: the socket unit
This is the part that holds port 80 from boot onward. /etc/systemd/system/homelab.socket:
[Unit]
Description=Home lab home page (socket)
[Socket]
ListenStream=80
Accept=yes
Backlog=128
# concurrency caps
MaxConnections=64
MaxConnectionsPerSource=8
[Install]
WantedBy=sockets.target
Accept=yes is what makes this inetd-style: systemd accepts each connection itself and spawns one short-lived service instance per connection, with that connection wired to stdin.
Step 4: the service template
/etc/systemd/system/homelab@.service — the @ marks it as a template, instantiated once per connection:
[Unit]
Description=Home lab home page (handler)
[Service]
ExecStart=/usr/local/bin/serve-home.sh
StandardInput=socket
StandardError=journal
# a stalled client must not pin a shell forever
RuntimeMaxSec=10
TimeoutStopSec=2
TasksMax=4
MemoryMax=32M
# a throwaway UID, allocated per connection
DynamicUser=yes
NoNewPrivileges=true
PrivateDevices=true
systemd pairs the two automatically by name: homelab.socket → homelab@.service.
Why DynamicUser=yes and not User=nobody
The reflex is to run this as nobody. Resist it. nobody is a shared catch-all — NFS maps to it, and every other daemon whose author couldn't be bothered to create a user runs as it too. Processes sharing a UID can signal and ptrace each other, and files owned by nobody are effectively owned by all of them. It looks like isolation and isn't.
DynamicUser=yes allocates a transient UID for each instance and releases it when the instance exits. Nothing is created, nothing persists in /etc/passwd, and no two things on the box share the identity. It also implies ProtectSystem=strict, ProtectHome=read-only, PrivateTmp=yes and RemoveIPC=yes, which is why those lines are gone from the unit — you get them for free. Needs systemd 232 or newer, so anything from the last several years.
The one constraint: since the UID is unpredictable, the page must be world-readable. chmod 644 from Step 1 covers it. If you'd rather it not be world-readable — say the page holds internal hostnames or a link with a token in it — create a real system user instead:
sudo useradd --system --user-group --no-create-home --shell /usr/sbin/nologin homelab
sudo chown root:homelab /var/www/homelab/index.html
sudo chmod 640 /var/www/homelab/index.html
and swap DynamicUser=yes for User=homelab / Group=homelab, putting back the ProtectSystem=strict and PrivateTmp=true lines you no longer get automatically.
Step 5: turn it on
sudo systemctl daemon-reload
sudo systemctl enable --now homelab.socket
Enable the socket, never the service. The service has no business starting on its own; it exists only to be spawned.
Test it:
curl -i http://192.168.1.104/
ss -ltnp | grep ':80 ' # systemd is holding the port
journalctl -u 'homelab@*' # per-connection logs, if you need them
Then point a browser at http://192.168.1.104/ and enjoy never typing a port number again.
Want to reach it as http://home/? Add an A record for home in Pi-hole (or whatever handles your local DNS) pointing at 192.168.1.104. That's the last mile of the whole exercise.
The four gotchas
These are the ones that will cost you an evening if nobody warns you.
1. StandardError=journal is mandatory. With StandardInput=socket, stdout inherits the socket — and stderr in turn inherits stdout. Leave that line out and any stray shell error message gets written into the middle of your HTTP response, producing a page that mysteriously fails to render with nothing obviously wrong. This is the single most confusing failure mode of the whole setup.
2. You must drain the request. It's tempting to ignore what the client sends and just print the page. Don't — exiting with unread data still sitting in the socket buffer can make the client see a connection reset instead of your carefully crafted response. That's what the while read loop is for.
3. RuntimeMaxSec is your slowloris defence. The handler blocks in read waiting for the blank line that ends the headers. A client that connects and then says nothing would otherwise hold an instance open indefinitely; enough of those and you exhaust MaxConnections and the page stops loading. A ten-second ceiling makes the problem disappear.
4. Port 80 needs no privilege here. systemd binds the socket as PID 1, then starts your handler under its throwaway UID. You get a low port without running anything as root and without granting CAP_NET_BIND_SERVICE. Try getting that for free from a container.
One deliberate omission: I've left out TriggerLimitBurst. It's a circuit breaker — exceed it and systemd stops the socket and leaves it stopped until you restart it by hand. On a LAN home page, that turns a harmless traffic blip into an outage requiring a human. MaxConnections plus RuntimeMaxSec degrade gracefully instead, which is what you want for something this unimportant.
Adding a new service to the index
You will do this constantly — every new box, every new container. So it's worth knowing exactly which changes need a systemctl incantation and which need nothing at all.
The short version: adding a service to the page needs no restart of anything.
Editing the page: nothing to restart
sudo nano /var/www/homelab/index.html # add one <li>, save
curl -s http://192.168.1.104/ | grep -i jellyfin
That's it. The handler cats the file fresh on every request and recomputes Content-Length with stat, so the next page load already has your new link. There is no cache to invalidate, no config to reload, no process holding a stale copy — because between requests there is no process. The Cache-Control: no-cache header we set means the browser won't hand you a stale copy either; a plain refresh is enough.
If you instinctively reach for systemctl daemon-reload after editing the HTML: don't. systemd has no idea that file exists.
Editing the handler script: also nothing to restart
serve-home.sh is exec'd afresh per connection, so changes take effect on the very next request.
One caveat: bash reads a script incrementally as it executes, so editing it in place while a request is mid-flight can confuse that one instance. Replace it atomically instead of appending to it:
sudo install -m 755 serve-home.new /usr/local/bin/serve-home.sh
(Most editors already do an atomic rename on save, so in practice this only matters if you're scripting the update.)
Editing the unit files: this one needs systemctl
Changing homelab.socket — a different port, a different MaxConnections, binding a specific address — is the only case that needs intervention, because that socket has been open since boot:
sudo systemctl daemon-reload
sudo systemctl restart homelab.socket
Changes to homelab@.service need only the daemon-reload. Instances are created per connection, so the next one picks up the new config by itself; there's nothing long-lived to restart.
Sanity-check a unit before you reload it:
systemd-analyze verify /etc/systemd/system/homelab.socket
Stopping it
sudo systemctl stop homelab.socket # stop listening (port released)
sudo systemctl stop 'homelab@*' # and kill any in-flight handlers
sudo systemctl start homelab.socket # back on
sudo systemctl disable homelab.socket # don't come back at boot
Worth knowing: stopping the socket only closes the listener. Connections already accepted keep running to completion in their own instances — hence the second command if you want a hard stop. In practice they're gone within ten seconds anyway, thanks to RuntimeMaxSec.
Cheat sheet
| You changed | What to run |
|---|---|
index.html (new service link) |
nothing |
serve-home.sh |
nothing (replace the file atomically) |
homelab@.service |
daemon-reload |
homelab.socket |
daemon-reload + restart homelab.socket
|
When it doesn't come back
systemctl status homelab.socket # is the listener up?
journalctl -u homelab.socket -n 20 # why did it fail?
journalctl -u 'homelab@*' -n 50 # what did the handlers do?
ss -ltnp | grep ':80 ' # is something else squatting on the port?
That last one catches the most common self-inflicted wound: you installed something else that grabbed port 80, and the socket unit now fails at boot. A failed socket unit stays failed — it will not retry on its own.
Debugging: the page is blank, now what?
A browser is the worst possible debugging tool here, because it renders a blank page for at least four completely different failures. Stop looking at the browser and start looking at the bytes.
Rule zero: curl -v
curl -sv http://192.168.1.104/ 2>&1 | head -30
This tells you almost everything at once: whether the connection was accepted, what status line came back, what Content-Length was claimed, and whether a body followed. Ninety percent of the time you're done here.
If the headers look right but the body is suspect, count the bytes and look at them raw:
curl -s http://192.168.1.104/ | wc -c
printf 'GET / HTTP/1.0\r\n\r\n' | nc 192.168.1.104 80 | head -c 400 | xxd | head
xxd is the one that catches CRLF problems. Every header line must end 0d0a, and there must be a bare 0d0a0d0a before the body. If you see lone 0a bytes, something in the script used echo where it should have used printf.
Run the handler without systemd
The handler is just a program that reads stdin and writes stdout, so you can drive it by hand and cut systemd out of the picture entirely:
printf 'GET / HTTP/1.0\r\n\r\n' | /usr/local/bin/serve-home.sh | xxd | head
If that produces a correct response but the real thing doesn't, the bug is in the unit, not the script — almost always permissions or sandboxing, because you just ran it as yourself, with your privileges and your view of the filesystem.
To reproduce the real conditions, systemd-run will build you the same cage the handler runs in, transient UID and all:
systemd-run --pty -p DynamicUser=yes cat /var/www/homelab/index.html
If that can't read the file, neither can the handler — and you've found your bug. The usual culprits are a chmod 640 that predates the switch to DynamicUser, or a directory along the path missing its +x traverse bit.
Symptom table
| What you see | What it means |
|---|---|
200 OK with Content-Length: 0
|
stat failed — the doc is missing, or isn't world-readable and so is invisible to the transient UID. This is the classic blank page.
|
404 on a page that used to work |
The path didn't match exactly. A query string (/?foo=1) or /index.htm falls through to the 404 branch. |
curl: (7) Failed to connect |
The listener isn't up. See When it doesn't come back above. |
curl: (52) Empty reply from server |
The handler exited without printing anything — a syntax error, or set -u tripping on an unset variable. Check the journal. |
| Page truncated, or the browser hangs |
Content-Length disagrees with the body length. Anything that writes extra bytes to stdout will do this. |
| HTML shown as plain text | The Content-Type header is missing, misspelled, or ended up after the blank line. |
| Garbage or a shell error in the middle of the page | You forgot StandardError=journal. See gotcha #1. |
Works via curl, blank in the browser |
Cached. Hard-refresh, or confirm Cache-Control is being sent. |
The blank page, specifically
That first row deserves detail, because it's the failure you'll actually hit. If stat -c%s "$INDEX" fails — file renamed, permissions wrong, typo in the path — it prints nothing, printf '%d' turns that empty string into 0, and you emit a perfectly valid 200 OK promising zero bytes. The browser dutifully renders nothing. No error, no clue.
The fix is to refuse to serve what you can't read. Add this right after the header drain:
if [ ! -r "$INDEX" ]; then
echo "serve-home: cannot read $INDEX" >&2
printf 'HTTP/1.0 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n'
exit 1
fi
Now the failure announces itself as a 500 in curl and a line in the journal, instead of silently pretending to succeed.
While you're there, the query-string 404 is worth pre-empting too — strip it before matching:
path=${path%%\?*}
Tracing what the handler actually saw
Because StandardError=journal is set, anything you write to stderr lands in the journal, tagged per connection. That makes tracing a one-liner:
echo "req: method=[$method] path=[$path]" >&2
Then watch it live while you reload the browser:
journalctl -f -u 'homelab@*'
Two warnings. Never write debug output to stdout — that's the client socket, and you'll corrupt the response you're trying to diagnose. And don't log to a file under /tmp: DynamicUser=yes implies PrivateTmp, so each instance gets its own throwaway /tmp and your log will appear to vanish. Use stderr, or logger.
For heavier work, set -x at the top of the script dumps a full trace to the journal. Just remember to take it out.
What it costs
Idle: one file descriptor. systemd holds the listening socket, which it was going to be running anyway. There is no process, no RSS, no image, no restart policy, no update treadmill.
Per request: one fork/exec of bash, a few milliseconds, and it's gone. On a LAN page that gets a few dozen hits a day, the total daily CPU cost rounds to nothing.
Compare against the nginx container: a few tens of megabytes resident twenty-four hours a day, an image to keep current, and a config file you'll re-learn every time you touch it — all to serve one unchanging document.
And as shown above, growing the lab costs you one <li> and a save.
When to stop and install a real web server
This is a deliberately sharp tool, and it's worth being honest about its edges. Reach for nginx or Caddy the moment you want:
- HTTPS. Not happening here. Terminate TLS properly if you need it.
- More than one document, images, or CSS files — the second you're mapping paths to files, you're writing a web server, and you should use one somebody else already debugged.
- Authentication, access logs, or anything resembling an audit trail.
- Exposure beyond the LAN. This belongs on a trusted network behind your router, full stop. Don't port-forward it.
But for "one page, one list of links, on my own network, forever"? Thirty lines of config, twenty lines of bash, and nothing running when nobody's looking.
That's about as minimalist as it gets.
Disclaimer: The text above has been written with the help of AI. All the ideas, checks and minimalistic approch are mine.
Top comments (0)