jkunz 1972b7f44b
Default (tags) / security (push) Failing after 2s
Default (tags) / test (push) Failing after 1s
Default (tags) / metadata (push) Skipped
v2.19.0
2026-10-02 20:27:38 +00:00
2026-10-02 20:27:38 +00:00
2026-10-02 20:27:38 +00:00
…
2026-10-02 20:27:38 +00:00

@push.rocks/smartdaemon 🚀

Turn your Node.js scripts into production-ready system daemons

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

npm version License: MIT

Seamlessly manage long-running processes, background services, and system daemons with smart root detection, automatic privilege escalation, and systemd integration.

🎯 What is SmartDaemon?

@push.rocks/smartdaemon is your Swiss Army knife for turning Node.js applications into bulletproof system services. Whether you're deploying microservices, running scheduled tasks, or managing background workers, SmartDaemon handles the complex stuff so you can focus on your business logic.

✨ Key Features

  • 🔐 Smart Privilege Management: Automatically detects root status and handles sudo operations seamlessly
  • 👤 User/Group Control: Run services under specific users for enhanced security
  • 🔄 Systemd Integration: Full Linux systemd support for production-grade service management
  • 🛡️ Auto-Recovery: Built-in service restart on failure
  • 📝 Declarative Service Definitions: Simple, clear service configuration
  • 🔌 Interactive & Non-Interactive Modes: Support for both passwordless sudo and interactive authentication
  • 🎛️ Complete Lifecycle Management: Start, stop, enable, disable, and reload services with ease

📦 Installation

# Using npm
npm install @push.rocks/smartdaemon --save

# Using pnpm (recommended)
pnpm add @push.rocks/smartdaemon

# Using yarn
yarn add @push.rocks/smartdaemon

🚀 Quick Start

Basic Usage

import { SmartDaemon } from '@push.rocks/smartdaemon';

// Initialize SmartDaemon
const daemon = new SmartDaemon();

// Create and enable a service
const myService = await daemon.addService({
  name: 'my-api-server',
  description: 'My awesome API server',
  command: 'node server.js',
  workingDir: '/opt/myapp',
  version: '1.0.0'
});

// Enable and start the service
await myService.enable(); // Creates systemd service file
await myService.start();  // Starts the service

Running Services as Specific Users 👥

Security best practice: Never run services as root unless absolutely necessary!

const webService = await daemon.addService({
  name: 'web-server',
  description: 'Production web server',
  command: 'node app.js',
  workingDir: '/var/www/app',
  version: '2.1.0',
  user: 'www-data',    // Run as www-data user
  group: 'www-data'    // Run under www-data group
});

await webService.enable();
await webService.start();

🔐 Smart Privilege Escalation

SmartDaemon intelligently handles privilege escalation based on your execution context:

Automatic Root Detection

// SmartDaemon automatically detects if running as root
const daemon = new SmartDaemon();

// If running as regular user, sudo will be used automatically
// If running as root, commands execute directly

With Sudo Password

If you're not running as root and don't have passwordless sudo configured:

const daemon = new SmartDaemon({
  sudoPassword: 'your-sudo-password'  // Will be used for privilege escalation
});

// All privileged operations will now use the provided password
const service = await daemon.addService({
  name: 'privileged-service',
  description: 'Service requiring root privileges',
  command: 'node admin-task.js',
  workingDir: '/opt/admin',
  version: '1.0.0'
});

await service.enable(); // Automatically uses sudo with password

For production environments, configure passwordless sudo for specific systemctl commands:

  1. Create a sudoers file: /etc/sudoers.d/smartdaemon
  2. Add the following content:
# Allow smartdaemon to manage services without password
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl start smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl disable smartdaemon_*
yourusername ALL=(ALL) NOPASSWD: /usr/bin/systemctl daemon-reload

📚 Complete API

SmartDaemon Class

interface ISmartDaemonOptions {
  sudoPassword?: string;  // Optional sudo password for privilege escalation
}

class SmartDaemon {
  constructor(options?: ISmartDaemonOptions);
  
  // Add a new service or update existing one
  async addService(options: ISmartDaemonServiceOptions): Promise<SmartDaemonService>;
  
  // Direct access to managers (advanced usage)
  systemdManager: SmartDaemonSystemdManager;
  templateManager: SmartDaemonTemplateManager;
}

Inspecting and controlling an existing systemd unit

SystemdUnit works with an exact canonical .service name. Construction performs no I/O. It does not create or rewrite a unit, enable it, remove a mask, or invoke sudo. The caller must already have permission for lifecycle commands.

import { SystemdUnit } from '@push.rocks/smartdaemon';

const unit = new SystemdUnit({ unitName: 'smartdaemon_my-api-server.service' });
const state = await unit.inspect();
console.log(state.loadState, state.activeState, state.unitFileState);

// Address the calling user's manager when running as that user.
const userUnit = new SystemdUnit({ unitName: 'authswitch.service', scope: 'user' });
const userState = await userUnit.inspect();

// Application code decides whether stopping or restarting is appropriate.
// await unit.stop();
// await unit.start();

Snapshots are immutable and also include subState, mainPid, controlPid, controlGroup, killMode, job, statusText, result and fileDescriptorStoreCount. An empty job means no systemd job was reported; otherwise it is the pending job's id. statusText is the service's last STATUS= notification, verbatim (empty when it sent none), and result is systemd's Result: success, or why the service last failed (timeout, exit-code, signal, core-dump, watchdog, start-limit-hit, oom-kill, resources, protocol, exec-condition; a newer manager's word is still reported). fileDescriptorStoreCount is how many descriptors the manager holds in the service's file descriptor store right now (NFileDescriptorStore): 0 for a service without a store and for a missing or masked unit, and possibly above 0 for a stopped service whose store is preserved. Missing and masked units remain distinct from inactive or disabled units. Aliases resolving to a different canonical unit are rejected.

start() and stop() await the systemctl command and return a fresh snapshot. They do not establish application readiness or prove database shutdown; callers must apply the owning application's lifecycle contract. A command failure or timeout rejects with SystemdUnitError and a local code, without returning raw command diagnostics. The systemd job can continue after a client timeout, so a failed command must not be treated as cancelled or successfully completed.

A Type=notify start returns only at READY=1, which can outlast commandTimeoutMs. start({ wait: false }) and stop({ wait: false }) pass --no-block: systemd verifies and enqueues the job, and the call returns the snapshot read right after, typically activating or deactivating with the job pending. A refused job (a missing or masked unit, a conflicting transaction) still rejects with command_failed. The caller then polls inspect(): job empties when the job finishes, statusText carries the service's progress, and result tells a start timeout (timeout) from a crash (exit-code, signal, core-dump). Options other than a boolean wait reject with invalid_options before any command runs.

const pending = await unit.start({ wait: false });
// Later, on the caller's own schedule:
const state = await unit.inspect();
if (state.job === '' && state.activeState === 'failed') {
  console.log(state.result === 'timeout' ? 'start stalled' : `crashed: ${state.result}`);
} else {
  console.log(state.activeState, state.statusText);
}

Calls use an absolute systemctlPath (default /usr/bin/systemctl), a clean environment, no shell or password prompt, and bounded output. Trusted owning code can choose another absolute executable path and commandTimeoutMs (default 120000, maximum 600000). This timeout bounds the client process only. The default scope: 'system' retains system-manager behavior. scope: 'user' uses systemctl --user as the calling user. Every SystemdUnit operation contacts the user manager, so each requires a real, owned, private XDG_RUNTIME_DIR (or an explicit runtimeDirectory) and passes that directory with the user's home and any set XDG_CONFIG_HOME and XDG_DATA_HOME to the command. Before each call, all of them must be absolute, normalized paths without a trailing slash, and the runtime directory is checked again on disk; a missing, malformed, replaced, linked or group-accessible runtime directory, a malformed XDG root and a missing or malformed home directory each fail with invalid_options before contacting systemd.

Construction validates only the options it is given: the unit name, systemctlPath, busctlPath, commandTimeoutMs, scope and a supplied runtimeDirectory, which must be a well-formed path (and is refused for system scope). It reads, but does not validate, the process environment above, so a unit can be constructed without a login session (cron, a plain ssh command) or with a malformed XDG variable; its calls then refuse. An empty XDG variable counts as unset. The environment is captured at construction; later changes to process.env do not affect an existing unit.

cleanFileDescriptorStore() releases the service's file descriptor store with systemctl clean --what=fdstore, closing every descriptor the manager holds for it, and returns a fresh snapshot; nothing else of the unit is cleaned. The manager refuses, and the call rejects with command_failed, unless the unit is loaded, inactive (a failed unit needs systemctl reset-failed first) and without a pending job, and its loaded FileDescriptorStoreMax is above zero or the store still holds a descriptor. Once the manager has accepted the clean, every outcome states that the store was released: the call resolves with the snapshot read afterwards, or, when that inspection fails, rejects with released_uninspected and the inspection's error as cause. The snapshot reports the unit as it is when read: its fileDescriptorStoreCount is 0 unless the unit was started right after the release and stored descriptors again, which a caller must not read as a failed clean. A refused clean, or one that could not be run, rejects with command_failed; so does a clean whose systemctl call exceeded commandTimeoutMs, whose outcome is unknown because the manager may have released the store before the client was killed. After command_failed a caller must inspect() the unit before treating the store as kept. Cleaning a store needs systemd 254 or later: the call reads the manager's version first and refuses an older manager with SystemdUnitManagerTooOldError (manager_too_old), an unreadable version with version_unknown and an unanswered read with command_failed, each without running the clean.

inspectConfiguration() reports the selected fragmentPath, opaque dropInPaths list and needsReload flag. reloadConfiguration() explicitly reloads the manager's unit definitions globally and returns a fresh configuration snapshot. Neither operation changes enablement or masks.

inspectServiceSettings() reports the loaded service settings a definition renders, drop-ins included: type, notifyAccess, restart, killMode (systemd's own words; type is empty for a unit systemd could not load), delegate, restartPreventExitStatus and successExitStatus (each { statuses, signals }: exit statuses ascending, and signals by systemd's name without SIG, such as TERM, which only a hand-written unit or drop-in sets), startLimitBurst, startLimitIntervalMicroseconds (0 when the start rate limit is disabled), restartMicroseconds, timeoutStopMicroseconds and timeoutStartMicroseconds (exact integers or 'infinity'; the start timeout is the loaded TimeoutStartSec=, without any EXTEND_TIMEOUT_USEC= extension a running start requested), limitNoFile and limitNoFileSoft (hard and soft), tasksMax (integers or 'infinity'), oomScoreAdjust, fileDescriptorStoreMax (0 without a store), fileDescriptorStorePreserve (no, yes or restart) and environment, the unit's own Environment= variables as a frozen name-to-value object (variables the manager gives every unit, EnvironmentFile= and PassEnvironment= are not included). A missing, repeated, foreign or malformed property rejects with invalid_state; this includes an Environment= value holding a control character, which systemctl show prints as a C escape. inspectServiceSettings() needs systemd 254 or later, the first to report FileDescriptorStorePreserve: it reads the manager's version first and refuses an older manager with SystemdUnitManagerTooOldError (manager_too_old) and an unreadable version with version_unknown, both before the unit is read (see The manager's version below). Every environment a SystemdServiceDefinition accepts reads back. Reload changed files first; the snapshot describes what the manager loaded.

execStart lists every ExecStart= command of the loaded unit, drop-ins included, in the order the manager runs them, each as a frozen ISystemdExecCommand: path (the executable), argv (the argument vector, argv[0] included, one string per argument exactly as loaded; % specifiers resolved as systemd loaded the unit, $VARIABLES and $$ as written) and ignoreFailure (a - prefix). It is empty for a unit that sets none or that the manager could not find. systemctl show joins argv with spaces, so an argument holding a space could not be told apart there; this field is read instead from the unit's D-Bus ExecStart property (a(sasbttttuii)) with busctl --json=short get-property, through an absolute busctlPath (default /usr/bin/busctl, systemd 240 or newer) with the same clean environment and timeout. The system scope reads the system bus; the user scope reads the user's bus at $XDG_RUNTIME_DIR/bus, so a user manager without a user D-Bus fails inspectServiceSettings() with command_failed. A failed bus read rejects with command_failed, and a reply that is not exactly that signature with an absolute path, a non-empty argv of strings and a boolean flag per command rejects with invalid_state.

const settings = await new SystemdUnit({ unitName: 'pallet-containerd.service' }).inspectServiceSettings();
const carriesContract = settings.type === 'notify' && settings.notifyAccess === 'all' &&
  settings.killMode === 'process' && settings.delegate && settings.limitNoFile === 'infinity' &&
  settings.limitNoFileSoft === 'infinity' && settings.tasksMax === 'infinity' &&
  settings.oomScoreAdjust === -999 && settings.restart === 'always' &&
  settings.restartMicroseconds === 2_000_000 && settings.timeoutStopMicroseconds === 60_000_000;
// The exact command line the manager runs, argument by argument.
const runsServe = settings.execStart.length === 1 &&
  settings.execStart[0].argv.join('\0') === ['/opt/pallet/pallet-control', 'containerd-serve'].join('\0');

enable() persistently enables the existing unit definition and returns a fresh state snapshot. It does not rewrite the definition, start the unit, unmask it or force conflicting links. Run it inside the owning installer's cross-process installation scope, after any required offline commissioning. A failed or timed out command can leave partial enablement; inspect and retry the exact operation without assuming cancellation or undoing the application's retained protection.

inspectRelationships() returns immutable sorted arrays for before, after, requires, wants, conflicts, requiredBy and wantedBy from the loaded manager graph. Literal systemd hex escapes are preserved. The bounded snapshot rejects missing fields, foreign identities, duplicate relationships and malformed names. unitFileState === 'enabled' proves only that some enablement links exist. For a boot guard, verify that its requiredBy and before both include every required target and the actual network provider; inspect the provider's requires and after as well. Recheck the exact file digest, selected fragment, absence of drop-ins and needsReload === false before permitting dependent activation.

The manager's version

inspectManagerVersion() reads the running manager's version through the unit's scope — the system manager, or the calling user's manager for scope: 'user' — with systemctl show --property=Version, the same systemctlPath, environment and timeout as every other call. It resolves with a frozen ISystemdManagerVersion: major (255) and version, the manager's Version property verbatim (255.4-1ubuntu8.17). This is the running manager's version, which after a package upgrade differs from systemctl --version until the manager re-executes. A manager that does not answer rejects with command_failed; an answer that is not a single Version= line with a version starting with a major of one to four digits rejects with version_unknown.

requireManagerVersion(requiredMajor) resolves with that version when its major is at least requiredMajor (an integer from 1 to 9999; anything else rejects with invalid_options before any command) and otherwise rejects with a SystemdUnitManagerTooOldError: a SystemdUnitError with code manager_too_old that carries the managerVersion it read and the requiredMajor. Calls that need a newer manager use it and fail closed: inspectServiceSettings(), cleanFileDescriptorStore() and installing a definition with a requiredManagerMajor (see SystemdUnitFile.install()), so none fails late with a generic code. runInTransientScope() refuses an older user manager the same way with its own SystemdTransientScopeManagerTooOldError.

import { SystemdUnit, SystemdUnitManagerTooOldError } from '@push.rocks/smartdaemon';

const unit = new SystemdUnit({ unitName: 'pallet-node.service' });
console.log((await unit.inspectManagerVersion()).major); // 255
try {
  await unit.inspectServiceSettings();
} catch (error) {
  if (error instanceof SystemdUnitManagerTooOldError) {
    // e.g. 'systemd 253 (253.5-1) running, 254 required'
    console.error(`systemd ${error.managerVersion.major} (${error.managerVersion.version}) running, ` +
      `${error.requiredMajor} required`);
  }
  throw error;
}

Installing a direct systemd service definition

SystemdServiceDefinition encodes a literal executable and argument list, with explicit restart policy, kill mode and stop timeout. System-scope definitions require user and group; user-scope definitions reject both directives because the user manager already runs under the calling identity. It defaults to Type=exec, journal output and null stdin. It introduces no shell; percent specifiers and argument environment expansion are escaped. Paths must be absolute and normalized. Descriptions, paths and arguments reject control characters, lone surrogates and Unicode noncharacters (U+FDD0–U+FDEF and every code point ending in FFFE or FFFF), which systemd does not accept as UTF-8 clean and would ignore, leaving the unit unloadable. Descriptions and working directories cannot have backslashes or surrounding whitespace; executable paths also reject quotes and backslashes. Arguments may contain spaces, quotes, backslashes and empty strings.

import { SystemdServiceDefinition, SystemdUnitFile } from '@push.rocks/smartdaemon';

const definition = new SystemdServiceDefinition({
  unitName: 'smartdaemon_my-api-server.service',
  description: 'My API server',
  executable: '/opt/my-api/bin/server',
  args: ['serve'],
  workingDirectory: '/opt/my-api',
  user: 'root',
  group: 'root',
  restart: 'no',
  killMode: 'mixed',
  timeoutStopSeconds: 360,
});
const file = new SystemdUnitFile({ unitName: definition.unitName });
// Inside the privileged installer's existing cross-process ownership scope:
const previous = await file.inspect();
const installed = await file.install(definition, previous?.sha256 ?? null);
console.log(installed.changed, installed.masked, installed.record.sha256);

For a user service, give the definition and installer the same scope:

const userDefinition = new SystemdServiceDefinition({
  scope: 'user',
  unitName: 'authswitch.service',
  description: 'Account authority',
  executable: '/opt/authswitch/bin/authswitch',
  args: ['daemon'],
  workingDirectory: '/opt/authswitch',
  restart: 'on-failure',
  killMode: 'mixed',
  timeoutStopSeconds: 60,
});
const userFile = new SystemdUnitFile({ scope: 'user', unitName: userDefinition.unitName });
const previousUserFile = await userFile.inspect();
await userFile.install(userDefinition, previousUserFile?.sha256 ?? null);

User definitions default to WantedBy=default.target and have no system-only After=local-fs.target edge. User unit files default to $XDG_DATA_HOME/systemd/user (or ~/.local/share/systemd/user), below the user's config directory in the unit load path. Installation and enablement use the current user manager and do not escalate privileges. The installer rejects unsafe ownership or group-writable path components; prepare those directories explicitly before installation. It never changes the permissions of existing directories.

A user-scope SystemdUnitFile validates each value where it is read:

  • Construction validates the unit's options as SystemdUnit does, a supplied unitDirectory and expectedUid (which must equal the caller's uid). Without unitDirectory, it also derives the public path from XDG_DATA_HOME, or from the home directory when that is unset, and rejects a missing or malformed source there with invalid_options; no other process value is validated at construction.
  • inspect() reads only the unit directory as that uid. It needs no user manager, no runtime directory and no XDG config root, so with an explicit unitDirectory it works whatever XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_RUNTIME_DIR and the home directory hold.
  • install() admits, reloads and verifies through the manager and recognizes the user's persistent and runtime mask paths. It therefore needs everything the manager calls need, plus a well-formed config root (XDG_CONFIG_HOME, or ~/.config), and rejects anything missing or malformed with invalid_options before touching any directory or running systemctl.

An owner can therefore ask whether its definition is installed from a context without a login session:

// Resolves to null when the file is absent, or { path, sha256 } for a protected definition.
const installed = await new SystemdUnitFile({ scope: 'user', unitName: 'authswitch.service' }).inspect();

inspect() and install() check every component of the unit directory from / down, then the unit file itself. A refusal is a SystemdUnitFileUnsafePathError, a SystemdUnitFileError with code unsafe_path that names the offending path and a reason of type TSystemdUnitFileUnsafePathReason, so the owner knows exactly what to fix:

reason path Refused because
unsupported_platform unit directory the process does not run on Linux
foreign_process unit directory the process does not run as the expected uid
symlink directory component or unit file it is a symbolic link
not_directory directory component it is not a directory
foreign_owner directory component or unit file a directory is owned by neither root nor the expected uid, or the file not by the expected uid
group_or_world_writable directory component it is group- or world-writable (only a root-owned mode-1777 /tmp is admitted)
unreadable unit file it cannot be opened; the operating-system error is the cause
not_regular_file unit file it is not a regular file
hard_linked unit file it has more than one hard link
wrong_mode unit file its mode is not exactly 0644
too_large unit file it is larger than 16384 bytes
replaced unit directory or a file being written it changed identity between two checks during install()
import { SystemdUnitFileUnsafePathError } from '@push.rocks/smartdaemon';

try {
  await file.inspect();
} catch (error) {
  if (error instanceof SystemdUnitFileUnsafePathError) {
    // e.g. 'group_or_world_writable' '/home/user/.local' after `chmod 0775 ~/.local`
    console.error(error.reason, error.path);
  }
  throw error;
}

For a boot prerequisite that must finish before dependent services start, supply startup: { type: 'oneshot', remainAfterExit: true, timeoutStartSeconds: 120 }. Systemd waits for successful process exit; RemainAfterExit keeps the completed unit active. The start timeout is an integer from 1 to 600 seconds. Oneshot services reject restart: 'always'; the default exec definition remains unchanged.

A long-running service that reports its own readiness uses startup: { type: 'notify', notifyAccess: 'all', timeoutStartSeconds: 120 }, rendered as Type=notify, NotifyAccess= and TimeoutStartSec=. Systemd counts the start complete when a process admitted by notifyAccess sends READY=1: 'main' admits only the main process, 'exec' also the processes of the unit's Exec*= commands, 'all' every process in the unit's cgroup, as a ready notification from a child of the main process needs. NotifyAccess=none is not offered because it discards every notification. Notify services may use restart: 'always'.

These optional settings render after TimeoutStopSec= in this order, and nothing when omitted, so existing definitions keep their exact bytes:

Option Directive Accepted values
restartSeconds RestartSec= (after Restart=) integer 1–600; refused with restart: 'no'
restartPreventExitStatus RestartPreventExitStatus= (after RestartSec=) distinct integers 0–255, at least one, rendered ascending; refused with restart: 'no'
successExitStatus SuccessExitStatus= (after RestartPreventExitStatus=) distinct integers 0–255, at least one, rendered ascending
startLimitIntervalSeconds StartLimitIntervalSec= in [Unit], after the relations integer 0–86400; 0 disables the start rate limit
startLimitBurst StartLimitBurst= in [Unit], after StartLimitIntervalSec= integer 1–1000; refused with startLimitIntervalSeconds: 0
delegate Delegate= boolean
limitNoFile LimitNOFILE= (soft and hard) positive integer or 'infinity'
tasksMax TasksMax= positive integer or 'infinity'
oomScoreAdjust OOMScoreAdjust= integer −1000–1000
environment one Environment= line per variable, ordered by name plain object of string values; see below
const containerd = new SystemdServiceDefinition({
  unitName: 'pallet-containerd.service',
  description: 'Pallet containerd',
  executable: '/opt/pallet/current/pallet-control',
  args: ['containerd-serve'],
  workingDirectory: '/',
  user: 'root',
  group: 'root',
  restart: 'always',
  restartSeconds: 2,
  killMode: 'process',
  timeoutStopSeconds: 60,
  startup: { type: 'notify', notifyAccess: 'all', timeoutStartSeconds: 120 },
  dependencies: {
    defaultDependencies: true,
    after: ['pallet-guard.service'],
    before: ['pallet-node.service'],
    requires: ['pallet-guard.service'],
    wants: [],
    conflicts: [],
  },
  delegate: true,
  limitNoFile: 'infinity',
  tasksMax: 'infinity',
  oomScoreAdjust: -999,
});

A service that restarts on failure but must not loop on a permanent refusal names the exit statuses that end it for good, and disables the start rate limit so the manager's DefaultStartLimitIntervalSec=/DefaultStartLimitBurst= cannot make it give up during a long outage of something it depends on:

const node = new SystemdServiceDefinition({
  unitName: 'spark.service',
  description: 'serve.zone Spark node',
  executable: '/opt/spark/current/spark',
  args: ['runnode'],
  workingDirectory: '/opt/spark',
  user: 'root',
  group: 'root',
  restart: 'on-failure',
  restartSeconds: 5,
  killMode: 'mixed',
  timeoutStopSeconds: 360,
  restartPreventExitStatus: [78], // EX_CONFIG: a permanent refusal
  startLimitIntervalSeconds: 0,
});

renders StartLimitIntervalSec=0s as the last [Unit] line and Restart=on-failure, RestartSec=5s, RestartPreventExitStatus=78 in [Service]. Systemd 230 and later read the start rate limit from [Unit] only; under systemd 255 systemd-analyze verify loads such a unit without a warning.

Systemd applies these within what the manager itself may grant. Under systemd 255 in an unprivileged container, this unit started with the manager's own open-file hard limit for LimitNOFILE=infinity and without the lowered OOM score, and still reported active. inspectServiceSettings() reports the configured values, not the running process's limits.

environment gives the service's processes literal variables, for example the installer's PATH when the executable or the tools it starts live outside the manager's default search path (a user manager's default PATH omits ~/.local/bin and nvm's Node directories):

const authority = new SystemdServiceDefinition({
  scope: 'user',
  unitName: 'authority.service',
  description: 'Authority daemon',
  executable: '/home/owner/.nvm/versions/node/v24.9.0/bin/node',
  args: ['/home/owner/app/cli.js', 'serve'],
  workingDirectory: '/home/owner',
  restart: 'on-failure',
  killMode: 'mixed',
  timeoutStopSeconds: 30,
  environment: { PATH: process.env.PATH!, CODEX_HOME: '/home/owner/.codex' },
});
// Environment="CODEX_HOME=/home/owner/.codex"
// Environment="PATH=/home/owner/.nvm/versions/node/v24.9.0/bin:/home/owner/.local/bin:/usr/bin:/bin"

The value must be a plain object (not a proxy, class instance or null-prototype object) whose own properties are enumerable data properties. Names match [A-Za-z_][A-Za-z0-9_]* and have at most 128 characters; values are strings of at most 4096 characters without control characters, lone surrogates or Unicode noncharacters, and may be empty. At most 64 variables and 8192 UTF-8 bytes of NAME=value in total are accepted, and the whole unit stays within 16384 bytes. Anything else throws SystemdServiceDefinitionError. Each assignment is double-quoted with \ and " backslash-escaped and % doubled, so systemd applies no specifier; Environment= never expands $, so values are taken literally. An empty object renders nothing. Systemd merges these variables over its own (HOME, USER, INVOCATION_ID, …), so a name such as NOTIFY_SOCKET overrides the manager's value.

Notify access and the file descriptor store

A service that keeps a descriptor across its own restarts, such as a network namespace a child process created, hands it to the manager with sd_notify FDSTORE=1 (optionally FDNAME=), and the manager passes it back to the next main process through LISTEN_FDS/LISTEN_FDNAMES:

Option Directive Accepted values
notifyAccess NotifyAccess=, right after the startup lines 'none', 'main', 'exec' or 'all'; refused beside a notify startup, which names its own
fileDescriptorStoreMax FileDescriptorStoreMax= (after OOMScoreAdjust=) integer 1–65536
fileDescriptorStorePreserve FileDescriptorStorePreserve= (after FileDescriptorStoreMax=) 'no', 'yes' or 'restart'; requires fileDescriptorStoreMax

'main' admits notifications from the main process only and 'exec' from the main and control processes the manager started; a descriptor a child of the main process stores needs 'all'. Without notifyAccess systemd 255 admits the main process for a service with a store, and it loads an explicit NotifyAccess=none beside a store as main, so 'none' is refused there. fileDescriptorStorePreserve: 'no' releases the store when the service stops, 'restart' (systemd's default) once it is neither active nor about to restart, and 'yes' only when the unit leaves the manager's memory or SystemdUnit.cleanFileDescriptorStore() releases it. A reboot empties the store; a systemctl soft-reboot keeps the running kernel, so a preserved store and the descriptors in it pass on to the next userspace boot. Systemd reads FileDescriptorStorePreserve= from version 254 and an older manager would ignore the line, so a definition with fileDescriptorStorePreserve has requiredManagerMajor 254 and SystemdUnitFile.install() refuses it on an older manager with SystemdUnitManagerTooOldError (manager_too_old) before touching anything; every other definition has requiredManagerMajor null. Sending FDSTORE=1 needs an AF_UNIX socket, so a definition with a store or a notify access other than 'none' that restricts its socket families must allow AF_UNIX. Omitted, each option renders nothing.

const node = new SystemdServiceDefinition({
  unitName: 'pallet-node.service',
  description: 'serve.zone Pallet node',
  executable: '/opt/pallet/current/pallet-control',
  args: ['runtime-unit'],
  workingDirectory: '/',
  user: 'root',
  group: 'root',
  restart: 'on-failure',
  restartSeconds: 5,
  killMode: 'mixed',
  timeoutStopSeconds: 360,
  notifyAccess: 'all',
  fileDescriptorStoreMax: 1,
  fileDescriptorStorePreserve: 'yes',
});
// Type=exec, NotifyAccess=all, …, FileDescriptorStoreMax=1, FileDescriptorStorePreserve=yes

Under systemd 255 systemd-analyze verify loads such a unit without a warning.

Credentials, a dynamic user and the sandbox

A system service can receive secrets as systemd credentials instead of variables, run as a transient account and be confined. Each option below renders nothing when omitted, so existing definitions keep their exact bytes; false renders the directive as no. A user service refuses all of them, since a user manager cannot grant them. DynamicUser= follows Group=; the others follow the environment lines in this order:

Option Directive Accepted values
dynamicUser DynamicUser= boolean; with true, user and group name the allocated account
loadCredentials one LoadCredential= line per credential, ordered by name array of { name, source? }
loadCredentialsEncrypted one LoadCredentialEncrypted= line per credential, ordered by name array of { name, source? }
noNewPrivileges NoNewPrivileges= boolean
protectSystem ProtectSystem= boolean, 'full' or 'strict'
protectHome ProtectHome= boolean, 'read-only' or 'tmpfs'
privateTmp, privateDevices, protectKernelTunables, protectKernelModules, protectKernelLogs, protectControlGroups, protectClock, protectHostname, restrictNamespaces, restrictRealtime, restrictSUIDSGID, lockPersonality the directive of the same name, in this order boolean
capabilityBoundingSet CapabilityBoundingSet= distinct capabilities from systemdCapabilities, sorted; empty drops every capability
ambientCapabilities AmbientCapabilities= distinct capabilities, sorted; within capabilityBoundingSet when both are given
restrictAddressFamilies RestrictAddressFamilies= at least one distinct family of AF_INET, AF_INET6, AF_NETLINK, AF_PACKET, AF_UNIX, AF_VSOCK, sorted; includes AF_UNIX for a notify service
const relay = new SystemdServiceDefinition({
  unitName: 'relay.service',
  description: 'Cluster relay',
  executable: '/opt/relay/current/relay',
  args: [],
  workingDirectory: '/opt/relay/current',
  user: 'relay',
  group: 'relay',
  dynamicUser: true,
  restart: 'always',
  restartSeconds: 2,
  killMode: 'mixed',
  timeoutStopSeconds: 60,
  dependencies: {
    defaultDependencies: true,
    after: ['network-online.target'],
    before: ['node.service'],
    requires: [],
    wants: ['network-online.target'],
    conflicts: [],
  },
  environment: { RELAY_BIND_PORT: '8443' },
  loadCredentials: [{ name: 'RELAY_AUTHORIZATION' }],
  noNewPrivileges: true,
  protectSystem: 'strict',
  protectHome: true,
  privateTmp: true,
  privateDevices: true,
  capabilityBoundingSet: [],
  restrictAddressFamilies: ['AF_INET', 'AF_INET6', 'AF_UNIX'],
});
// DynamicUser=yes
// LoadCredential=RELAY_AUTHORIZATION
// CapabilityBoundingSet=
// RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

A credential's value never appears in the unit, in the service's environment (so child processes do not inherit it) or in systemctl show: the service reads it from $CREDENTIALS_DIRECTORY/<name>, readable by the service's user only. name is 1–255 characters of [A-Za-z0-9_.@-], not starting with .. source is either an absolute normalized path of [A-Za-z0-9_.@/+-] characters without a trailing / (a file, a directory of files or an AF_UNIX socket), or a credential name that systemd looks up among its own credentials and then in /etc/credstore/, /run/credstore/ and /usr/lib/credstore/ (for encrypted credentials also the credstore.encrypted directories). Without source, systemd looks up name itself, so { name: 'RELAY_AUTHORIZATION' } reads /etc/credstore/RELAY_AUTHORIZATION; a source equal to name is refused, as it only spells the same lookup twice. These characters need no quoting and hold no % specifier, : separator or escape, so systemd reads each line exactly as written. Encrypted credentials are sealed with systemd-creds encrypt (host key or TPM2). Systemd keeps both kinds in one table by name, so a name appears in at most one list; at most 64 per list. The directory search needs systemd 250 or later; an absolute source works from systemd 247.

dynamicUser: true allocates the account when the service starts. Systemd would use a static account of the same name instead, so user and group must not be root, and they have at most 31 characters, the longest name systemd allocates. For such a service systemd enforces ProtectSystem=strict, PrivateTmp=yes, NoNewPrivileges=yes, RestrictSUIDSGID=yes and at least ProtectHome=read-only; a definition that states any of them weaker is refused, so the unit never reads as less confined than it is.

Each refusal throws SystemdServiceDefinitionError, whose option names the offending option (for every option of a definition, not only these) and whose message is Invalid direct systemd service definition: <option>.. option is 'options' for the definition object itself: not a plain object, an unknown or accessor key, or a unit over 16384 bytes. No refusal carries the offending value.

Optional dependencies describes the complete literal runtime relationships:

dependencies: {
  defaultDependencies: false,
  after: [],
  before: ['network-pre.target', 'shutdown.target', 'systemd-networkd.service'],
  requires: [],
  wants: ['network-pre.target'],
  conflicts: ['shutdown.target'],
},
install: { wantedBy: [], requiredBy: ['network-pre.target', 'systemd-networkd.service'] },

System definitions always retain After=local-fs.target. Disable default dependencies only for an intentionally early boot or late shutdown service. Every list is bounded to 32 distinct literal unit names, sorted deterministically; templates, specifiers, self references and contradictory direct relationships are rejected. These local checks do not prove the complete host dependency graph is free of cycles.

Consumers must declare both requires: ['the-bootstrap.service'] and after: ['the-bootstrap.service'] for a failed bootstrap to prevent their start. Ordering alone does not propagate failure. A prerequisite must actually fail its start on failure; a skipped condition is not equivalent. Reverse stop ordering keeps the prerequisite active until its ordered consumers stop. The example assumes the host uses systemd-networkd.service: requiring the guard only from network-pre.target does not block a network manager that merely orders itself after that target. Select and verify the actual host network owner's edges.

install supplies only WantedBy/RequiredBy metadata, with at least one target. The privileged installer must separately enable and verify the intended links; rendering or installing the file does not activate them. A successful oneshot proves only that its executable returned success, so that executable must finish and verify all required work before exiting. See the upstream service readiness and dependency semantics.

test/native/qualify-systemd.py exercises the generated units under Ubuntu systemd PID 1 across provisioning, successful cold boot and failed cold boot. It checks actual networkd failure coupling and reverse shutdown order in a disposable QEMU overlay without an external network backend. The fixture requires QEMU/KVM, OVMF, xorriso, Python PyYAML and global tsx. Supply the unmodified Ubuntu Noble 20260826 image with SHA-256 d0fe84bb5f80853425fa6be28e2c106f30104c3cfe8611933f2e65c9b63f0e30:

python3 test/native/qualify-systemd.py --image /path/to/noble-server-cloudimg-amd64.img \
  --work-root /tmp/smartdaemon-systemd-qualification-local

To exercise the public enablement and relationship APIs in the same guest, bundle test/native/activate-systemd.ts as a self-contained Node ESM file and also supply --node /path/to/node --activation-bundle /path/to/activate.mjs. Any library absent from the minimal image can be supplied explicitly with repeatable --node-library /path/to/libname.so.N. These hashed test inputs travel on a read-only ISO. The API fixture checks enablement without implicit start, oneshot readiness, unchanged definition/configuration, detection of a missing provider link despite enabled state, exact enable retry and preservation of a mask.

Installation requires Linux, the relevant manager's user identity and a quiescent exact unit with no process, cgroup or job. It rejects effective drop-ins and shadowing fragments. Definitions default to /usr/local/lib/systemd/system for system scope and the user's data unit directory for user scope, below administrator or user configuration in the load path, so existing persistent/runtime masks remain intact. Existing enablement is preserved; an absent unit becomes disabled. It never enables, unmasks, starts or stops a service, or invokes sudo.

Masked system units may report /dev/null or their exact administrator mask path: /etc/systemd/system/<unit> for a persistent mask, /run/systemd/system/<unit> for a runtime mask. Installation preserves that exact manager-selected fragment through the write and reload, and never replaces the masking fragment itself. For user scope, the exact user config or runtime mask path and the global user-unit mask paths are also recognized. Pending manager reloads are rejected before writing; inspect and explicitly reload an existing changed configuration before retrying installation.

Each of these manager checks refuses with a SystemdUnitFileUnsafeUnitError, a SystemdUnitFileError with code unsafe_unit whose reason of type TSystemdUnitFileUnsafeUnitReason says what to resolve. It also carries the state snapshot (ISystemdUnitState) the check read and, for the configuration reasons, the configuration snapshot (ISystemdUnitConfigurationState), otherwise null:

reason configuration Refused because
active null the unit has a main or control process or a control group, or is neither inactive/dead nor failed/failed: stop it first
job_pending null a systemd job is queued for the otherwise quiet unit
unexpected_load_state null the load state is none of loaded, masked and not-found
unexpected_file_state null the unit-file state does not fit the load state (loaded: enabled, enabled-runtime or disabled; masked: masked or masked-runtime; not-found: none)
reload_pending snapshot the manager needs a daemon-reload for the unit
drop_ins snapshot the unit has effective drop-in files (dropInPaths)
unexpected_fragment snapshot the selected fragmentPath is not the unit path (none for an absent unit), or a masked unit's is neither /dev/null nor its matching mask path, or is the unit path itself
import { SystemdUnitFileUnsafeUnitError } from '@push.rocks/smartdaemon';

try {
  await file.install(definition, installed?.sha256 ?? null);
} catch (error) {
  if (error instanceof SystemdUnitFileUnsafeUnitError && error.reason === 'active') {
    // e.g. 'active' 'running' 4711: the service must be stopped before its definition is replaced
    console.error(error.state.activeState, error.state.subState, error.state.mainPid);
  }
  throw error;
}

The same checks rerun after the manager reload; a refusal there is reported as reload_failed, because the new definition is already in place, and the SystemdUnitFileUnsafeUnitError is its cause. Every other reload_failed also keeps the underlying error as cause, such as the SystemdUnitError of a failed systemctl daemon-reload.

The caller must already serialize privileged installation across processes; SystemdUnitFile only excludes simultaneous operations on the same instance. The expected previous SHA-256 is mandatory (null requires an absent file), while already matching bytes are idempotent. Installation checks protected directories and regular single-link mode-0644 files, retains changed definitions as <unit>.previous-<sha256>, flushes them before atomic replacement, reloads the manager and checks its selected fragment and final bytes. A reload failure leaves the new file and retained previous definition in place; recovery requires inspection, never an automatic rollback. A definition whose requiredManagerMajor is not null is admitted only on a manager at least that new, read with SystemdUnit.requireManagerVersion() before any directory, file or other systemctl effect; that read's refusal propagates unchanged as the unit's SystemdUnitError: manager_too_old (a SystemdUnitManagerTooOldError), version_unknown, or command_failed when the manager does not answer. Filesystem and definition errors use SystemdUnitFileError with a local code; every unsafe_path refusal is a SystemdUnitFileUnsafePathError naming the offending path and its reason, and every unsafe_unit refusal is a SystemdUnitFileUnsafeUnitError naming its reason with the manager snapshots it read. Trusted qualification code can set unitDirectory, expectedUid and the inherited SystemdUnit command options; user scope requires expectedUid to match the caller's actual uid. The manager's selected fragment must match the installed file after reload, so an unrecognized custom user unit path fails closed.

With killMode: 'mixed', the main process owns graceful child shutdown and must remain alive until its children have joined. Systemd sends SIGTERM to the main process first and can send SIGKILL to the whole cgroup when the main process exits or the stop deadline expires. control-group sends the initial signal to the whole group. process signals only the main process, on stop and on a stop timeout; every other process in the unit survives, so the main process must own their shutdown or their deliberate retention (as with Delegate=yes workloads). A successful systemd start still requires application readiness verification, and a completed stop alone does not prove database drain.

Starting a program in its own transient scope

A service that launches a long-lived program keeps that program in its own control group, so stopping the service ends the program too, even when the program detached itself with setsid. runInTransientScope() starts a program in a new transient scope unit of the calling user's systemd manager instead (systemd-run --user --scope): the program and every process it starts run in that scope and outlive the caller's unit. The call waits for the program's direct process and returns its result; a program that daemonizes and exits returns while its daemon keeps the scope active, and the scope ends when its last process exits.

import { runInTransientScope, SystemdTransientScopeError } from '@push.rocks/smartdaemon';

try {
  const result = await runInTransientScope({
    scope: 'user',
    description: 'Codex app-server daemon',
    executable: '/home/owner/.local/bin/codex',
    args: ['app-server', 'daemon', 'start'],
    workingDirectory: '/home/owner',
    environment: { HOME: '/home/owner', CODEX_HOME: '/home/owner/.codex', PATH: '/usr/bin:/bin' },
  });
  console.log(result.unitName, result.exitCode, result.stdout);
} catch (error) {
  if (error instanceof SystemdTransientScopeError && error.code === 'user_manager_unavailable') {
    // No user manager: the caller decides how to run the program without one.
  }
  throw error;
}

The result holds the scope's unitName, the direct process's exitCode (or the signal that ended it), and its stdout and stderr, UTF-8 decoded. A failure status is a result, not an error. The program is executed directly from an absolute executable with literal args, with no shell, PATH search or $ expansion (systemd 258 expands $ in a scope's command line unless told not to, and the call always passes --expand-environment=no), in the absolute workingDirectory, with stdin on /dev/null. Its environment is exactly environment plus the manager's XDG_RUNTIME_DIR and systemd's INVOCATION_ID: nothing of the calling process is inherited, so pass every variable the program needs. XDG_RUNTIME_DIR, INVOCATION_ID and names starting with SYSTEMD_ or DBUS_, which would configure systemd-run itself, are refused. unitName names the scope (a .scope name) and defaults to a unique smartdaemon-<32 hex digits>.scope; a scope of that name that is still active refuses a second start. The literal description is the unit's description.

Failures throw SystemdTransientScopeError with the scope's unitName (null for refused options), the stderr read so far and one of these codes:

Code Meaning
invalid_options Malformed options, or a runtime directory that is not a private directory of the caller
unsupported_platform The process is not running on Linux
user_manager_unavailable No XDG_RUNTIME_DIR (or runtimeDirectory), a runtime directory that does not exist, or a manager that does not answer systemctl --user show --property=Version with a version as SystemdUnit.inspectManagerVersion() reads it
manager_too_old The user manager is older than systemd 254; a SystemdTransientScopeManagerTooOldError naming its managerVersion and the requiredMajor. Nothing was started
scope_start_failed systemd-run exited without reporting the scope running; the program has not run
timed_out The manager probe or the program did not finish and close its output within commandTimeoutMs
output_limit The program's stdout or stderr exceeded 1 MiB

The call completes once the direct process has exited and its stdout and stderr have closed, so a daemon must not keep them open (Codex, for example, redirects its daemon's output); otherwise the call runs into its timeout. On timed_out and output_limit the direct process is killed and the processes it started stay in the scope. commandTimeoutMs (default 120000, maximum 600000) bounds the probe and the program each. Trusted owning code can set runtimeDirectory and the absolute systemdRunPath and systemctlPath. systemd-run reports the started scope as its first stderr line, which systemd 254 and later print, and systemd-run before 254 lacks --expand-environment. The manager probe's version stands for the installation's: a user manager older than 254 is refused with manager_too_old before systemd-run starts. A systemd-run older than the manager it addresses still fails with scope_start_failed. If the program cannot be executed after the scope started, systemd-run reports that as the program's exit status 1.

SmartDaemonService Class

interface ISmartDaemonServiceOptions {
  name: string;           // Unique service identifier
  description: string;    // Human-readable description
  command: string;        // Command to execute
  workingDir: string;     // Working directory (absolute path)
  version: string;        // Service version
  user?: string;          // User to run service as (optional)
  group?: string;         // Group to run service as (optional)
}

class SmartDaemonService {
  // Lifecycle management
  async enable(): Promise<void>;   // Install and enable service
  async disable(): Promise<void>;  // Disable service
  async start(): Promise<void>;    // Start service
  async stop(): Promise<void>;     // Stop service
  
  // Service management
  async save(): Promise<void>;     // Save service configuration
  async delete(): Promise<void>;   // Remove service
  async reload(): Promise<void>;   // Reload systemd daemon
}

🎭 Real-World Examples

Microservice Deployment

import { SmartDaemon } from '@push.rocks/smartdaemon';

async function deployMicroservices() {
  const daemon = new SmartDaemon();
  
  // API Gateway
  const apiGateway = await daemon.addService({
    name: 'api-gateway',
    description: 'API Gateway Service',
    command: 'node --max-old-space-size=2048 gateway.js',
    workingDir: '/opt/services/gateway',
    version: '3.2.1',
    user: 'apiuser',
    group: 'apigroup'
  });
  
  // Auth Service
  const authService = await daemon.addService({
    name: 'auth-service',
    description: 'Authentication Service',
    command: 'node auth-service.js',
    workingDir: '/opt/services/auth',
    version: '2.1.0',
    user: 'authuser',
    group: 'authgroup'
  });
  
  // Database Sync Worker
  const dbWorker = await daemon.addService({
    name: 'db-sync-worker',
    description: 'Database Synchronization Worker',
    command: 'node workers/db-sync.js',
    workingDir: '/opt/services/workers',
    version: '1.5.3',
    user: 'dbworker',
    group: 'dbgroup'
  });
  
  // Enable and start all services
  const services = [apiGateway, authService, dbWorker];
  
  for (const service of services) {
    await service.enable();
    await service.start();
    console.log(`✅ Service ${service.name} is running`);
  }
}

deployMicroservices();

Scheduled Task Runner

async function setupScheduledTasks() {
  const daemon = new SmartDaemon();
  
  // Backup service that runs every night
  const backupService = await daemon.addService({
    name: 'nightly-backup',
    description: 'Nightly database backup service',
    command: 'node --require dotenv/config backup-runner.js',
    workingDir: '/opt/backup',
    version: '1.0.0',
    user: 'backup',
    group: 'backup'
  });
  
  await backupService.enable();
  await backupService.start();
}

Development vs Production Setup

import { SmartDaemon } from '@push.rocks/smartdaemon';

async function setupService(environment: 'development' | 'production') {
  let daemonOptions = {};
  
  if (environment === 'development') {
    // In development, you might use sudo password
    daemonOptions = {
      sudoPassword: process.env.SUDO_PASSWORD
    };
  }
  // In production, rely on passwordless sudo configuration
  
  const daemon = new SmartDaemon(daemonOptions);
  
  const service = await daemon.addService({
    name: `app-${environment}`,
    description: `Application ${environment} server`,
    command: environment === 'production' 
      ? 'node --production app.js'
      : 'node --inspect app.js',
    workingDir: '/opt/application',
    version: '1.0.0',
    user: environment === 'production' ? 'appuser' : process.env.USER,
    group: environment === 'production' ? 'appgroup' : process.env.USER
  });
  
  await service.enable();
  await service.start();
}

🔧 Advanced Usage

Direct SystemdManager Access

For advanced users who need fine-grained control:

const daemon = new SmartDaemon();

// Access the SystemdManager directly
const systemdManager = daemon.systemdManager;

// Check if running on a compatible system
const canRun = await systemdManager.checkElegibility();

// Get all existing SmartDaemon services
const existingServices = await systemdManager.getServices();

// Reload systemd daemon
await systemdManager.reload();

Custom Service Templates

Access the template manager for custom service file generation:

const daemon = new SmartDaemon();
const template = daemon.templateManager.generateUnitFileForService(myService);
console.log(template); // View generated systemd unit file

🏗️ System Requirements

  • Operating System: Linux with systemd (Ubuntu 16.04+, Debian 9+, CentOS 7+, RHEL 7+, Fedora, Arch, etc.)
  • systemd 254 or later for SystemdUnit.inspectServiceSettings(), SystemdUnit.cleanFileDescriptorStore(), runInTransientScope() and for installing a definition with fileDescriptorStorePreserve (Debian 13, Ubuntu 24.04, Fedora 39 and later ship it); an older manager is refused by name with manager_too_old
  • Node.js: Version 14.x or higher
  • Permissions: Either root access or properly configured sudo

⚙️ Generated Systemd Service Files

SmartDaemon generates professional systemd unit files with:

  • Automatic restart on failure
  • Network target dependencies
  • Proper working directory configuration
  • User/Group assignment
  • Resource limits configuration
  • Syslog integration for logging
  • Environment variable support

Example generated service file:

[Unit]
Description=My API Server
Requires=network.target
After=network.target

[Service]
Type=simple
User=www-data
Group=www-data
Environment=NODE_OPTIONS="--max_old_space_size=100"
ExecStart=/bin/bash -c "cd /opt/myapp && node server.js"
WorkingDirectory=/opt/myapp
Restart=always
RestartSec=10
LimitNOFILE=infinity
LimitCORE=infinity
StandardOutput=syslog
StandardError=syslog

[Install]
WantedBy=multi-user.target

🐛 Troubleshooting

Permission Denied Errors

If you encounter "Interactive authentication required" errors:

  1. Option 1: Run your application with sudo

    sudo node your-app.js
    
  2. Option 2: Configure passwordless sudo (recommended for production)

  3. Option 3: Provide sudo password programmatically

    const daemon = new SmartDaemon({ sudoPassword: 'your-password' });
    

Service Not Found

If systemctl can't find your service:

  • Check that the service was enabled: await service.enable()
  • Verify the service name: Services are prefixed with smartdaemon_
  • Reload systemd: await service.reload()

Service Fails to Start

Check logs using:

journalctl -u smartdaemon_your-service-name -f

🔒 Security Best Practices

  1. Never run services as root unless absolutely necessary
  2. Use dedicated service users with minimal permissions
  3. Configure passwordless sudo for production instead of storing passwords
  4. Limit sudo permissions to only the specific systemctl commands needed
  5. Regular security audits of your service configurations

🤝 Support

This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the license file within this repository.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.

Company Information

Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany

For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
Start scripts as long running daemons and manage them.
Readme
3 MiB
Languages
TypeScript 96.4%
Python 3.6%