Skip to content
Go back

systemd-run: Units Without Unit Files

By KingPin 13 min read
systemd-run: Units Without Unit Files
Contents

You Already Have a Job Scheduler, a Process Babysitter, and a Cage

You need to run a long script and keep it alive after you close the terminal. So you type nohup ./job.sh &, close the laptop, and hope. Or you open tmux. Or you write a 12-line unit file, drop it in /etc/systemd/system/, run daemon-reload, and then forget to delete it.

systemd-run does all of that in one command. It creates a transient unit (a service, scope, or timer that exists only in memory and under /run), starts it, and lets systemd handle logging, limits, and cleanup. No file to write. No file to forget.

My opinion: for anything you’d call “one-off” or “let me just try this,” systemd-run is a better tool than nohup, screen, at, and ulimit on any box that runs systemd. You pay for it with a few extra characters of typing. Everything below was run on systemd 262, with --user so nothing needed root.

The Old Habits, Side by Side

You wantOld habitsystemd-run
Keep it running after logoutnohup cmd &, tmux, screensystemd-run --user cmd
Run it laterat now + 1 hoursystemd-run --on-active=1h cmd
Run it on a calendar schedulea crontab entry you forgetsystemd-run --on-calendar=... cmd
Cap memory or CPUulimit, cgexec, nice-p MemoryMax=... -p CPUQuota=...
Lock it downhand-rolled unshare or bubblewrap-p ProtectSystem=strict ...
Find the output laternohup.out, hopejournalctl -u name

The old tools each solve one row. systemd-run covers all of them with the same syntax, and the logs land in the journal where you already look.

A Transient Service in One Line

Terminal window
$ systemd-run --user --unit=backup-test sleep 60
Running as unit: backup-test.service; invocation ID: 55aa86f8777f488bb0021d5f52f0de88

That’s it. The process is now a real service. Check on it like any other:

Terminal window
$ systemctl --user status backup-test
● backup-test.service - [systemd-run] /usr/bin/sleep 60
Loaded: loaded (/run/user/1000/systemd/transient/backup-test.service; transient)
Transient: yes
Active: active (running) since Tue 2026-11-03 15:01:13 EST; 6ms ago
Main PID: 687472 ((sleep))

Note the Transient: yes line and the path under /run. That directory is a tmpfs, so a reboot erases the unit. You never wrote it, and you never have to clean it up.

If you skip --unit=, systemd invents a name like run-p687462-i9066693.service. Fine for throwaway jobs. Give it a name when you want to find it again with journalctl -u.

Stopping it is also the normal command:

Terminal window
$ systemctl --user stop backup-test
$ journalctl --user -u backup-test

After a stop, the unit is gone. systemctl --user status backup-test now says Unit backup-test.service could not be found.

System Mode vs --user Mode

Without --user, systemd-run talks to the system manager and needs root (or polkit approval). With --user, it talks to your own user manager. No privileges, and the unit runs as you.

Use --user by default. It’s the forklift-versus-pallet-jack choice: you rarely need the forklift, and the pallet jack doesn’t need a license.

User units have a shelf life. They normally die when your last login session ends. If a --user unit must outlive logout (a long download on a server you SSH into, say), turn on lingering once:

Terminal window
$ loginctl enable-linger

Per the man page, users can enable lingering for themselves in the default configuration. With lingering on, your user manager starts at boot and stays up after you log out, which is what screen people were faking all along.

Services vs Scopes: Who Owns the Process?

A service (the default) is started by systemd. systemd forks the command, holds it, and systemd-run returns immediately. The process is not a child of your shell.

A scope (--scope) is different. Your command starts as a child of systemd-run, in the foreground, and systemd only wraps it in a cgroup so it can track and limit it.

Terminal window
$ systemd-run --user --scope -p MemoryMax=50M echo inscope
Running as unit: run-p687532-i9093782.scope; invocation ID: b259d839624d4248b532e6cfb9a7f3c4
inscope

Pick by what you need:

Scopes have one limit worth knowing. They can’t be combined with timer options. Try it and you get Path, socket or timer options are not supported in --scope mode. If you want a delay, you need a service.

Resource Limits Without cgexec

This is where systemd-run replaces ulimit, nice, and cgexec outright. Pass any resource-control property with -p:

Terminal window
$ systemd-run --user --unit=reindex \
-p MemoryMax=2G \
-p CPUQuota=50% \
-p IOWeight=50 \
-p TasksMax=200 \
./reindex.sh

What those do (per systemd.resource-control):

The swap gotcha

I tested MemoryMax=30M with a Python script that allocates 200 MB. The script survived. The unit report said Memory peak: 30M (swap: 172M). The kernel pushed the excess into swap instead of killing anything.

