DynamicUser and the StateDirectory pitfall
Every backend on this platform is a Rust binary launched through systemd, and every unit is a locked-down DynamicUser=yes service. DynamicUser is great: each instance gets a transient, unprivileged uid/gid that exists for the life of the unit and vanishes when it stops, taking account capabilities with it. No shared accounts, no shell, nothing to leak between services.
But there is a trap. A dynamically allocated user does not automatically get a home directory it can write to, and a naive Persistent=true does not give you a readable storage root either. A Rust service calling std::fs::create_dir_all("/var/lib/huck-storage") under such a unit fails with Permission denied, because the unit’s user cannot touch /var/lib at all once ProtectSystem=strict is on. The directory must be provisioned and owned by the unit before the process starts.
The one-liner: StateDirectory
The correct answer is systemd’s StateDirectory=:
[Service]
DynamicUser=yes
StateDirectory=huck-storage
Environment=HUCK_STATE_DIR=/var/lib/huck-storage
StateDirectory= makes systemd create /var/lib/huck-storage, chown it to the dynamic user, and make it writable — all before ExecStart runs. That single directive is what lets an otherwise fully-unprivileged process own its own persistent state.
The pitfall we hit twice
The subtlety is that a StateDirectory is per-unit. The moment two services share a path, systemd refuses to start the second one: the directory is already owned by another dynamic user, and the hardened unit gets a “permission denied” on boot. That is why each service here declares its own directory (huck-config, huck-storage, …) rather than a shared /var/lib/huck.
It is also why the state path must be passed explicitly via Environment=. DynamicUser does not expand ~ to your new uid reliably, and relying on $HOME in a hardened unit is fragile. One explicit variable, one matching StateDirectory=, and the process always knows where it may write.
Atomic writes
Ownership solved, the remaining habit worth keeping is writing state carefully. Our persistence pattern is load-at-boot plus write-on-mutation:
std::fs::write(&tmp, &bytes)?; // state.json.tmp
std::fs::rename(&tmp, &path)?; // atomic over state.json
Writing to a temp file then rename-ing over the target avoids a torn file if the unit is killed mid-write — rename is atomic on the same filesystem, so the service never observes a half-written state.json.
The rule of thumb: give every DynamicUser unit its own StateDirectory, hand it the path through an explicit variable, and write state atomically. That is the whole lock-down without the surprise.