How to create a systemd template unit

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.

Steps to create a systemd template unit:

  1. Convert the first real instance label into a valid instantiated unit name.
    $ 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.

  2. Create the helper script that each instance will run.
    #!/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.

  3. Make the helper script executable.
    $ 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.

  4. Create the template unit file.
    [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.

  5. Verify that the template file parses cleanly before reloading the manager.
    $ 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.

  6. Reload the systemd manager so it notices the new template.
    $ sudo systemctl daemon-reload

    This reloads unit files and rebuilds the dependency tree before the first instance is started or enabled.

  7. Enable and start a specific instance from the template.
    $ 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.

  8. Confirm that the instance loaded from the template and ran successfully.
    $ 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.

  9. Confirm that the escaped and unescaped instance values were passed to the workload correctly.
    $ 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.

  10. Start another instance from the same template without creating another unit file.
    $ 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.