Skip to main content

Unit File Anatomy

Learning Focus

By the end of this lesson you will be able to write a complete .path and .service unit pair from memory, understand every directive available in the [Path] section, and know which [Service] options are critical for path-triggered jobs.

Unit File Format

systemd unit files use the INI format — sections in brackets, directives as Key=Value pairs. A .path unit has up to three sections:

anatomy-overview.path
[Unit] # Metadata, dependencies, ordering
[Path] # What to watch and how to watch it
[Install] # How to enable the unit on boot

Its paired .service unit has:

anatomy-overview.service
[Unit] # Metadata, dependencies, ordering
[Service] # What to run, how to run it, security
# No [Install] section — activated by the .path unit

The .path Unit File

[Unit] Section

The [Unit] section provides metadata and declares dependencies. These directives are common to all systemd unit types.

DirectiveDescriptionExample
Description=Human-readable description shown in systemctl statusDescription=Watch /var/www/drop for incoming files
Documentation=Link to documentation (man page, URL)Documentation=man:systemd.path(5)
After=Start this unit after the listed units are activeAfter=network.target
Wants=Weak dependency — try to start the listed units, but don't fail if they can'tWants=network-online.target
Requires=Strong dependency — fail if the listed units can't startRequires=local-fs.target
ConditionPathExists=Only start if this path existsConditionPathExists=/var/www/html
ConditionPathIsDirectory=Only start if this path is a directoryConditionPathIsDirectory=/var/www/drop
example-unit-section.path
[Unit]
Description=Watch for incoming media files
Documentation=man:systemd.path(5)
After=local-fs.target
Wants=network-online.target

[Path] Section

The [Path] section is specific to .path units. It defines what to watch and how to handle the watched path.

Watch Directives

DirectiveDescriptionLoop Risk
PathExists=PATHTrigger when the path existsHigh
PathExistsGlob=PATTERNTrigger when at least one glob match existsHigh
PathChanged=PATHTrigger when path changes and fd is closedLow
PathModified=PATHTrigger on any write to the pathLow
DirectoryNotEmpty=PATHTrigger when directory contains at least one entryMedium

See Watch Directives for a deep dive on each.

Configuration Directives

DirectiveDescriptionDefaultExample
Unit=Name of the service unit to activate (if different from base name)Same base name .serviceUnit=nginx-reload.service
MakeDirectory=Create the watched path and all parents if they don't existnoMakeDirectory=yes
DirectoryMode=Octal permission mode for auto-created directories0755DirectoryMode=0775
TriggerLimitIntervalSec=Rate-limit window for path triggers (systemd 250+)2sTriggerLimitIntervalSec=5s
TriggerLimitBurst=Max triggers within the rate-limit window (systemd 250+)200TriggerLimitBurst=10
example-path-section.path
[Path]
DirectoryNotEmpty=/var/www/drop
MakeDirectory=yes
DirectoryMode=0775
Unit=process-drop.service

About MakeDirectory=

When MakeDirectory=yes is set:

  • systemd creates the entire path (including parents) if it doesn't exist.
  • Permissions are set according to DirectoryMode=.
  • The directory is created before the watch is registered.
  • This eliminates the common "directory not found" startup error.
make-directory-example.path
[Path]
DirectoryNotEmpty=/opt/reports/incoming/2026
MakeDirectory=yes
DirectoryMode=0775 # Group-writable for www-data

[Install] Section

The [Install] section determines how the unit is enabled on boot.

DirectiveDescriptionExample
WantedBy=Target(s) that want this unit when enabledWantedBy=paths.target
RequiredBy=Target(s) that require this unit when enabledRequiredBy=multi-user.target
Also=Additional units to enable/disable togetherAlso=process-drop.service
example-install-section.path
[Install]
WantedBy=paths.target
Why paths.target?

paths.target is a systemd target that groups all .path units. By setting WantedBy=paths.target, your path unit starts automatically at boot after all filesystems are mounted. This is the standard practice for all .path units.

Complete .path Template

complete-template.path
[Unit]
Description=Watch /var/www/drop for incoming files
Documentation=man:systemd.path(5)
After=local-fs.target

[Path]
DirectoryNotEmpty=/var/www/drop
MakeDirectory=yes
DirectoryMode=0775
# Unit=custom-service.service # Uncomment to override default name match

[Install]
WantedBy=paths.target

The Paired .service Unit File

Why the Service Must Be Type=oneshot

A path-triggered service runs once, processes the event, and exits. This maps directly to Type=oneshot:

Service TypeBehaviorSuitable for .path?
Type=oneshotRuns once, exits when done. systemd considers it "active" during execution.Yes — recommended
Type=simpleStarts a long-running process.No — path triggers expect short-lived jobs
Type=forkingStarts a daemon that forks.No — wrong model for event processing
Type=execSimilar to simple but waits for exec.Rarely — only if your command is a long-running processor

Why No [Install] Section

The .service unit should not have an [Install] section because:

  • It is activated by the .path unit, not by systemd at boot.
  • If you systemctl enable the service directly, it would run once at boot — which is usually not what you want.
  • The .path unit handles enabling and boot integration.

[Unit] Section

Same as the .path unit's [Unit] section. Critical directives for the service:

DirectiveDescriptionExample
Description=Human-readable descriptionDescription=Process one file from the drop folder
After=Start after these unitsAfter=network-online.target
Wants=Weak dependencyWants=network-online.target
StartLimitBurst=Max starts within the limit window (rate-limit)StartLimitBurst=5
StartLimitIntervalSec=Time window for the start rate limitStartLimitIntervalSec=30

[Service] Section

DirectiveDescriptionDefaultExample
Type=Service typesimpleType=oneshot
ExecStart=Command to execute (full absolute path required)ExecStart=/usr/local/bin/process.sh
ExecStartPre=Command to run BEFORE ExecStartExecStartPre=/usr/bin/test -d /var/www/drop
ExecStartPost=Command to run AFTER ExecStart succeedsExecStartPost=/usr/bin/logger "Job done"
User=Run the service as this userrootUser=www-data
Group=Run the service as this grouprootGroup=www-data
WorkingDirectory=Set the current working directory/WorkingDirectory=/var/www/html
Environment=Set environment variablesEnvironment="PATH=/usr/local/bin:/usr/bin"
EnvironmentFile=Load environment variables from a fileEnvironmentFile=/etc/default/myapp
StandardOutput=Where to send stdoutjournalStandardOutput=append:/var/log/job.log
StandardError=Where to send stderrjournalStandardError=append:/var/log/job.log
RuntimeMaxSec=Kill the process after this durationinfinityRuntimeMaxSec=30m
TimeoutStartSec=Max time to wait for start90sTimeoutStartSec=5m
Restart=When to restart the servicenoRestart=on-failure
RestartSec=Delay between restart attempts100msRestartSec=5s
Nice=Process niceness (-20 to 19)0Nice=10
IOSchedulingClass=I/O scheduler classnoneIOSchedulingClass=idle

Logging Options

ValueBehavior
StandardOutput=journalSend to journald (default, use journalctl to read)
StandardOutput=journal+consoleSend to both journald and console
StandardOutput=append:/path/to/fileAppend to a file
StandardOutput=truncate:/path/to/fileOverwrite a file each time
StandardOutput=nullDiscard output

Security Hardening Directives

These restrict what the service can do, reducing the blast radius of a compromised script:

DirectiveDescriptionExample
NoNewPrivileges=Prevent privilege escalationNoNewPrivileges=true
PrivateTmp=Give the service its own /tmpPrivateTmp=true
ProtectSystem=Make /usr, /boot, /efi read-onlyProtectSystem=strict
ProtectHome=Hide or make home directories read-onlyProtectHome=read-only
ReadWritePaths=Whitelist writable paths (used with ProtectSystem=strict)ReadWritePaths=/var/www/drop /var/log
ReadOnlyPaths=Force specific paths to be read-onlyReadOnlyPaths=/etc
PrivateDevices=Hide physical devicesPrivateDevices=true
ProtectKernelTunables=Prevent writing to kernel tunablesProtectKernelTunables=true
ProtectControlGroups=Prevent writing to cgroupsProtectControlGroups=true
MemoryDenyWriteExecute=Prevent creating writable+executable memoryMemoryDenyWriteExecute=true
RestrictRealtime=Prevent real-time schedulingRestrictRealtime=true

Complete .service Template

complete-template.service
[Unit]
Description=Process incoming files from the drop folder
After=network-online.target
Wants=network-online.target
StartLimitBurst=10
StartLimitIntervalSec=60

[Service]
Type=oneshot
User=www-data
Group=www-data
WorkingDirectory=/var/www/html
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
ExecStart=/usr/local/bin/process.sh
RuntimeMaxSec=30m
StandardOutput=append:/var/log/process-job.log
StandardError=append:/var/log/process-job.log

# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/log /var/www/drop

Specifiers (Variables in Unit Files)

systemd supports specifiers — special % tokens that are expanded at runtime:

SpecifierExpands ToExample
%nFull unit nameprocess-drop.path
%NUnescaped full unit nameprocess-drop.path
%pUnit name prefix (before the first .)process-drop
%iInstance name (for template units)csv in import@csv.service
%hHome directory of the user running the unit/home/user
%uUsername of the user running the unitwww-data
%UUID of the user running the unit33
%tRuntime directory/run (system) or /run/user/1000 (user)
%HHostnamevps

Specifier Example

user-path-with-specifiers.path
[Unit]
Description=Watch %h/Downloads for new files

[Path]
DirectoryNotEmpty=%h/Downloads
MakeDirectory=yes

[Install]
WantedBy=default.target

This expands to /home/user/Downloads when run as a user unit.


Template Units (Advanced)

Template units allow you to create reusable unit files that accept a parameter (instance name). The template file name contains an @:

import@.path → import@csv.path, import@json.path, import@xml.path
import@.service → import@csv.service, import@json.service, import@xml.service

The instance name is accessed via the %i specifier:

/etc/systemd/system/import@.path
[Unit]
Description=Watch for incoming %i files

[Path]
PathExistsGlob=/mnt/import/*.%i

[Install]
WantedBy=paths.target
/etc/systemd/system/import@.service
[Unit]
Description=Import %i files

[Service]
Type=oneshot
User=www-data
ExecStart=/usr/local/bin/import-%i.sh
use-template-units.sh
# Enable for CSV files
sudo systemctl enable --now import@csv.path

# Enable for JSON files
sudo systemctl enable --now import@json.path

# Check status
systemctl status import@csv.path
systemctl status import@json.path

Drop-In Overrides

Instead of editing a unit file directly (especially package-provided ones), you can create a drop-in override file:

create-override.sh
# Create an override directory
sudo mkdir -p /etc/systemd/system/process-drop.service.d/

# Create the override file
sudo tee /etc/systemd/system/process-drop.service.d/override.conf > /dev/null <<'EOF'
[Service]
# Override the runtime limit
RuntimeMaxSec=1h
# Add security hardening
NoNewPrivileges=true
ProtectSystem=strict
EOF

# Or use the built-in editor
sudo systemctl edit process-drop.service

The override is merged with the original unit file. You can override, add, or clear specific directives.

Clearing a Directive

To remove a directive set in the original unit, set it to an empty value:

override-clear.conf
[Service]
# Clear the Environment directive from the original
Environment=
# Set new environment
Environment="NEW_VAR=value"

Full Example — Annotated

Here is a complete, production-ready unit pair with every line annotated:

/etc/systemd/system/media-processor.path
[Unit]
Description=Watch for incoming media files to process
Documentation=man:systemd.path(5)
# Wait for local filesystems to be mounted
After=local-fs.target

[Path]
# Trigger when files appear in the queue directory
DirectoryNotEmpty=/var/media/queue
# Create the directory if it doesn't exist
MakeDirectory=yes
# Set permissions: owner rwx, group rwx, other r-x
DirectoryMode=0775
# Optional: explicitly name the target service (default: media-processor.service)
# Unit=media-processor.service

[Install]
# Start this path unit on boot as part of the paths.target group
WantedBy=paths.target
/etc/systemd/system/media-processor.service
[Unit]
Description=Process one media file from the queue
# Require network for uploading processed files
After=network-online.target
Wants=network-online.target
# Rate limit: max 10 starts in 60 seconds
StartLimitBurst=10
StartLimitIntervalSec=60

[Service]
# Run once and exit
Type=oneshot
# Run as www-data, not root
User=www-data
Group=www-data
# Set working directory for relative paths in the script
WorkingDirectory=/var/www/html
# Ensure PATH includes common binary locations
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# The actual command to run
ExecStart=/usr/local/bin/process-media.sh
# Kill the job if it runs longer than 30 minutes
RuntimeMaxSec=30m
# Log to both journald and a file
StandardOutput=append:/var/log/media-processor.log
StandardError=append:/var/log/media-processor.log

# --- Security hardening ---
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
# Only allow writes to these paths
ReadWritePaths=/var/log /var/media/queue /var/media/done

# No [Install] section — this service is activated by media-processor.path

Key Takeaways

  • A .path unit has three sections: [Unit], [Path], [Install].
  • A .service unit paired with a .path should use Type=oneshot and have no [Install] section.
  • MakeDirectory=yes eliminates startup errors by creating the watched path.
  • Security hardening (NoNewPrivileges, ProtectSystem, etc.) should be applied to every production service.
  • Template units (@) allow reusable unit files with instance-specific parameters.
  • Drop-in overrides (name.d/override.conf) let you customize units without modifying the original file.

What's Next