Skip to content
Subscribe

Linux Infrastructure

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.

Editorial illustration of a Linux desktop moving from scattered autostart scripts and fragile background processes into a clean supervised systemd user-session graph.
On this page
  1. The problem: the desktop as a bag of startups
  2. What I tried first
  3. Stuffing everything into shell profiles and compositor autostart
  4. Cron, @reboot, and the “clever” substitutes
  5. Desktop “Startup Applications”
  6. Why those solutions keep failing
  7. Then: systemd --user
  8. Minimal working example: a background helper
  9. Agents and mid-task logout
  10. A timer instead of cron
  11. Something that should only live in a graphical session
  12. Linger: when you need the user manager without a seat
  13. Environment: the other footgun
  14. A practical reader path
  15. Tradeoffs worth saying out loud
  16. 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 .desktop files
  • “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:

  1. Always with my user manager — WantedBy default.target. Runs when the user instance is up (login, or boot if lingering).
  2. Only while a graphical session is up — PartOf / WantedBy graphical-session.target (when your session actually raises it).
  3. On a schedule — a .timer + oneshot .service.
  4. 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

Terminal window
mkdir -p ~/.config/systemd/user

A long-running helper that should come back if it dies, and does not need a display:

~/.config/systemd/user/dev-helper.service
[Unit]
Description=Local development helper
[Service]
Type=exec
ExecStart=%h/bin/dev-helper
Restart=on-failure
RestartSec=3
Slice=background.slice
[Install]
WantedBy=default.target

No 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.

Terminal window
systemctl --user daemon-reload
systemctl --user enable --now dev-helper.service
systemctl --user status dev-helper.service
systemctl --user list-dependencies default.target
journalctl --user -b | grep -i cycle
journalctl --user -u dev-helper.service -f

That 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=120
KillMode=mixed

TimeoutStopSec= 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

~/.config/systemd/user/notes-sync.service
[Unit]
Description=Sync notes tree
[Service]
Type=oneshot
ExecStart=%h/bin/sync-notes
~/.config/systemd/user/notes-sync.timer
[Unit]
Description=Run notes sync periodically
[Timer]
OnCalendar=*:0/30
Persistent=true
[Install]
WantedBy=timers.target
Terminal window
systemctl --user daemon-reload
systemctl --user enable --now notes-sync.timer
systemctl --user list-timers

Persistent=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:

~/.config/systemd/user/clipboard-helper.service
[Unit]
Description=Clipboard helper for graphical session
PartOf=graphical-session.target
After=graphical-session-pre.target
[Service]
Type=exec
ExecStart=/usr/bin/wl-paste --watch cliphist store
Restart=on-failure
RestartSec=2
[Install]
WantedBy=graphical-session.target
Terminal window
systemctl --user daemon-reload
systemctl --user enable clipboard-helper.service

Caveat: 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:
Terminal window
# from compositor startup (conceptually)
systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP
dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP
systemctl --user start graphical-session.target

UWSM 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.

Terminal window
loginctl enable-linger
loginctl show-user "$USER" -p Linger
# Linger=yes

Lingering 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.

Terminal window
systemctl --user show-environment
systemctl --user import-environment PATH WAYLAND_DISPLAY
# and/or ~/.config/environment.d/*.conf

If 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

  1. Pick one long-running helper that currently lives in exec-once or a shell profile.
  2. Move it to ~/.config/systemd/user/foo.service with WantedBy=default.target and Restart=on-failure — and no After=default.target.
  3. daemon-reload, enable --now, watch journalctl --user -u foo.
  4. Only then decide if it needs linger, a timer, TimeoutStopSec= for mid-task work, or graphical-session.target.
  5. 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:

Terminal window
systemctl --user is-active graphical-session.target
systemctl --user list-dependencies graphical-session.target

Try 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.

Comments

Loading comments…