I had a small, annoying problem: a directory of files that needed watching, and a webhook that needed poking whenever something changed. I had been doing it with a cron job, a shell script, and a prayer. It worked the way shell scripts work, which is to say it worked until it didn't, silently, at the worst possible time. So I spent an evening replacing it with a tiny daemon in Go, and the result has been running for a fortnight without a single line in the journal that wasn't expected. This is how it went.
one job, done properly
The whole design philosophy was "do one thing and have a clear opinion about it". The daemon watches a path, debounces the flurry of events you always get when something writes a file, and fires an HTTP request. That is it. No plugins, no config DSL, no clever extensibility I would regret. If I need a second behaviour later, I will write a second binary.
Go is unreasonably good for this. One static binary, no runtime to install on the target box, cross-compiles to the little ARM machine in the garage with a single environment variable. GOOS=linux GOARCH=arm64 go build and scp, done. No virtualenv, no node_modules the size of a small moon, no "works on my machine".
the actual loop
The heart of it is a watcher feeding a debounced channel. The trick with file watching is that one logical change often produces several filesystem events, so you collect events and only act once they go quiet. A timer that resets on each event does this neatly.
func debounce(d time.Duration, in <-chan struct{}, out chan<- struct{}) {
var timer *time.Timer
for range in {
if timer != nil {
timer.Stop()
}
timer = time.AfterFunc(d, func() {
out <- struct{}{}
})
}
}
It is not the most defensive code I have ever written, but for a single producer it does the job, and the shape of it is obvious at a glance, which matters more to me than cleverness in something I will read again in a year.
context, and shutting down without losing work
The part people skip in toy daemons is graceful shutdown, and it is exactly the part that bites you in production. If systemd sends SIGTERM mid-request, you want to finish the in-flight work and then exit, not drop it on the floor. The Go idiom here is signal.NotifyContext, which has made this genuinely pleasant since it landed.
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
go runWatcher(ctx, cfg)
<-ctx.Done()
log.Println("shutdown signal received, draining")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
drain(shutdownCtx)
Everything downstream takes a context.Context and respects it. The HTTP client gets the context so a hung webhook cannot wedge the shutdown forever. The drain gets a ten-second budget, after which I would rather exit than hang on the boot sequence. This is the single most valuable habit Go has taught me: thread a context everywhere, and cancellation stops being something you bolt on at the end.
configuration, kept boring
I resisted the urge to reach for a config library. The whole configuration is a handful of values, so they come from the environment with sane defaults, and that is the end of it.
type Config struct {
WatchPath string
Webhook string
Debounce time.Duration
}
func loadConfig() Config {
return Config{
WatchPath: env("WATCH_PATH", "/var/lib/thing"),
Webhook: env("WEBHOOK_URL", ""),
Debounce: envDuration("DEBOUNCE", 2*time.Second),
}
}
Environment variables play nicely with systemd, with containers, and with my memory at 11pm. A YAML file would have been more "proper" and would have earned me nothing but a parser to maintain.
the unit file
The daemon does not daemonise itself. It runs in the foreground, logs to stdout, and lets systemd own the lifecycle, which is the modern way and saves a pile of double-fork nonsense nobody enjoys writing.
[Unit]
Description=file watch webhook poker
After=network-online.target
[Service]
ExecStart=/usr/local/bin/watchpoke
Environment=WATCH_PATH=/srv/incoming
Environment=WEBHOOK_URL=https://hooks.internal/notify
Restart=on-failure
RestartSec=5
DynamicUser=yes
[Install]
WantedBy=multi-user.target
DynamicUser=yes is the quiet hero there. systemd conjures an unprivileged user for the service, so a single misbehaving binary cannot wander the filesystem as anything important. Defence in depth for free, which is my favourite kind of defence.
what I left out, on purpose
No metrics endpoint, because I do not need to graph a thing that pokes a webhook twice a day. No retry queue with exponential backoff, because Restart=on-failure plus an idempotent webhook covers the failure mode I actually have. No structured logging framework, because log.Println to the journal is grep-able and I have never once wished it were JSON for a tool this small.
That restraint is the whole point. The temptation with a green-field daemon is to build the platform you might one day want. Resisting it is most of what makes small Go tools a joy: they stay small, they stay readable, and they keep running. Two weeks in, this one has done its one job without complaint, and I have not thought about it once since I deployed it. For a piece of infrastructure, there is no higher praise.