◀ Back to blog
Linux

Creating systemd services

Published on 25 Sep 2024· 7 min read
#Linux#Systemd#Services

Managing your applications with systemd

systemd is the standard service manager on Linux. Creating a custom service lets you manage your applications' lifecycle: automatic start at boot, restart after a crash, start order relative to the database, centralised logs and resource limits. All of this without installing an extra supervisor: systemd already ships with Debian, Ubuntu, RHEL and nearly every current distribution.

For a PHP application, typical use cases are message queue workers, long-running consumers, small embedded HTTP servers and scheduled tasks. Let's see how to write reliable units, then how to operate them day to day.

Anatomy of a unit file

A service is described by an INI-style text file, placed in /etc/systemd/system/ for machine-specific units. It has three sections:

  • [Unit]: description and dependencies on other units.
  • [Service]: how to start, stop and restart the process, and under which identity.
  • [Install]: which target the service is attached to when you enable it with systemctl enable.

Creating a service

# /etc/systemd/system/myapp.service
[Unit]
Description=My PHP Application
After=network.target mysql.service
Requires=mysql.service

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/php bin/console messenger:consume async --limit=100
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

The important directives:

  • After and Requires: two distinct notions. After defines the start order; Requires defines a dependency: if MySQL is stopped, the service is stopped too. Wants is a softer variant that starts the dependency without failing if it is missing. On some distributions the service is called mariadb.service: check the exact name.
  • Type=simple: the process started by ExecStart is the main process and stays in the foreground. That is the case for messenger:consume. Use Type=oneshot for a command that runs and then exits.
  • User and Group: never run an application as root. Here, the worker has the same permissions as PHP-FPM.
  • WorkingDirectory: the executable path must be absolute, but arguments such as bin/console are resolved from this directory.
  • Restart=always and RestartSec=5: with --limit=100, the worker stops on purpose after 100 messages; systemd restarts it five seconds later with a clean memory state. Restart=on-failure would only restart processes that exit with an error.
  • StandardOutput=journal: the process output is captured by journald, timestamped and tied to the service.

Essential commands

sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
sudo systemctl status myapp
journalctl -u myapp -f

daemon-reload is mandatory after every creation or change of a unit file: without it, systemd keeps using the old version. enable creates the link that starts the service at boot, start launches it immediately; enable --now does both. Before loading a file, systemd-analyze verify /etc/systemd/system/myapp.service reports unknown directives and typos.

For logs, journalctl offers valuable filters during an incident:

# Logs from the last hour, errors only
journalctl -u myapp --since "1 hour ago" -p err

# Logs from the current boot, without paging
journalctl -u myapp -b --no-pager

Avoiding restart loops

A service that crashes immediately on start (missing configuration file, unreachable database) will be restarted in a loop. By default, systemd gives up after 5 starts within 10 seconds and marks the service as failed. With RestartSec=5, that threshold is never reached, so adjust the window in the [Unit] section:

[Unit]
StartLimitIntervalSec=300
StartLimitBurst=10

Here, more than 10 starts within 5 minutes put the service into the failed state, which shows up in systemctl --failed and is easy to monitor. Once the cause is fixed, systemctl reset-failed myapp resets the counter.

Managing Symfony Messenger workers

For Symfony applications using Messenger, create a service with several instances:

# /etc/systemd/system/messenger-worker@.service
[Unit]
Description=Symfony Messenger worker %i

[Service]
User=www-data
Group=www-data
ExecStart=/usr/bin/php /var/www/app/bin/console messenger:consume async --time-limit=3600
Restart=always

[Install]
WantedBy=multi-user.target

# Start 3 workers
sudo systemctl enable messenger-worker@{1..3}
sudo systemctl start messenger-worker@{1..3}

The @ in the file name makes it a template: messenger-worker@1, messenger-worker@2, etc. are separate instances of the same file, and %i is replaced with the instance identifier. The {1..3} notation is a Bash shell expansion, not a systemd feature.

The --time-limit=3600 option makes each worker restart every hour, which limits the impact of memory leaks. You can also use --memory-limit=128M. During a deployment, workers keep the old code in memory: run php bin/console messenger:stop-workers at the end of the deployment. Each worker finishes its current message and exits, and systemd restarts it on the new code.

Environment variables and overrides

Do not put secrets in the unit file, which is world-readable. Use a protected environment file:

[Service]
EnvironmentFile=/etc/myapp/env
Environment=APP_ENV=prod

The /etc/myapp/env file contains KEY=value lines and should be owned by root with 600 permissions: systemd reads it before switching user. To change a service shipped by a package without touching the original file, use sudo systemctl edit nginx: the command creates an override file in /etc/systemd/system/nginx.service.d/ that survives upgrades.

Limiting resources and isolating the service

systemd places each service in its own cgroup, which lets you cap its resources and restrict what it can see of the system:

[Service]
MemoryMax=512M
CPUQuota=50%
TasksMax=100
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true

MemoryMax kills the process if it exceeds the limit, instead of letting the kernel pick a random victim. CPUQuota=50% limits the service to half of one core. PrivateTmp gives it an isolated /tmp, ProtectSystem=full makes /usr, /boot and /etc read-only, and ProtectHome hides home directories. The systemd-analyze security myapp command gives the service an exposure score and lists the available hardening options.

Replacing cron with a timer

systemd timers are a modern alternative to cron: runs show up in the journal, a failure is visible in systemctl --failed, and a run missed while the machine was down can be caught up. A timer activates the service with the same name:

# /etc/systemd/system/app-cleanup.service
[Unit]
Description=Daily application cleanup

[Service]
Type=oneshot
User=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/php bin/console app:cleanup

# /etc/systemd/system/app-cleanup.timer
[Unit]
Description=Run app-cleanup every day at 3am

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now app-cleanup.timer
systemctl list-timers
systemd-analyze calendar "*-*-* 03:00:00"

Persistent=true triggers the service at boot if the scheduled run was missed. systemd-analyze calendar checks an expression and shows the next elapse time.

Best practices

  • Define dependencies with After and Requires
  • Set Restart=always for resilience
  • Use the systemd journal rather than log files
  • Limit resources with cgroups
  • Never run an application as root: always set User and Group
  • Store secrets in a protected EnvironmentFile
  • Use systemctl edit to override a service shipped by a package
  • Restart workers on every deployment with messenger:stop-workers

systemd is not the right answer everywhere: in a containerised environment, the orchestrator (Docker, Kubernetes) manages process lifecycles. But on a classic server or a VPS, a few well-written unit files are a better replacement for Supervisor, home-made init scripts and a good part of the crontab.