
August 20, 2026
9 min read
Table of Contents
By Kokil Thapa | Last reviewed: August 2026
If you are deploying PHP applications, background workers, or custom APIs on Ubuntu servers in 2026, you must know how to write a systemd service unit. Relying on cron hacks, screen sessions, or unmanaged processes is no longer acceptable for production reliability. Systemd provides the standardized interface for starting, stopping, logging, and automatically recovering your application processes.
.service file in /etc/systemd/system/ defining the [Unit], [Service], and [Install] sections. Specify the executable path, user context, and restart policy, then run systemctl daemon-reload and systemctl enable --now to activate it.For developers managing infrastructure alongside application code—a common reality when you work as a full-stack developer handling both Laravel logic and server operations—mastering service units is non-negotiable. I have seen too many production outages caused by queue workers that died silently because they were started manually via SSH rather than managed by the init system. Whether you are running Laravel Horizon, a Node.js API, or a Python data processor, the principles remain consistent.
How do you structure a basic systemd service unit file?
A systemd unit file consists of three primary sections: [Unit], [Service], and [Install]. Each serves a distinct purpose in defining metadata, execution behavior, and boot integration. Missing any section often leads to unpredictable behavior or failure to start at boot.
The [Unit] Section: Metadata and Dependencies
This section describes the service and its relationship to other system components. The Description field appears in systemctl status output and logs—make it meaningful. More critically, After= and Requires= (or Wants=) control startup ordering. For a Laravel application depending on MySQL and Redis, you would specify:
[Unit]
Description=Laravel Queue Worker
After=network.target mysql.service redis-server.service
Wants=mysql.service redis-server.service Using Wants= instead of Requires= is usually safer for web applications; it attempts to start dependencies but doesn't fail the entire unit if a dependency is unavailable during transient maintenance windows.
The [Service] Section: Execution Configuration
This is where you define how the process actually runs. Key directives include:
- Type=: Use
simplefor most long-running daemons. Usenotifyonly if your application explicitly sends sd_notify signals. Avoidforkingunless wrapping legacy software. - User=/Group=: Never run application services as root. Create a dedicated user or use
www-datafor PHP applications. - WorkingDirectory=: Set this explicitly. Many frameworks expect to run from their project root.
- EnvironmentFile=: Point to a
.envfile rather than hardcoding secrets in the unit file itself.
The [Install] Section: Boot Integration
Without this section, systemctl enable will fail. WantedBy=multi-user.target is the standard target for server-side applications that should start during normal multi-user boot (i.e., not graphical desktop).
What is the correct way to configure environment variables and paths?
A frequent mistake when learning to write a systemd service unit is assuming the shell environment carries over. Systemd services run in a minimal, sanitized environment. Your $PATH, $HOME, and application-specific variables will not exist unless explicitly defined.
Use EnvironmentFile for Application Secrets
Never embed database passwords or API keys directly in the unit file. Instead, reference an external file with restricted permissions:
[Service]
EnvironmentFile=/var/www/myapp/.env.systemd
Environment=APP_ENV=production
Environment=LOG_CHANNEL=stderr Ensure the environment file is owned by root with chmod 600 permissions. This separates deployment artifacts (the unit file) from sensitive configuration, making GitOps workflows safer.
Always Use Absolute Paths
Systemd does not perform PATH resolution like an interactive shell. Every executable reference must be absolute. Find the real path with which or readlink -f:
# WRONG - may fail unpredictably
ExecStart=php artisan queue:work
# CORRECT - explicit binary path
ExecStart=/usr/bin/php /var/www/myapp/artisan queue:work On Ubuntu 24.04 LTS systems I manage, PHP binaries are typically at /usr/bin/php8.3 or /usr/bin/php8.4 when installed from Ondřej Surý's PPA. Always verify with which php before writing the unit.
How do you implement automatic restarts and resource limits?
Production services must self-heal. Systemd provides granular restart policies and cgroup-based resource controls that eliminate the need for external monitoring scripts.
Choosing the Right Restart Policy
| Policy | Behavior | Best For |
|---|---|---|
on-failure | Restarts on non-zero exit, signal termination, timeout, or watchdog trigger | Most application services, queue workers |
always | Restarts regardless of exit code (except manual stop) | Critical infrastructure, health-check proxies |
on-abnormal | Restarts only on signals, timeouts, watchdog failures | Services where clean exits are intentional |
no | Never auto-restart | One-shot tasks, batch jobs |
For Laravel queue workers, Restart=on-failure combined with RestartSec=5 prevents rapid restart loops while ensuring recovery from crashes. Add rate limiting to prevent infinite crash cycles:
[Service]
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5 This configuration allows five restarts within 60 seconds before systemd stops trying and marks the unit as failed—triggering alerts if you have monitoring configured.
Resource Limits via Cgroups
Prevent runaway processes from consuming all server resources. These directives work natively in systemd v256+ (standard on Ubuntu 24.04):
[Service]
MemoryMax=512M
CPUQuota=80%
TasksMax=256 I routinely set MemoryMax on PHP worker services to 512MB or 1GB depending on workload. Without this limit, a memory leak in a third-party package can eventually trigger the OOM killer, taking down unrelated services including your database.
How do you secure a systemd service unit against privilege escalation?
Security hardening is not optional for internet-facing applications. When you write a systemd service unit for production, apply these sandboxing directives systematically. They cost nothing in performance but significantly reduce attack surface.
Essential Hardening Directives
[Service]
# Run as unprivileged user
User=www-data
Group=www-data
# Filesystem restrictions
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/www/myapp/storage /var/www/myapp/bootstrap/cache
PrivateTmp=yes
# Capability restrictions
NoNewPrivileges=yes
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
# Network restrictions (if applicable)
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX ProtectSystem=strict mounts the entire filesystem read-only except paths explicitly listed in ReadWritePaths. This prevents compromised applications from modifying system binaries or configuration files. For Laravel applications, you must whitelist storage/ and bootstrap/cache/ directories.
Why NoNewPrivileges Matters
This directive prevents child processes from gaining elevated privileges via setuid/setgid binaries or file capabilities. Even if an attacker exploits your application, they cannot escalate to root through common privilege escalation vectors. There is virtually no legitimate reason for a web application service to require new privileges after startup.
When building secure Laravel APIs, these systemd-level protections complement application-layer security. Defense in depth means assuming every layer might fail independently.
How do you debug and validate systemd service units effectively?
Writing the unit file is only half the work. Validating behavior and diagnosing failures requires specific tooling beyond basic systemctl status.
Syntax Validation Before Deployment
Always validate unit files before reloading the daemon. systemd-analyze verify catches syntax errors, invalid directives, and missing dependencies without risking a broken production state:
sudo systemd-analyze verify /etc/systemd/system/myapp-worker.service This command returns warnings for deprecated options, permission issues, and structural problems. Treat warnings as errors in CI pipelines when automating deployments.
Effective Log Inspection
systemctl status shows only the last 10 log lines. For real debugging, use journalctl with proper filters:
# Follow live logs
sudo journalctl -u myapp-worker.service -f --no-pager
# View logs since last boot
sudo journalctl -u myapp-worker.service -b
# Filter by priority (errors only)
sudo journalctl -u myapp-worker.service -p err..emerg
# Show logs with timestamps and full output
sudo journalctl -u myapp-worker.service --since "2026-08-20 10:00:00" --output=verbose Configure your application to log to stdout/stderr rather than files when running under systemd. Journal handles rotation, compression, and querying far better than custom logrotate configurations. For Laravel, set LOG_CHANNEL=stderr in your environment file.
Common Failure Patterns
- Permission denied on ExecStart: Verify the binary exists, is executable (
chmod +x), and the User has traverse permissions on parent directories. - Failed to load environment files: Check
EnvironmentFilepath is absolute and readable by the service user. Remember systemd parses this file differently than bash—no variable expansion or command substitution. - Service starts then immediately exits: Usually indicates wrong
Type=setting. If your app forks to background, useType=forkingwithPIDFile=. If it stays foreground, useType=simple. - Working directory not found: Ensure
WorkingDirectoryexists before service start. Deployer or Capistrano releases may change paths between deployments.
Write a systemd service unit: Production Checklist
Getting service management right separates reliable production systems from fragile prototypes. When you write a systemd service unit, follow this checklist before considering the task complete:
- Unit file passes
systemd-analyze verifywithout warnings - Service runs as non-root user with minimal capabilities
- All paths are absolute; no shell features assumed
- Restart policy matches failure semantics (
on-failurefor apps) - Resource limits prevent runaway consumption
- Logs flow to journald, not arbitrary files
- Unit is enabled for boot persistence
- Monitoring alerts on unit failure state
For teams managing multiple services across staging and production environments, consider templated units (@.service suffix) to reduce duplication. A single laravel-worker@.service template can manage dozens of queue configurations by passing instance names as arguments.
Infrastructure discipline compounds over time. Every properly managed service reduces midnight pages and deployment anxiety. If you need help auditing your current service configurations or establishing reliable deployment practices, reach out to discuss your infrastructure needs.

