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 withsystemctl 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:
AfterandRequires: two distinct notions.Afterdefines the start order;Requiresdefines a dependency: if MySQL is stopped, the service is stopped too.Wantsis a softer variant that starts the dependency without failing if it is missing. On some distributions the service is calledmariadb.service: check the exact name.Type=simple: the process started byExecStartis the main process and stays in the foreground. That is the case formessenger:consume. UseType=oneshotfor a command that runs and then exits.UserandGroup: 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 asbin/consoleare resolved from this directory.Restart=alwaysandRestartSec=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-failurewould 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
AfterandRequires - Set
Restart=alwaysfor resilience - Use the systemd journal rather than log files
- Limit resources with cgroups
- Never run an application as root: always set
UserandGroup - Store secrets in a protected
EnvironmentFile - Use
systemctl editto 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.