Auto-starting a Hugo dev server at boot with systemd

· 3 min read

I run my site’s Hugo dev server on a box on the LAN so I can preview drafts from any machine in the house. Starting it by hand after every reboot got old, so I moved it into a user-level systemd service.

The launcher script

The server itself is started by a small wrapper, ~/bin/hugo-run, which binds to the machine’s LAN address instead of localhost:

#!/usr/bin/env bash

MY_ADDRESS=$(hostname -I | awk '{print $1}')
cd  "$SITE_HOME" && \
    hugo server --buildDrafts --disableFastRender --bind="$MY_ADDRESS" --baseURL="http://$MY_ADDRESS:1313"

Two details matter here:

  • --buildDrafts so unpublished posts show up.
  • --disableFastRender, because fast render skips rebuilding pages that Hugo thinks haven’t changed, which bites you when editing theme layouts and partials.

It reads $SITE_HOME from the environment, so the unit file supplies it.

The unit file

Because everything lives under my home directory and needs no privileges, this is a user service rather than a system one. No root, no User= directive, no permission juggling.

~/.config/systemd/user/hugo-site.service:

[Unit]
Description=Hugo dev server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
Environment=SITE_HOME=%h/src/mysite
WorkingDirectory=%h/src/mysite
ExecStart=%h/bin/hugo-run
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target

Restart=on-failure with RestartSec=5 is doing real work, not just belt-and-braces: the wrapper resolves the LAN address with hostname -I at launch. If the network isn’t up yet, that comes back empty and Hugo fails to bind. systemd then retries every five seconds until DHCP has handed out an address. (network-online.target isn’t reliably available in the user manager, so the retry loop is the actual safety net.)

%h is systemd’s specifier for the user’s home directory — unit files don’t expand ~, so use it rather than hardcoding an absolute path.

WantedBy=default.target is the user-session equivalent of multi-user.target — it’s what gets pulled in when the user manager starts.

Enabling it

systemctl --user daemon-reload
systemctl --user enable --now hugo-site.service
systemctl --user status hugo-site.service

Check it’s actually serving:

curl -s -o /dev/null -w '%{http_code}\n' http://192.168.1.42:1313/

The part that needs root: lingering

This is the step that’s easy to miss. A user service only runs while that user has an active session. Enable it, reboot, don’t log in, and nothing starts — which defeats the whole point on a headless box.

The fix is to enable lingering, which tells systemd to start the user manager at boot regardless of logins:

sudo loginctl enable-linger $USER

Verify:

loginctl show-user $USER | grep Linger
# Linger=yes

This is the one command in the whole setup that needs sudo.

Day-to-day

systemctl --user restart hugo-site    # after layout/partial changes
systemctl --user stop hugo-site
journalctl --user -u hugo-site -f     # follow the build log

That last one is handy: Hugo’s build errors and page counts land in the journal rather than a terminal you closed three days ago.