Skip to content
Search Fedora

    Browse this documentation

    systemd in practice: writing a service, a timer, and how to debug failures

    On Fedora, “make this start at boot” is not a line in /etc/rc.local — it is a unit file. Scheduled jobs work the same way: systemd timers are replacing cron (F45 minimal images do not even ship crontab).

    What follows was measured on F45.

    Terminal window
    systemctl --version | head -2
    systemctl get-default
    systemd 261 (261.2-1.fc45)
    +PAM +AUDIT +SELINUX -APPARMOR +IMA +IPE +SMACK +SECCOMP ...
    multi-user.target

    A command-line Fedora defaults to multi-user.target (no graphical session); the desktop editions use graphical.target.

    How many units are there:

    Terminal window
    systemctl list-unit-files --type=service --no-pager | tail -1
    systemctl list-unit-files --type=timer --no-pager | tail -1
    278 unit files listed.
    12 unit files listed.
    LocationWritten byPriorityOverwritten on upgrade
    /usr/lib/systemd/system/the package managerlowestyes
    /run/systemd/system/generated at runtimemiddlelost on reboot
    /etc/systemd/system/youhighestno

    Put your own units in /etc/systemd/system/. Do not edit files under /usr/lib — upgrades overwrite them and later nobody can tell who changed what.

    Terminal window
    sudo tee /etc/systemd/system/lab-hello.service >/dev/null <<'EOF'
    [Unit]
    Description=Lab hello service
    After=network-online.target
    [Service]
    Type=oneshot
    ExecStart=/bin/sh -c 'date >> /var/log/lab-hello.log; echo hello >> /var/log/lab-hello.log'
    RemainAfterExit=yes
    [Install]
    WantedBy=multi-user.target
    EOF
    sudo systemctl daemon-reload
    sudo systemctl start lab-hello.service
    systemctl status lab-hello.service --no-pager | head -6
    ● lab-hello.service - Lab hello service
    Loaded: loaded (/etc/systemd/system/lab-hello.service; disabled; preset: disabled)
    Drop-In: /usr/lib/systemd/system/service.d
    └─10-timeout-abort.conf
    Active: active (exited) since Tue 2026-10-27 17:41:46 UTC; 9ms ago
    Invocation: 45ec53c7e0f24c72883a6d099d034a05

    Three details worth reading carefully:

    • disabled — the file exists but you never enabled it, so it will not start at boot;
    • active (exited) — the normal state for oneshot with RemainAfterExit=yes: the script finished, the service still counts as active;
    • Drop-In — those lines are not yours; the distribution adds a shared timeout policy.
    TypeCounts as “started” whenSuits
    simple (default)the process has been spawnedlong-running foreground processes
    oneshotthe process has finishedone-off scripts, initialisation
    forkingthe parent exitsclassic daemons that daemonise themselves
    notifythe program calls sd_notifywhen “actually ready” matters

    systemd unit relations: enable creates a symlink under target.wants, and the timer triggers the matching service

    A timer needs a pair: a .service that does the work and a .timer that schedules it.

    Terminal window
    sudo tee /etc/systemd/system/lab-tick.service >/dev/null <<'EOF'
    [Unit]
    Description=Lab tick
    [Service]
    Type=oneshot
    ExecStart=/bin/sh -c 'echo tick $(date +%s) >> /var/log/lab-tick.log'
    EOF
    sudo tee /etc/systemd/system/lab-tick.timer >/dev/null <<'EOF'
    [Unit]
    Description=Lab tick every minute
    [Timer]
    OnCalendar=*:0/1
    Persistent=true
    [Install]
    WantedBy=timers.target
    EOF
    sudo systemctl daemon-reload
    sudo systemctl enable --now lab-tick.timer
    Created symlink '/etc/systemd/system/timers.target.wants/lab-tick.timer' → '/etc/systemd/system/lab-tick.timer'.

    enable --now creates a symlink under timers.target.wants/ — that symlink is what “start at boot” actually means. It is not an entry in some configuration file.

    When does it run next:

    Terminal window
    systemctl list-timers lab-tick.timer --no-pager
    NEXT LEFT LAST PASSED UNIT ACTIVATES
    Tue 2026-10-27 17:43:00 UTC 29s - - lab-tick.timer lab-tick.service

    The ACTIVATES column names the service it triggers.

    Persistent=true means a run missed while the machine was off is caught up on the next boot — something cron’s @reboot cannot do.

    FormMeaning
    *:0/1every minute
    hourly / daily / weeklyfixed periods
    Mon..Fri 02:00weekdays at 02:00
    *-*-* 03:30:00every day at 03:30
    OnUnitActiveSec=5min5 minutes after the last run (relative)

    Validate an expression without waiting for it:

    Terminal window
    systemd-analyze calendar "*:0/1"

    Deliberately failing a service produces:

    ● lab-fail.service - Lab failing service
    Loaded: loaded (/etc/systemd/system/lab-fail.service; static)
    Active: failed (Result: exit-code) since Tue 2026-10-27 17:43:03 UTC
    Process: 24880 ExecStart=/bin/false (code=exited, status=1/FAILURE)
    Main PID: 24880 (code=exited, status=1/FAILURE)
    Mem peak: 1.2M

    The Process: lines are where debugging starts — they give an exit code instead of a vague “failed to start”.

    The order to follow:

    Terminal window
    systemctl list-units --failed # 1) what failed this boot
    systemctl status <unit> --no-pager # 2) exit code and reason
    journalctl -u <unit> -n 50 --no-pager # 3) the actual log
    journalctl -xeu <unit> # 4) log with systemd's explanations
    UNIT LOAD ACTIVE SUB DESCRIPTION
    ● dnf-makecache.service loaded failed failed dnf5 makecache
    ● lab-fail.service loaded failed failed Lab failing service

    Changing someone else’s unit: use a drop-in

    Section titled “Changing someone else’s unit: use a drop-in”

    Never edit the file under /usr/lib. Instead:

    Terminal window
    sudo systemctl edit <unit> # creates /etc/systemd/system/<unit>.d/override.conf
    sudo systemctl daemon-reload

    edit opens an empty file; you write only what you want to override or append:

    [Service]
    Environment=HTTP_PROXY=http://proxy.internal:3128
    TimeoutStartSec=180

    Upgrades keep the distribution’s changes and yours survive alongside them.

    Measured in a GNOME terminal: systemctl --failed lists nothing; systemd-analyze reports kernel 1.773s + initrd 15.691s + userspace 33.379s; blame is topped by device waits such as dev-ttyS3.device

    Terminal window
    systemd-analyze
    Startup finished in 1.502s (kernel) + 2.497s (initrd) + 7.888s (userspace) = 11.888s

    The three parts are kernel, initrd and userspace. If userspace dominates, look at blame:

    Terminal window
    systemd-analyze blame | head -3
    11.713s unbound-anchor.service
    3.798s sys-module-fuse.device
    3.763s dev-vport1p1.device

    unbound-anchor.service on top is exactly the service that kept DNS resolution failing on this machine — it was stuck waiting to refresh the upstream root key. The “slow boot” and the “weird network” were the same root cause.

    GoalCommand
    statesystemctl status <unit> --no-pager
    start / stopsystemctl start / stop
    start at bootsystemctl enable --now <unit>
    stop starting at bootsystemctl disable --now <unit>
    block entirelysystemctl mask <unit>
    list failuressystemctl list-units --failed
    show the unit filesystemctl cat <unit>
    show effective valuessystemctl show <unit> -p <property>
    reload after editssystemctl daemon-reload
    timerssystemctl list-timers --all