A service package often needs a dedicated account before it can own files or run without root. systemd-sysusers describes that account and its groups in a small configuration file. Its central operation is creating missing identities and establishing requested memberships. It is not a general account migration engine, a directory creator, or a guarantee that automatically allocated numeric IDs match on every host.

When it breaks

A u entry creates a missing system user and normally its matching primary group. A g entry creates a group, and m adds a supplementary membership. An ID of - requests automatic allocation. An r entry constrains the allocation pool, but another account already occupying a number changes what is available. The same pool therefore does not establish cross-machine identity equality.

Changing a declaration for an existing account does not automatically renumber that account or update its home directory and descriptive fields. This behavior makes repeated package setup useful, but it also means a configuration diff is not proof that live account records changed. A real UID migration needs a separate plan for existing ownership, running processes, shared storage, and rollback.

The home-directory column sets an account-database field; it does not create the directory. A service can consequently have a valid account and still fail because its state path is missing or unwritable. Use an appropriate managed service directory or a tmpfiles rule when a directory must be created with defined ownership and permissions.

Check it quickly

Run this Python script as an administrator in a disposable Linux environment containing systemd-sysusers. It does not need to alter the machine’s live account database. The recorded run used Debian 12 with systemd 252.39. Each test operates on files inside a separate temporary root selected with --root; shadow database contents are compared internally and never printed:

from pathlib import Path
from tempfile import TemporaryDirectory
import subprocess

definition='''r - 61000-61010
u field-worker - "Field worker" /var/lib/field-worker /usr/sbin/nologin
g field-readers -
m field-worker field-readers
'''
def prepare(root,occupied=None):
    (root/'etc').mkdir(parents=True)
    passwd='root:x:0:0:root:/root:/bin/sh\n';group='root:x:0:\n'
    if occupied is not None:
        passwd+=f'occupied:x:{occupied}:{occupied}:Fixture:/nonexistent:/usr/sbin/nologin\n'
        group+=f'occupied:x:{occupied}:\n'
    (root/'etc/passwd').write_text(passwd);(root/'etc/group').write_text(group)
    directory=root/'usr/lib/sysusers.d';directory.mkdir(parents=True)
    (directory/'field.conf').write_text(definition)
def apply(root):
    result=subprocess.run(['systemd-sysusers','--root='+str(root)],capture_output=True,text=True,timeout=10)
    assert result.returncode==0,result.stderr
def entries(root,name):
    return {line.split(':')[0]:line.split(':') for line in (root/'etc'/name).read_text().splitlines()}
with TemporaryDirectory(prefix='xbcatm-sysusers-') as directory:
    first=Path(directory)/'first';second=Path(directory)/'second';prepare(first);apply(first)
    worker=entries(first,'passwd')['field-worker'];groups=entries(first,'group')
    assert worker[3]==groups['field-worker'][2]
    assert 'field-worker' in groups['field-readers'][3].split(',')
    assert worker[5:]==['/var/lib/field-worker','/usr/sbin/nologin']
    assert not (first/'var/lib/field-worker').exists()
    before={name:(first/'etc'/name).read_bytes() for name in ('passwd','group','shadow','gshadow')}
    apply(first)
    assert before=={name:(first/'etc'/name).read_bytes() for name in before}
    print('user, primary group, and supplementary membership created; repeat leaves databases unchanged')
    print('home and nologin shell recorded; home directory was not created')
    prepare(second,int(worker[2]));apply(second)
    other=entries(second,'passwd')['field-worker']
    assert other[2]!=worker[2]
    print('same allocation range and definition choose different UIDs when existing accounts differ')
    changed=definition.replace('u field-worker - "Field worker" /var/lib/field-worker',
                               'u field-worker 62000 "Changed description" /changed-home')
    (first/'usr/lib/sysusers.d/field.conf').write_text(changed);apply(first)
    assert entries(first,'passwd')['field-worker']==worker
    print('changing a declaration does not renumber or rewrite an existing account')
    assert not (first/'changed-home').exists()
print('only temporary alternate-root account databases were changed')

The first root begins with only its fixture root account. Applying the declaration creates the worker, its primary group, the readers group, and the worker’s supplementary membership. The script checks the numeric primary-group relationship and actual membership list. A successfully parsed configuration alone would not establish either relationship.

The account record contains the requested home path and a nologin shell, but that home directory does not exist. This directly separates identity declaration from filesystem provisioning. An account intended for service execution can still be used by an authorized service manager; a nologin shell is not a replacement for all permission and credential controls.

Applying the same declaration again leaves the passwd, group, shadow, and gshadow file contents unchanged. This verifies repeat behavior for the exact fixture instead of assuming that every imaginable declaration is harmless. Conflicting input and allocation exhaustion still need diagnosis when the command reports them.

The second root reserves the first worker’s numeric identity for another fixture account before applying the identical configuration. Its worker receives a different UID. Names and requested allocation range match, but existing database state differs. Copying numeric file ownership between these roots without an identity mapping would therefore be unsafe.

Finally, the first root’s declaration requests a new numeric UID, description, and home path for the existing worker. Reapplying leaves its complete account record unchanged and creates no new home. This is the expected boundary of creation-oriented setup, not evidence that the edited fields were successfully migrated. The temporary roots are removed when the exercise ends.

Make packaging and identity policy explicit

Packages normally place declarations under /usr/lib/sysusers.d, while administrator files can override matching filenames under /etc/sysusers.d. Filename ordering and conflicting names matter; declarations are not universally order-independent statements. Inspect the effective input when more than one package or local policy defines the same identity.

Writing a file does not execute sysusers by itself. Distribution boot units and package integration determine when the tool is invoked. Verify the relevant installation path and service dependency so a daemon does not start before its account or state directory exists. Keep account creation and directory provisioning coordinated without treating them as the same operation.

Use automatically allocated IDs when host-local identity is sufficient. If shared storage or a published interface requires a particular numeric identity, establish and verify that allocation policy explicitly, including collisions and existing accounts. Test the actual service’s access after installation. Stable names, stable numbers, and correct filesystem ownership are related requirements that each need evidence.

Further reading

Sources