If you want the kill, cap swap as well:

Terminal window
$ systemd-run --user --wait --pipe -p MemoryMax=30M -p MemorySwapMax=0 \
python3 -c 'x=bytearray(200*1024*1024); print("survived")'
Running as unit: run-p688430-i9079379.service
Finished with result: oom-kill
Main processes terminated with: code=killed, status=9/KILL

Now the limit means what you thought it meant. Your 2 AM self will appreciate knowing this before the swap partition fills.

Changing limits on a running unit

You don’t have to stop and restart. systemctl set-property works on transient units:

Terminal window
$ systemd-run --user --unit=big-job -p CPUQuota=50% sleep 300
$ systemctl --user set-property big-job CPUQuota=25%
$ systemctl --user show big-job -p CPUQuotaPerSecUSec
CPUQuotaPerSecUSec=250ms

That’s renice and cpulimit for people who read the manual.

Test Sandboxing Before It Goes in a Unit File

Hardening a service is mostly guesswork: you add ProtectSystem=strict, restart, and find out at 2 AM that the app needed to write somewhere. systemd-run lets you try the property on the actual command first.

Terminal window
$ systemd-run --user --wait --pipe \
-p ProtectSystem=strict -p ReadWritePaths=/tmp \
sh -c 'touch /etc/x; touch /tmp/ok && echo tmpok'
Running as unit: run-p687544-i9083464.service
touch: cannot touch '/etc/x': Read-only file system
tmpok

ProtectSystem=strict makes the whole filesystem read-only except what you list in ReadWritePaths=. The /etc write failed, the /tmp write worked. The same trick with home directories:

Terminal window
$ systemd-run --user --wait --pipe \
-p PrivateTmp=yes -p ProtectHome=read-only -p NoNewPrivileges=yes \
sh -c 'touch ~/x; ls /tmp | wc -l'
touch: cannot touch '/home/alice/x': Read-only file system
0

Here PrivateTmp=yes gave the process its own empty /tmp (the 0), ProtectHome=read-only blocked the write, and NoNewPrivileges=yes stops the process from gaining privileges through setuid binaries. Property names are the same ones documented in systemd.exec.

The workflow: run your real command with a candidate property, watch what breaks, loosen exactly that, then copy the working set into a unit file. Iterating with -p takes seconds. Iterating through daemon-reload and restarts takes minutes, and you’ll skip it.

Transient Timers: at and Cron’s Cleaner Cousin

Add a timer option and systemd-run creates a transient .timer plus the service it triggers:

Terminal window
$ systemd-run --user --unit=nightly-note --on-active=5min echo hi
Running timer as unit: nightly-note.timer
Will run service as unit: nightly-note.service

Check the queue with the same tool you use for real timers:

Terminal window
$ systemctl --user list-timers
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-11-03 15:06:14 EST 4min 59s - - nightly-note.timer nightly-note.service

The timer flags, which the man page describes as shortcuts for --timer-property= with the matching systemd.timer settings:

Terminal window
$ systemd-run --user --unit=poll --on-active=10 --on-unit-active=1h ./check.sh
Terminal window
$ systemd-run --user --unit=quarter-hour --on-calendar='*:0/15' ./sync.sh

To cancel any of them, stop the timer, not the service:

Terminal window
$ systemctl --user stop poll.timer

Compared to at, you get the journal, resource limits via -p, and a unit you can list and cancel by name. Compared to cron, you get no crontab edit and no forgetting. Remember that transient timers vanish at reboot. If a schedule must survive reboots, write the real timer unit.

Waiting, Piping, and Interactive Runs

By default systemd-run returns the moment the unit starts. Four flags change that.

--wait blocks until the unit finishes and prints a summary: result, exit status, runtime, CPU time, memory peak. It’s the one to use in scripts:

Terminal window
$ systemd-run --user --wait -p MemoryMax=64M -p CPUQuota=20% ./job.sh
Running as unit: run-p1234-i5678.service
Finished with result: success
Main processes terminated with: code=exited, status=0/SUCCESS
Service runtime: 13ms
CPU time consumed: 5ms
Memory peak: 2.2M (swap: 0B)

--pipe (-P) wires the service’s stdin, stdout, and stderr to your shell’s pipes, so the command works in a pipeline. Combine --wait --pipe and a transient service behaves like a normal command with a cgroup around it.

--pty (-t) connects the unit to your terminal through a pseudo-TTY, for things that expect interactive input. A shell, for instance:

Terminal window
$ systemd-run --user -t -p MemoryMax=500M bash

