A template unit lets systemd apply one reusable unit definition to many instances, which is useful when the same service shape should run per tenant, queue, mount, serial port, or other variable input. That avoids cloning near-identical unit files just to change one name or argument.
In systemd, a template unit uses a single @ before the unit type suffix, such as demo-worker@.service. When a specific instance such as demo-worker@tenant-api.service is started or enabled, systemd looks for that exact file first and then falls back to the matching template file if only demo-worker@.service exists. Inside the template, %i expands to the escaped instance string and %I expands to the unescaped form, which is why tools such as systemd-escape --template=... are useful when the real instance label contains characters like slashes or spaces.
System template units belong under /etc/systemd/system/ and need root access. After creating or editing the file, reload the manager before starting or enabling an instance. Most template units must be started or enabled with an explicit instance name, and current upstream rules only let the bare template name be enabled directly when the unit defines DefaultInstance=. A syntax check with systemd-analyze verify is still worth running first, but existing units on the host can also emit unrelated warnings during that pass.
$ systemd-escape --template=demo-worker@.service 'tenant/api' demo-worker@tenant-api.service
Use the escaped name in systemctl commands and keep the original label for application logic or reporting. Current upstream systemd-escape behavior also supports reversing the process with systemd-escape -u --template=demo-worker@.service demo-worker@tenant-api.service when the original label needs to be inspected again.
#!/usr/bin/env bash
set -euo pipefail
escaped="$1"
unescaped="$2"
log_dir=/var/log/demo-worker-template
install -d -m 0755 "$log_dir"
{
echo "instance=$escaped"
echo "unescaped=$unescaped"
echo "started=$(date --iso-8601=seconds)"
} > "$log_dir/${escaped}.log"
This small script makes the template behavior visible by writing one log file per instance. Replace it with the real workload once the unit structure is proven.
$ sudo chmod 0755 /usr/local/bin/demo-worker-template.sh
Use an absolute executable path in the unit file so the service does not depend on an interactive shell PATH.
[Unit] Description=Demo worker for %I [Service] Type=oneshot ExecStart=/usr/local/bin/demo-worker-template.sh %i %I RemainAfterExit=yes [Install] WantedBy=multi-user.target DefaultInstance=alpha
%i expands to the escaped instance name such as tenant-api, while %I expands to the unescaped form such as tenant/api. RemainAfterExit=yes keeps this oneshot example in active (exited) after a successful run so the instance state stays visible in systemctl status.
WantedBy=multi-user.target fits a service template that should be available in a normal multi-user boot. DefaultInstance=alpha allows systemctl enable demo-worker@.service to enable demo-worker@alpha.service without naming an instance explicitly.
$ sudo systemd-analyze verify /etc/systemd/system/demo-worker@.service
No output from the new unit is the ideal result. Current upstream systemd-analyze verify behavior also loads referenced or existing units, so a warning can point at some other file on the host rather than the template that was just written.
$ sudo systemctl daemon-reload
This reloads unit files and rebuilds the dependency tree before the first instance is started or enabled.
$ sudo systemctl enable --now demo-worker@tenant-api.service Created symlink /etc/systemd/system/multi-user.target.wants/demo-worker@tenant-api.service → /etc/systemd/system/demo-worker@.service.
For an instantiated unit, the enablement symlink is named after the instance but points back to the single template file. This is how one template can define many persistent instances.
Because this example defines DefaultInstance=alpha, sudo systemctl enable demo-worker@.service would also work and would enable demo-worker@alpha.service. Without DefaultInstance=, use an explicit instance name as shown here.
$ systemctl status --no-pager --full demo-worker@tenant-api.service | head -n 7
● demo-worker@tenant-api.service - Demo worker for tenant/api
Loaded: loaded (/etc/systemd/system/demo-worker@.service; enabled; preset: enabled)
Active: active (exited) since Mon 2026-04-13 21:45:01 +08; 3ms ago
Process: 1562 ExecStart=/usr/local/bin/demo-worker-template.sh tenant-api tenant/api (code=exited, status=0/SUCCESS)
Main PID: 1562 (code=exited, status=0/SUCCESS)
CPU: 1ms
The success state for this oneshot example is Loaded: loaded (/etc/systemd/system/demo-worker@.service…) together with Active: active (exited) and a zero exit status. A long-running template service would usually stay in active (running) instead.
$ cat /var/log/demo-worker-template/tenant-api.log instance=tenant-api unescaped=tenant/api started=2026-04-13T21:45:01+08:00
The log shows why template specifiers matter: the instantiated unit name stays safely escaped for filenames and unit names, while the original label is still available to the workload through %I.
$ sudo systemctl start demo-worker@backup-db.service $ ls -1 /var/log/demo-worker-template backup-db.log tenant-api.log
The second log file proves that one template can serve multiple instances independently. Each instance can have its own enablement state, status, and runtime data even though all of them come from the same @.service definition.