LFCS 63 🐧 Creating Custom systemd Units
Creating a custom systemd unit is the operational bridge between “I have an executable” and “systemd manages this process, restarts it when it crashes, and starts it at boot.” The LFCS exam tests this as a sequence of file creation, section configuration, cache refresh, and enablement—not as a single command. The most common failure is not a syntax error in the unit file; it is skipping systemctl daemon-reload after creating the file, which leaves systemd unaware that the unit exists and produces a “Unit not found” error even though the file is on disk .
The unit file itself is an INI-format document with three sections. [Unit] carries the description and ordering directives like After=network.target. [Service] carries the execution model: ExecStart, Type, Restart, and User. [Install] carries the boot target that systemctl enable reads to create the symlink in the target’s .wants directory . The Type= directive is the most frequently misunderstood: simple for foreground processes (the preferred modern pattern), forking for legacy daemons that background themselves, and oneshot for scripts that run once and exit .
This chapter covers the complete custom unit creation workflow, the [Unit], [Service], and [Install] directives that the exam tests, the Type= decision, the daemon-reload requirement, and the verification steps that confirm the unit will survive a reboot. It assumes familiarity with systemctl service control and unit file structure from the preceding chapters.
Key point: A custom unit is not recognized by systemd until daemon-reload runs. The file can be syntactically correct, in the right directory, with the right permissions—and systemctl start will still fail with “Unit not found” if the cache has not been refreshed. daemon-reload is not optional; it is the step that makes the file exist from systemd’s perspective.
Why custom units are an exam domain
The exam tests creation, not just control. Starting and stopping a service is a systemctl command. Creating a service requires writing a unit file, placing it in /etc/systemd/system/, reloading the daemon, and enabling it . The LFCS performance-based format includes tasks that require this sequence, and the grader reboots the machine to verify persistence.
The [Install] section is the enable contract. systemctl enable reads the [Install] section and creates a symlink in the target’s .wants directory based on WantedBy= . A unit file without an [Install] section can be started manually but cannot be enabled. The exam may present a custom unit and ask why it does not start after reboot; the answer is almost always a missing [Install] section or a missing WantedBy target.
The Type= choice determines whether systemd’s status matches reality. A service that forks into the background but is declared Type=simple will appear to have exited immediately, and systemd will mark the unit inactive (dead) while the process is still running. The Red Hat documentation recommends Type=forking with PIDFile= for daemons that make the fork system call, and Type=simple for foreground processes . The Debian wiki is more direct: “Most services should use the simple type… Running in the foreground is preferred” .
The trade-off. A unit file is more verbose than a shell script with nohup, but it provides restart policy, dependency ordering, user isolation, and boot persistence in a declarative format. The exam tests the verbosity because production systems depend on it.
a. The creation workflow: file, reload, enable, verify
The workflow has five steps, and skipping any of them produces a unit that either does not start or does not survive a reboot. The Red Hat documentation lists them explicitly: create the file in /etc/systemd/system/, set permissions (the file does not need to be executable), add the configuration, run daemon-reload, then start and enable .
# /etc/systemd/system/my_app.service
[Unit]
Description=My Python Web Application
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/my_app/app.py
Restart=always
RestartSec=5
User=appuser
WorkingDirectory=/opt/my_app
[Install]
WantedBy=multi-user.target
The [Unit] section declares the description and ordering. After=network.target ensures the service starts after networking is available, which matters for applications that bind to a port . The [Service] section declares the execution model. Type=simple means the process started by ExecStart is the main process and stays in the foreground. Restart=always configures systemd to restart the process if it exits, and RestartSec=5 waits five seconds before restarting, preventing a rapid crash loop . User=appuser runs the service as a non-root user. The [Install] section declares that enable should create a symlink in multi-user.target.wants .
After writing the file, systemctl daemon-reload refreshes systemd’s cache. The Red Hat documentation warns that without this command, systemctl start or systemctl enable may fail because the systemd state and the on-disk unit file state are mismatched . Then systemctl enable --now my_app enables and starts the service in one command, satisfying both the boot persistence and current execution requirements.
# Reload systemd's unit cache to recognize the new file.
sudo systemctl daemon-reload
# Enable at boot and start now.
sudo systemctl enable --now my_app
# Verify both states.
systemctl is-active my_app # active
systemctl is-enabled my_app # enabled
b. The Type= decision and the execution model
The Type= directive tells systemd how to interpret the process that ExecStart launches. simple (the default) means the process runs in the foreground and systemd considers the unit started immediately after launching it. forking means the process forks into the background and the parent exits; systemd must track the child via PIDFile=. oneshot is for scripts that run once and exit, and RemainAfterExit=yes keeps the unit marked active after the process exits .
# A daemon that backgrounds itself.
[Service]
Type=forking
ExecStart=/usr/sbin/mydaemon
PIDFile=/run/mydaemon.pid
# A script that runs once.
[Service]
Type=oneshot
ExecStart=/usr/local/bin/setup.sh
RemainAfterExit=yes
The Type=simple + foreground pattern is preferred for new applications. The Debian wiki notes that if a service normally runs itself in the background, you should search the documentation for an option to disable that and run in the foreground instead . This gives systemd direct supervision of the main process without PID file parsing.
For a Type=oneshot service without RemainAfterExit=yes, the unit becomes inactive (dead) after the script exits, even if the script succeeded. The RemainAfterExit directive keeps the unit active so systemctl is-active returns active, which is useful for setup scripts that other units depend on .
c. Verification and the reboot test
The exam grader reboots the machine. A unit that is active but not enabled fails the persistence requirement. A unit that is enabled but has a Type= mismatch may fail to start at boot because the process model does not match systemd’s expectations. The verification step must check both states and, for custom units, inspect the merged configuration.
# Both must return the expected state.
systemctl is-active my_app
systemctl is-enabled my_app
# For custom units, check that systemd parses the file correctly.
systemctl cat my_app
# If the service fails, read the journal.
journalctl -u my_app -n 50
systemctl cat shows the unit file as systemd sees it, including the base file and any drop-ins. If the output is missing directives or shows an error, the file has a syntax problem or the daemon-reload did not run. The journal is the diagnostic tool for runtime failures: a missing ExecStart path, a user that does not exist, or a permission error all appear in journalctl -u my_app .
The [Install] section is the final verification target. A unit with WantedBy=multi-user.target and a successful enable creates a symlink at /etc/systemd/system/multi-user.target.wants/my_app.service. The is-enabled command confirms this symlink exists. If enable fails, the [Install] section is missing or malformed.
Complete Example Session
# ============================================
# PART 1: THE COMPLETE CUSTOM UNIT
# ============================================
# File: /etc/systemd/system/my_app.service
[Unit]
Description=My Python Web Application
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /opt/my_app/app.py
Restart=always
RestartSec=5
User=appuser
WorkingDirectory=/opt/my_app
[Install]
WantedBy=multi-user.target
# ============================================
# PART 2: SECTION BREAKDOWN
# ============================================
# [Unit]
# Description → shown in status and journal.
# After=network.target → start after network.
#
# [Service]
# Type=simple → foreground process.
# ExecStart → command to run.
# Restart=always → restart on any exit.
# RestartSec=5 → wait 5s between restarts.
# User=appuser → non-root execution.
# WorkingDirectory → process CWD.
#
# [Install]
# WantedBy=multi-user.target → enable target.
# This is what makes boot persistence work.
# ============================================
# PART 3: CREATE THE FILE
# ============================================
# sudo tee /etc/systemd/system/my_app.service <<'EOF'
# [Unit]
# Description=My App
# After=network.target
#
# [Service]
# ExecStart=/usr/bin/python3 /opt/my_app/app.py
# Restart=always
#
# [Install]
# WantedBy=multi-user.target
# EOF
# ============================================
# PART 4: RELOAD AND ENABLE
# ============================================
# daemon-reload is mandatory after file creation.
sudo systemctl daemon-reload
# Enable at boot and start now.
sudo systemctl enable --now my_app
# ============================================
# PART 5: VERIFY
# ============================================
systemctl is-active my_app # active
systemctl is-enabled my_app # enabled
systemctl status my_app # shows PID and logs
systemctl cat my_app # merged configuration
# ============================================
# PART 6: TYPE=FORKING FOR DAEMONS
# ============================================
# If the application backgrounds itself:
# [Service]
# Type=forking
# ExecStart=/usr/sbin/mydaemon
# PIDFile=/run/mydaemon.pid
#
# Without Type=forking, systemd thinks the
# service exited when the parent returns.
# ============================================
# PART 7: TYPE=ONESHOT FOR SCRIPTS
# ============================================
# If the service runs once and exits:
# [Service]
# Type=oneshot
# ExecStart=/usr/local/bin/setup.sh
# RemainAfterExit=yes
#
# RemainAfterExit keeps is-active = active.
# ============================================
# PART 8: TROUBLESHOOT FAILURES
# ============================================
# "Unit not found" → daemon-reload not run.
# "Failed to start" → journalctl -u my_app.
# "enable fails" → missing [Install] section.
# "inactive (dead)" → check Type= and ExecStart.
# ============================================
# PART 9: WHY ENABLE FAILS WITHOUT [INSTALL]
# ============================================
# A unit with no [Install] section:
# systemctl enable my_app
# → error: no installation config
#
# Fix: add [Install] with WantedBy=target.
# ============================================
# PART 10: THE EXAM CHECKLIST
# ============================================
# 1. File in /etc/systemd/system/<name>.service
# 2. Sections: [Unit], [Service], [Install]
# 3. ExecStart points to executable
# 4. [Install] has WantedBy=multi-user.target
# 5. sudo systemctl daemon-reload
# 6. sudo systemctl enable --now <name>
# 7. Verify: is-active AND is-enabled
# 8. Grader reboots. Enabled is mandatory.
Quick Reference
Creation Workflow
| Step | Command | Purpose |
|---|---|---|
| 1 | touch /etc/systemd/system/<name>.service | Create file |
| 2 | Add sections | Configure unit |
| 3 | systemctl daemon-reload | Refresh cache |
| 4 | systemctl enable --now <name> | Enable and start |
| 5 | is-active + is-enabled | Verify |
[Service] Type Options
| Type | Meaning | Use When |
|---|---|---|
simple | Foreground process (default) | Most applications |
forking | Backgrounds itself | Legacy daemons |
oneshot | Runs once, exits | Scripts |
notify | Signals readiness via sd_notify | Modern services |
Essential Directives
| Directive | Section | Purpose |
|---|---|---|
Description | [Unit] | Shown in status/journal |
After=network.target | [Unit] | Ordering |
ExecStart | [Service] | Command to run |
Restart=always | [Service] | Restart on exit |
RestartSec=5 | [Service] | Delay before restart |
User=appuser | [Service] | Non-root execution |
WantedBy=multi-user.target | [Install] | Enable target |
Common Errors
| Error | Cause | Fix |
|---|---|---|
| “Unit not found” | daemon-reload not run | Run it |
enable fails | Missing [Install] | Add WantedBy |
| Status shows dead but running | Type=simple with forking daemon | Use Type=forking |
| Oneshot shows inactive | No RemainAfterExit=yes | Add it |
Best Practices
✅ Do This:
# Place custom units in /etc/systemd/system. // ✅
# /etc/systemd/system/my_app.service
# Include [Install] with WantedBy. // ✅
[Install]
WantedBy=multi-user.target
# Use Type=simple and run in foreground. // ✅
Type=simple
ExecStart=/usr/bin/myapp
# Run daemon-reload after creation. // ✅
sudo systemctl daemon-reload
❌ Don’t Do This:
# Skip daemon-reload. // ❌
# systemctl start fails with "Unit not found."
# Omit [Install] section. // ❌
# enable fails with "no installation config."
# Use Type=simple for a forking daemon. // ❌
# systemd marks the unit dead while the process runs.
# Run as root without a specific reason. // ❌
User=appuser // Use a non-root user.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Unit not found after creation | daemon-reload not run | Run it before start |
| Cannot enable custom unit | Missing [Install] section | Add WantedBy=multi-user.target |
| Status shows inactive but process runs | Type=simple with forking daemon | Use Type=forking |
| Oneshot unit shows dead after success | No RemainAfterExit=yes | Add it |
| Service fails at boot but works manually | Missing After=network.target | Add ordering |
Real-World Examples
1. Minimal Custom Unit
[Unit]
Description=My App
[Service]
ExecStart=/usr/bin/myapp
[Install]
WantedBy=multi-user.target
2. Network-Dependent Service
[Unit]
After=network.target
3. Restart Policy
[Service]
Restart=always
RestartSec=5
4. Non-Root User
[Service]
User=appuser
WorkingDirectory=/opt/app
5. Forking Daemon
[Service]
Type=forking
PIDFile=/run/mydaemon.pid
6. Oneshot Script
[Service]
Type=oneshot
RemainAfterExit=yes
7. Reload After Creation
sudo systemctl daemon-reload
8. Enable and Start
sudo systemctl enable --now my_app
9. Verify Merged Config
systemctl cat my_app
10. Diagnose Failure
journalctl -u my_app -n 50
Visual
Custom Unit Creation Flow
┌──────────────────────────────────────────────┐
│ CREATE → RELOAD → ENABLE → VERIFY │
│ │
│ 1. Write /etc/systemd/system/my_app.service │
│ │ │
│ ▼ │
│ 2. systemctl daemon-reload │
│ │ │
│ │ systemd reads the new file │
│ ▼ │
│ 3. systemctl enable --now my_app │
│ │ │
│ │ creates symlink + starts │
│ ▼ │
│ 4. is-active + is-enabled │
│ │ │
│ ├── active / enabled → PASS │
│ └── inactive / disabled → FAIL │
│ │
│ Skip step 2 → step 3 fails. │
│ │
└──────────────────────────────────────────────┘
Type= Decision
┌──────────────────────────────────────────────┐
│ WHICH TYPE? │
│ │
│ Runs in foreground, stays running? │
│ └── Type=simple (default, preferred) │
│ │
│ Forks into background, writes PID? │
│ └── Type=forking + PIDFile= │
│ │
│ Runs once, exits, should stay active? │
│ └── Type=oneshot + RemainAfterExit=yes │
│ │
│ Wrong type → status lies about state. │
│ │
└──────────────────────────────────────────────┘
Why Enable Fails
┌──────────────────────────────────────────────┐
│ [Install] IS THE ENABLE CONTRACT │
│ │
│ Unit with [Install]: │
│ WantedBy=multi-user.target │
│ │ │
│ ▼ │
│ systemctl enable creates: │
│ multi-user.target.wants/my_app.service │
│ │
│ Unit without [Install]: │
│ systemctl enable → error │
│ "no installation config" │
│ │
│ Fix: add [Install] with WantedBy=target. │
│ │
└──────────────────────────────────────────────┘
Exam Verification
┌──────────────────────────────────────────────┐
│ BEFORE SUBMITTING │
│ │
│ systemctl is-active my_app │
│ → must return "active" │
│ │
│ systemctl is-enabled my_app │
│ → must return "enabled" │
│ │
│ If custom unit: │
│ systemctl cat my_app │
│ → file parses correctly │
│ │
│ If failure: │
│ journalctl -u my_app -n 50 │
│ │
│ Grader reboots. Both states matter. │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Unit file location | /etc/systemd/system/<name>.service |
| Required sections | [Unit], [Service], [Install] |
ExecStart | Full path to executable |
Type=simple | Foreground process (default) |
Type=forking | Background daemon, use PIDFile |
Type=oneshot | Script, use RemainAfterExit |
Restart=always | Restart on any exit |
WantedBy=multi-user.target | Enable target |
daemon-reload | Required after file creation |
| Verification | is-active + is-enabled |
Key takeaways:
daemon-reloadis the step that makes the file exist. systemd caches unit files. Without the reload,systemctl startreturns “Unit not found” even though the file is on disk .- The
[Install]section is mandatory for enable.WantedBy=multi-user.targettellssystemctl enablewhere to create the symlink. Without it, the unit can start manually but cannot survive a reboot . Type=determines whether status matches reality. A forking daemon withType=simpleappears dead while running. Usesimplefor foreground,forkingfor background daemons,oneshotfor scripts .enable --nowis the exam-safe command. It satisfies both the boot persistence and current execution requirements in one command, reducing the chance of forgetting one of them.- Verify both
is-activeandis-enabled. The grader reboots the machine. A unit that is active but not enabled fails the persistence test. - The journal is the diagnostic tool.
journalctl -u my_appshows why a service failed to start, whether the cause is a missing executable, a user that does not exist, or a permission error.
Remember: A custom systemd unit is a contract in three sections. [Unit] says what the service is and what it depends on. [Service] says how to run it, with Type= matching the process model. [Install] says when to start it at boot. The file goes in /etc/systemd/system/, the daemon-reload makes it visible, and enable --now makes it persistent. The exam grader reboots. The unit must be enabled, not just started. The journal is where failures explain themselves. Get the Type= right, the [Install] section present, and the reload executed, and the custom unit becomes indistinguishable from a package-provided service.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!