Everything you run in that shell lives under the 500M ceiling.

--no-block goes the other way: don’t even wait for the start job to finish.

Environment and working directory

A transient service does not inherit your shell’s directory or environment. Two flags fix that:

Terminal window
$ cd /srv/backup
$ systemd-run --user --wait --pipe --same-dir -E FOO=bar sh -c 'pwd; echo $FOO'
/srv/backup
bar

--same-dir (-d) uses your current directory. --working-directory=/some/path sets one explicitly. -E NAME=VALUE (--setenv) sets a variable. Leave off =VALUE and the variable is copied from your current environment.

What Happens After the Command Exits

A transient service that finishes successfully is unloaded right away. One that fails stays in memory, marked failed, until you clear it:

Terminal window
$ systemd-run --user --unit=flaky false
$ systemctl --user list-units --failed
UNIT LOAD ACTIVE SUB DESCRIPTION
● flaky.service loaded failed failed [systemd-run] /usr/bin/false
$ systemctl --user reset-failed flaky

That’s deliberate, since a failure is evidence you might want to read in the journal. But run a flaky job in a loop and you’ll fill list-units --failed with junk.

--collect (-G) tells systemd to unload the unit after it finishes, even on failure:

Terminal window
$ systemd-run --user --wait --collect --unit=flaky2 false
Finished with result: exit-code
$ systemctl --user status flaky2
Unit flaky2.service could not be found.

Use --collect for fire-and-forget jobs. Skip it when you want to inspect a failure afterward.

--remain-after-exit does the opposite: keep the unit around after the process exits, so you can still query its status and accounting. It can’t be combined with --wait. Try it and systemd-run answers --wait may not be combined with --remain-after-exit.

The journal entries outlive the unit either way, so journalctl --user -u reindex still works after a clean exit, as long as the journal itself keeps those logs.

-p vs the Dedicated Flags

systemd-run has shortcut flags for the common properties: --uid=, --gid=, --nice=, --slice=, --working-directory=, --setenv=. Each is equivalent to the matching property (--nice= is Nice= from systemd.exec, for example).

Use the dedicated flag where one exists, and -p (long form --property=) for everything else. Resource limits and sandboxing have no shortcut flags, so they’re always -p. You can repeat -p as often as you like.

For timers, --timer-property= passes settings to the timer unit instead of the service. The --on-* flags already cover the common ones.

run0: The Sudo Replacement Built on This

systemd 256 added run0. Per the NEWS file, systemd-run became a multi-call binary, and when invoked as run0 it runs a command as another user (root by default) through the same transient-unit machinery. There’s no setuid binary involved. It’s a different topic with a different set of trade-offs, so treat this as a pointer, not a tutorial.

What to Use When

  1. Background job that must survive a logout: systemd-run --user --unit=name cmd, plus loginctl enable-linger if the machine has no active session.
  2. Foreground command with a memory or CPU cap: systemd-run --user --scope -p MemoryMax=... cmd. Add -p MemorySwapMax=0 if you want a real kill.
  3. One-off delayed run: --on-active=. Recurring: add --on-unit-active=.
  4. Testing a hardening property: --wait --pipe -p ProtectSystem=strict ... until it stops breaking, then write the unit file.
  5. Script that needs the exit code and stats: --wait --collect.
  6. Anything permanent: write a real unit file. Transient means gone at reboot, and that’s the feature.

You don’t need a unit file for every idea. You just need one for the ideas that last.

Common Questions

Does systemd-run work without root?

Yes. Running systemd-run --user talks to your own user manager, so the unit runs as you and needs no privileges. Without --user, systemd-run targets the system manager and needs root or polkit authorization. Resource limits and sandbox properties work in user mode on systemd 262.

Do transient units survive a reboot?

No. Transient units live under /run, which is a tmpfs, so a reboot erases them along with their timers. For a schedule or service that must come back after boot, write a normal unit file and enable it. Journal logs may persist, depending on your journald storage setting.

Does systemd-run work inside a Docker container?

No, not in a standard Docker container. systemd-run needs a running systemd manager to talk to over D-Bus, and a typical container runs the application itself as PID 1 with no systemd at all. Containers that boot systemd as PID 1, such as systemd-nspawn machines, do support systemd-run.

Why did MemoryMax not kill my process?

Because the kernel can push the excess memory into swap instead of killing the process. In one test, a script that allocated 200 MB under MemoryMax=30M survived with 172 MB in swap. Add -p MemorySwapMax=0 to the command and the unit gets OOM-killed as expected.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
PUID and PGID: Container Permission Fights
Next Post
My 125B Local Run Ran Out of Weekend

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts