Creating Custom systemd Services for PHP Applications
AI generated
$
/etc
Linux · systemd · PHP · DevOps
Creating Custom systemd Services for PHP Applications
From unit file to a resilient queue worker

Anyone keeping PHP workers and queue consumers alive with nothing more than nohup or a screen session risks silent outages after every server reboot, deployment, or crash. A properly written systemd unit file with a clear restart policy, a dedicated service user, and automatic journald integration makes PHP background processes resilient, observable, and production-ready, without needing extra tools like Supervisor or Monit.

16 min read ExecStart · Restart policy · journald systemd 249+ · PHP 8.4 · Debian/Ubuntu

1. Why PHP workers need their own systemd service

Long-running PHP processes such as queue consumers, message workers, or import scripts are, in many projects, started with nohup php worker.php & or inside a screen session. That works fine as long as nobody reboots the server, closes the SSH terminal, or the process crashes on an unhandled error. It's exactly at those moments that the worker fails silently, the queue backs up, and nobody notices until customers start complaining about missing order confirmations or delayed exports.

A dedicated systemd service solves this problem structurally: Linux's init process supervises the worker, starts it automatically at boot, restarts it after a crash, and writes every output line to a central log. Unlike cron jobs, which briefly start a task on a repeating schedule, a systemd service runs continuously in the background, making it the right choice for queue consumers, WebSocket servers, or scheduler daemons that need to stay permanently active.

2. Anatomy of a unit file: Unit, Service, Install

A systemd unit file is organized into three sections. The [Unit] block describes metadata and dependencies: Description for a human-readable name, and After/Wants to specify that the service should only start once the network, database, or Redis are available. Without these dependencies, a worker can start before the database connection is ready and immediately abort with an exception.

The [Service] block holds the actual configuration: which command runs, under which user, in which working directory, and with which restart strategy. The [Install] block defines which system target (Target) the service is wired into, usually multi-user.target for servers without a graphical interface. Only the [Install] block makes a service permanently enable-able via systemctl enable, without having to start it manually after every reboot.

3. Configuring ExecStart correctly

ExecStart is the central directive that launches the actual process. The most common mistake: a relative command like php worker.php without an absolute path. systemd starts services without a shell and without the usual environment variables such as PATH from an interactive login shell, so both the PHP binary path and the script path must be written out in full, for example /usr/bin/php /var/www/html/bin/magento queue:consumers:start async.operations.all.

For Magento projects, the queue consumer command is a realistic example: --max-messages caps how many messages get processed per run, preventing memory from building up in the PHP process over days, similar to a memory leak caused by long-lived objects. systemd automatically restarts the process once the limit is reached, provided Restart=always is set. It's also important to set Type=simple for processes that run in the foreground and don't fork themselves, which is the default case for most PHP CLI workers.


# /etc/systemd/system/mironsoft-queue-worker.service
[Unit]
Description=Mironsoft Magento Queue Consumer (async.operations.all)
After=network-online.target mysql.service redis-server.service
Wants=network-online.target mysql.service redis-server.service

[Service]
Type=simple
User=magento
Group=magento
WorkingDirectory=/var/www/html
EnvironmentFile=/etc/mironsoft/queue-worker.env
ExecStart=/usr/bin/php /var/www/html/bin/magento queue:consumers:start async.operations.all --max-messages=10000
Restart=always
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5
TimeoutStopSec=30
KillSignal=SIGTERM

# Everything written to stdout/stderr lands in journald automatically
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mironsoft-queue-worker

[Install]
WantedBy=multi-user.target

4. Restart policy for resilient worker processes

By default, Restart=no is set, meaning a service stays inactive after every exit, whether intentional or caused by an error. For a permanently running queue worker, Restart=always is the right choice, since it restarts the process after a regular exit as well as after a crash or a SIGKILL. RestartSec=5 waits five seconds between restart attempts, preventing a persistently failing worker from hammering the CPU with an endless restart loop.

StartLimitIntervalSec and StartLimitBurst additionally limit how many times systemd is allowed to restart within a given time window before the service permanently moves into the failed state, which should trigger an alert. Without this limit, a worker with a structural bug, such as incorrect database credentials, would restart endlessly, filling up log files and system resources without anyone noticing the problem.


# Test restart behavior under real conditions
systemctl status mironsoft-queue-worker --no-pager

# Simulate a process crash
sudo kill -9 $(systemctl show --property MainPID --value mironsoft-queue-worker)

# Watch live as systemd restarts the worker automatically
journalctl -u mironsoft-queue-worker -f --since "1 minute ago"

# Check the restart counter and the last exit code
systemctl show mironsoft-queue-worker --property=NRestarts,ExecMainStatus,ActiveState

5. User and group: never run as root

Without an explicit User directive, a systemd service runs as root by default, and that applies just as much to PHP workers that don't need any privileged operation at all. A queue consumer that processes order data or writes files to var/import doesn't need root access, only read and write permissions on the Magento installation. The principle of least privilege significantly limits the damage if a security vulnerability in the code or a dependency is ever exploited.

In practice, that means using the same user under which php-fpm and the web server processes run, typically www-data or a dedicated magento user. User=magento and Group=magento in the unit file ensure that file permissions stay consistent and the worker has the same access rights as the rest of the application, no more and no less. Additionally, NoNewPrivileges=true and ProtectSystem=strict can further reduce the attack surface without affecting the worker's functionality.

6. Loading environment variables with EnvironmentFile

PHP applications frequently read configuration from environment variables, such as database credentials, API keys, or feature flags. An interactive shell loads these from ~/.bashrc or a .env file, but a systemd service has no automatic knowledge of these files. If the matching configuration is missing from the unit file, the worker starts with empty variables and often fails only seconds later with a cryptic database error, even though the same command runs flawlessly when started manually in the shell.

The EnvironmentFile directive points to a separate file in KEY=VALUE format that systemd reads before startup and passes to the process as environment variables. Alternatively, Environment=KEY=VALUE works directly in the unit file for individual values; for several related variables, though, a separate file is clearer and can be protected independently of the unit file with tighter file permissions, for example chmod 600 for credentials.


# /etc/mironsoft/queue-worker.env
# File permissions: chmod 600, owner magento:magento
MAGENTO_MODE=production
PHP_MEMORY_LIMIT=756M
QUEUE_MAX_MESSAGES=10000
DATABASE_HOST=127.0.0.1
DATABASE_NAME=magento
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

# Referenced in the unit file via:
# EnvironmentFile=/etc/mironsoft/queue-worker.env

7. Enabling, starting, and testing the service

Once the unit file is placed under /etc/systemd/system/, systemd first needs to be told about the new file. systemctl daemon-reload re-reads all unit files, a step that's mandatory after every change to a unit file and, in practice, the most frequently forgotten command when debugging: without daemon-reload, systemd keeps using the old, cached configuration even though the file on disk has already been updated.

systemctl enable --now mironsoft-queue-worker.service enables the service for future boots and starts it immediately in a single step. The status command then shows whether the process is running, since when, under which PID, and with which exit code it last stopped. A service stuck in the activating (auto-restart) state points to a problem during startup, usually a mistake in the ExecStart path, missing permissions, or an unreachable database.


# Register the unit file and enable the service
sudo cp mironsoft-queue-worker.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now mironsoft-queue-worker.service

# Check the status
sudo systemctl status mironsoft-queue-worker.service --no-pager

# Example output
● mironsoft-queue-worker.service - Mironsoft Magento Queue Consumer (async.operations.all)
     Loaded: loaded (/etc/systemd/system/mironsoft-queue-worker.service; enabled)
     Active: active (running) since Sun 2026-07-12 08:14:02 UTC; 3min ago
   Main PID: 48213 (php)
      Tasks: 4 (limit: 4678)
     Memory: 41.2M
        CPU: 2.108s

# Run again after every change to the unit file
sudo systemctl daemon-reload
sudo systemctl restart mironsoft-queue-worker.service

8. Logging with journald: automatic instead of manual

As long as StandardOutput and StandardError aren't explicitly redirected, systemd automatically writes every output of a service into journald, systemd's central logging service. A PHP worker doesn't need to do anything special for this, every echo, every unhandled exception with its stack trace, and every warning ends up in the journal without extra configuration, complete with timestamp, PID, and the correct unit assignment. This fully replaces manual log-file handling with its own rotation for most use cases.

journalctl -u mironsoft-queue-worker -f follows the logs live, similar to tail -f, but with structured metadata. Time windows can be narrowed down precisely, output filtered by priority, and results exported as JSON for further processing in other systems. For production setups with multiple workers, it's also worth making the journal persist permanently instead of keeping it only in memory, via Storage=persistent in /etc/systemd/journald.conf, otherwise logs are lost on a server reboot.


# Follow the logs of a single service live
journalctl -u mironsoft-queue-worker -f

# Narrow down the time window
journalctl -u mironsoft-queue-worker --since "2026-07-12 08:00" --until "2026-07-12 09:00"

# Show only errors and critical messages
journalctl -u mironsoft-queue-worker -p err

# Export as structured JSON, e.g. for log shipping
journalctl -u mironsoft-queue-worker -o json-pretty -n 20

# Logs since the last boot
journalctl -u mironsoft-queue-worker -b

9. Common unit file mistakes compared

Most problems with hand-written unit files follow recurring patterns. A service that runs flawlessly when started manually in the shell but immediately fails under systemd with failed almost always has one of these causes: a missing working directory, relative paths, or missing environment variables that were silently present in the interactive shell.

The table below shows the most common sources of error and the corresponding correct fix, so a new worker starts on the first try instead of having to be tediously narrowed down over multiple debugging rounds with journalctl.

Source of error Wrong Correct Impact
Working directory no WorkingDirectory WorkingDirectory=/var/www/html Relative paths in the code otherwise break with "file not found"
ExecStart path ExecStart=php worker.php ExecStart=/usr/bin/php /var/www/html/worker.php No PATH without a login shell, startup fails immediately
Environment variables no EnvironmentFile EnvironmentFile=/etc/mironsoft/worker.env Cryptic DB errors instead of a clear configuration error
Executing user implicitly runs as root User=magento, Group=magento Unnecessary privileges, larger attack surface
Restart after crash Restart=no (default) Restart=always, RestartSec=5 Worker stays permanently inactive after a crash

Mironsoft

systemd services, queue workers, and deployment infrastructure for PHP and Magento

PHP workers that run reliably in the background?

We write and harden systemd unit files for your queue consumers and worker processes, with correct restart policy, dedicated service user, and journald integration, production-ready and without extra tools.

Writing unit files

ExecStart, restart policy, and resource limits configured properly

Security hardening

Dedicated service users, NoNewPrivileges, and ProtectSystem

Logging & monitoring

journald persistence, alerting on failed states

10. Summary

Custom systemd services for PHP applications solve a problem that nohup, screen, or tmux cannot structurally solve: staying available through reboots and crashes. A unit file with ExecStart pointing to absolute paths, Restart=always combined with a sensible RestartSec, a dedicated User instead of root, and an EnvironmentFile for configuration values covers most of the requirements for a production-ready PHP worker.

The biggest advantage over cron jobs or manually started background processes is the automatic journald integration: no extra log-file handling, no custom rotation, no lost error messages. Anyone who consistently applies the patterns shown in this article to all PHP workers in a project ends up with uniform, observable, and resilient background processes that can be monitored with the same tools as any other systemd service.

Creating Custom systemd Services for PHP Applications: The Essentials at a Glance

ExecStart

Always use absolute paths for the binary and the script, no PATH available without a login shell.

Restart policy

Restart=always with RestartSec and StartLimitBurst against endless restart loops.

User & group

A dedicated service user instead of root, with the same rights as php-fpm.

journald logging

Automatic without extra configuration, filterable via journalctl -u.

11. FAQ: Creating Custom systemd Services for PHP Applications

1Why isn't a cron job enough for a queue consumer?
Cron jobs start briefly and end again. A queue consumer needs to run continuously and wait for messages. systemd keeps the process active, restarts it after crashes, and handles logging.
2Where do custom unit files need to be placed?
Under /etc/systemd/system/. After every change, systemctl daemon-reload is mandatory, otherwise systemd keeps using the old configuration.
3Why does a command work in the shell but not as a service?
systemd starts without a login shell and without its environment variables such as PATH. Relative paths and missing .env values therefore cause errors that don't occur in the shell.
4What exactly does Restart=always do?
Restarts on every exit, whether regular, crash, or signal. RestartSec and StartLimitBurst still keep the behavior under control.
5Why shouldn't a PHP worker run as root?
Without an explicit User directive, a service runs as root. That increases the damage from a security vulnerability. A dedicated service user with the same rights as php-fpm is enough.
6How do environment variables get into a systemd service?
Via EnvironmentFile with a separate KEY=VALUE file, or via Environment=KEY=VALUE directly in the unit file. A .env file in the project is not read automatically.
7Do PHP logs end up in journald automatically?
Yes, as long as StandardOutput/StandardError aren't redirected. Every output, including exceptions, ends up structured in the journal, retrievable via journalctl -u.
8How do I test whether the restart policy works?
Hard-kill the process with kill -9 on the main PID and watch with journalctl -u <service> -f whether systemd restarts it within RestartSec.
9What does activating (auto-restart) mean?
The service keeps starting repeatedly and fails shortly after. Typical causes: incorrect ExecStart path, missing permissions, or an unreachable database/Redis connection.
10Why should I set WorkingDirectory explicitly?
Without WorkingDirectory, the process starts in the root directory. Relative paths in the code then resolve to nothing and produce file-not-found errors that are otherwise hard to trace.