Your Desktop Needs `systemd --user`
Dotfiles and autostart launch processes but do not supervise them. systemd --user adds lifecycle management, restarts, timers, and logs to the Linux desktop.
On this page
- The problem: the desktop as a bag of startups
- What I tried first
- Stuffing everything into shell profiles and compositor autostart
- Cron, @reboot, and the “clever” substitutes
- Desktop “Startup Applications”
- Why those solutions keep failing
- Then: systemd --user
- Minimal working example: a background helper
- Agents and mid-task logout
- A timer instead of cron
- Something that should only live in a graphical session
- Linger: when you need the user manager without a seat
- Environment: the other footgun
- A practical reader path
- Tradeoffs worth saying out loud
- Experiment
I did not set out to rewrite how my desktop boots.
I set out to stop losing background processes.
Somewhere between a tiling compositor, a handful of long-running helpers, and coding agents that expect the machine to still be awake when I come back, my “session” stopped being a session. It became a pile of shell startups, compositor exec-once lines, desktop autostart entries, and one script I was slightly ashamed of.
Choosing a window manager did not fix that.
Niri vs Hyprland vs whatever you prefer is a layout question. Session reliability is a different question. You can have a beautiful scrollable tiling setup and still watch half your tools evaporate because something exited with status 1 and nothing was listening.
That was the problem.
Not a dramatic outage. Not a kernel panic. Just a quiet reliability gap between “I logged in” and “the tools I depend on are actually supervised.”
The problem: the desktop as a bag of startups
On a typical Linux desktop — especially a DIY Wayland one — “things that should be running” end up in five places:
.bashrc/.zshrc/.profile(shell-tied, interactive, wrong lifetime).xprofile/xinitrc/ compositor config (exec-once,exec)- XDG autostart
.desktopfiles - “Startup Applications” GUIs that write those files for you
- Random scripts launched from somewhere you will forget by Thursday
Each of those can start a process.
Almost none of them can:
- restart it when it crashes
- wait for another unit to be ready first
- keep it alive across logout when you actually want that
- stop it cleanly when the graphical session ends
- show you a journal of why it died at 2:14 a.m.
And the modern desktop is not just a panel and a browser. It hosts sync helpers, clipboard managers, agent runtimes, forgotten language servers, tunnels. Work lives here, not only chrome.
The failure mode is boring: something dies, nothing restarts it, you notice when an agent goes silent. The second mode feels like success until logout — half the graph gone, or orphaned children of a session logind already tore down. Your brain wants that behavior to be explainable.
Dotfiles are great at capturing preference. They are mediocre at capturing lifecycle.
What I tried first
Stuffing everything into shell profiles and compositor autostart
This is the default path for tiling people.
Put mpd in the shell. Put waybar in Hyprland’s exec-once. Put the agent launcher in both, just in case. Export WAYLAND_DISPLAY by hand. Hope logout order is polite.
It works until it doesn’t. Shell profiles run in the wrong places; interactive vs login shells disagree; SSH inherits junk. Compositor exec-once is fire-and-forget. Dependency ordering is “put this line above that line and pray.” Change compositors or keep helpers local while work moves to a remote VM, and you duplicate the same fragile list. Convenient paste target. Wrong owner for process lifetime.
Cron, @reboot, and the “clever” substitutes
Cron is honest about what it is. It is a scheduler, not a session manager.
@reboot will launch something after boot. It will not know whether your graphical session exists, import DISPLAY / WAYLAND_DISPLAY reliably, restart a crashed daemon with backoff, or tie lifetime to login vs linger semantics.
You can duct-tape all of that. You will then own a duct-tape museum.
The watchdog script and the “tmux-on-login, stuff daemons into panes” trick land in the same museum. A pgrep loop invents its own logs. A multiplexer acting as an init system invents special cases for “was I logged in via SSH or the greeter?” tmux is excellent for interactive work. It is a mediocre process supervisor.
Desktop “Startup Applications”
Fine for “open Telegram.” Weak for “keep this helper alive with Restart=on-failure and After=network-online.”
These entries are usually XDG autostart. systemd can even generate user units from them (systemd-xdg-autostart-generator), but the GUI rarely exposes restart policy, hard dependencies, or clean stop on session end.
Each approach solves a slice of the problem and invents a second one: operational opacity. When something fails at login, you get a missing icon or a socket that never appears — not a unit state, an exit code, and a journal cursor for future-you.
Why those solutions keep failing
They all share the same missing abstraction: a user-level service manager with a real dependency and lifecycle model.
Dotfiles are configuration. Autostart is a to-do list. Cron is a calendar. tmux is a terminal. None of them are init for your uid.
Meanwhile the system already has one: systemd --user. It starts with your first login (via pam_systemd), manages units under ~/.config/systemd/user/, speaks the same language as system units (After=, Wants=, Restart=, timers, sockets), and writes to the journal.
I had been using systemd for years on servers and treating the desktop like it was still 2009.
That was the actual bug.
The funny part is that the user manager was already running. pam_systemd had been launching systemd --user on first login the whole time. I was just refusing to put anything important under it, like a landlord who installs circuit breakers and then powers the kitchen with extension cords.
Then: systemd --user
The shift is mental before it is technical.
Stop asking “where do I launch this?” Start asking “what is this unit’s lifetime?”
Rough buckets that map to systemd concepts:
- Always with my user manager — WantedBy
default.target. Runs when the user instance is up (login, or boot if lingering). - Only while a graphical session is up — PartOf / WantedBy
graphical-session.target(when your session actually raises it). - On a schedule — a
.timer+ oneshot.service. - Must survive logout / start at boot without a session — enable linger, then use (1) or (3) carefully.
That is the missing layer between “dotfiles that sprinkle processes” and “a desktop that behaves like a system.”
Minimal working example: a background helper
mkdir -p ~/.config/systemd/userA long-running helper that should come back if it dies, and does not need a display:
[Unit]Description=Local development helper
[Service]Type=execExecStart=%h/bin/dev-helperRestart=on-failureRestartSec=3Slice=background.slice
[Install]WantedBy=default.targetNo After=default.target. Target units already imply ordering for everything they pull in; pairing WantedBy=X.target with After=X.target creates an ordering cycle. systemd logs Found ordering cycle, breaks it by dropping a job (often yours), and the unit silently never starts — exactly the failure mode this article is arguing against.
Type=exec is a better default than Type=simple: the unit fails immediately if the binary cannot be exec’d, instead of reporting success and then dying. %h expands to your home directory. Restart=on-failure is the line your compositor config never had.
systemctl --user daemon-reloadsystemctl --user enable --now dev-helper.servicesystemctl --user status dev-helper.servicesystemctl --user list-dependencies default.targetjournalctl --user -b | grep -i cyclejournalctl --user -u dev-helper.service -fThat alone replaces a surprising amount of bash folklore.
Agents and mid-task logout
The intro mentioned coding agents orphaning themselves. Autostart cannot help with that — it only starts things. Under systemd you at least get to decide how stop behaves when the session ends. Add to the [Service] section of any long-running agent or helper that may be mid-task:
[Service]# ...TimeoutStopSec=120KillMode=mixedTimeoutStopSec= gives the process time to flush or checkpoint before SIGKILL. KillMode=mixed sends SIGTERM to the main process and SIGKILL to leftovers after the timeout — usually kinder than wiping the cgroup instantly, and clearer than hoping a compositor exec-once child survives logout by accident. Match the timeout to the work: a clipboard helper needs seconds; an agent mid-write may need a couple of minutes.
A timer instead of cron
[Unit]Description=Sync notes tree
[Service]Type=oneshotExecStart=%h/bin/sync-notes[Unit]Description=Run notes sync periodically
[Timer]OnCalendar=*:0/30Persistent=true
[Install]WantedBy=timers.targetsystemctl --user daemon-reloadsystemctl --user enable --now notes-sync.timersystemctl --user list-timersPersistent=true means if the machine was asleep or off at the scheduled time, systemd catches up when it can. Cron people reinvent that with anacron. Here it is a flag.
Something that should only live in a graphical session
This is where people get bitten — especially on Sway/Hyprland/niri setups that do not always raise the special user targets the way GNOME does.
From systemd.special(7), graphical-session.target is a passive user unit: it is active when a graphical session is running. Services that should die with the GUI should use PartOf=graphical-session.target.
Same trap as before, so if you need ordering at all, order on the pre target:
[Unit]Description=Clipboard helper for graphical sessionPartOf=graphical-session.targetAfter=graphical-session-pre.target
[Service]Type=execExecStart=/usr/bin/wl-paste --watch cliphist storeRestart=on-failureRestartSec=2
[Install]WantedBy=graphical-session.targetsystemctl --user daemon-reloadsystemctl --user enable clipboard-helper.serviceCaveat: many standalone Wayland compositors do not start graphical-session.target for you. If that target never becomes active, a WantedBy=graphical-session.target unit simply will not start. GNOME-style sessions tend to wire this; raw exec Hyprland often does not.
Practical options:
- Use UWSM (Universal Wayland Session Manager), which wraps compositors into systemd user units and binds into
graphical-session-pre.target/graphical-session.target/xdg-desktop-autostart.target. - Or import the environment from the compositor and start a session target yourself:
# from compositor startup (conceptually)systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOPdbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOPsystemctl --user start graphical-session.targetUWSM exists because this edge is sharp. If your helper does not need a display, prefer default.target and skip the drama.
Linger: when you need the user manager without a seat
By default the user instance starts on first login and goes away after the last session closes. Separately, logind’s KillUserProcesses= decides whether leftovers die with the session. Upstream’s default is no; Arch leaves that alone. Distros that flip it to yes are the exception — which is why logout orphans feel mysterious until you check your machine. Understand the user manager lifetime either way.
loginctl enable-lingerloginctl show-user "$USER" -p Linger# Linger=yesLingering starts user@.service at boot with no interactive sessions. Do not treat it as automatic graphical login — use it for background user workloads, not as a greeter. With linger, default.target units may start before WAYLAND_DISPLAY exists; keep display-bound units on graphical-session.target. Revoke with loginctl disable-linger when the machine should not keep a user manager warm.
Environment: the other footgun
User units do not inherit your .bashrc. XDG_RUNTIME_DIR is usually set (/run/user/$UID); DISPLAY / WAYLAND_DISPLAY often are not.
systemctl --user show-environmentsystemctl --user import-environment PATH WAYLAND_DISPLAY# and/or ~/.config/environment.d/*.confIf a unit fails under systemd but works in a terminal, compare environments first. Use dbus-update-activation-environment --systemd for display-adjacent tools. If systemctl --user cannot talk to the bus, check /run/user/$UID was not created by something else before rewriting units in anger.
A practical reader path
- Pick one long-running helper that currently lives in
exec-onceor a shell profile. - Move it to
~/.config/systemd/user/foo.servicewithWantedBy=default.targetandRestart=on-failure— and noAfter=default.target. daemon-reload,enable --now, watchjournalctl --user -u foo.- Only then decide if it needs linger, a timer,
TimeoutStopSec=for mid-task work, orgraphical-session.target. - Delete the old autostart line after a week of boredom (boredom means it worked).
Do not boil the ocean on day one. The win is replacing invisible failure with restart policy and logs.
Window manager choice still matters for how you feel at the keyboard. I’ve written about choosing Niri and moving development to a remote VM separately. This layer sits underneath those preferences. None of them remove the need for something that owns process lifetime on the local machine.
Tradeoffs worth saying out loud
systemd --user is not free complexity. Unit files live in your dotfiles. You learn default.target vs graphical-session.target. You will hit an environment bug. On some compositors you opt into session target integration (UWSM or manual) or graphical WantedBy lines silently no-op.
Compared to a shell script, that is more structure. Compared to a desktop that randomly sheds processes, that is less chaos.
I do not enable linger on every machine. A laptop that should sleep hard can skip it. A workstation hosting helpers and agent-facing services whether I’m at the seat or not is the point of linger.
Match the tool to the lifetime.
One more honest limitation: packaging units in a dotfiles repo is great until a machine-specific path sneaks in. Prefer %h, environment.d, and wrappers under ~/bin over hard-coded /home/yourname/.... Future you will thank present you for being boring.
Experiment
Steal the minimal unit. Break it on purpose. Watch the journal. Add Restart=. Move one graphical toy onto graphical-session.target and confirm your session actually raises that target:
systemctl --user is-active graphical-session.targetsystemctl --user list-dependencies graphical-session.targetTry a timer. Enable linger on a throwaway user account first if you’re nervous. If you run a standalone Wayland compositor, skim UWSM before inventing your own session target graph — reinventing session teardown is a classic way to lose an evening.
Send the interesting failure modes. Those are usually more educational than the happy path.
LogCTL Dispatch
New essays, experiments and workflows. No daily noise.
Usually 2–4 emails a month.
Comments
Loading comments…