Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Write a systemd Service Unit

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.

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.

[Unit]Description=My AppAfter=network.targetWants=mysql.service[Service]Type=simpleUser=www-dataExecStart=/path/binRestart=on-failure[Install]WantedBy=multi-user
The three mandatory sections of every systemd service unit file

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 simple for most long-running daemons. Use notify only if your application explicitly sends sd_notify signals. Avoid forking unless wrapping legacy software.
  • User=/Group=: Never run application services as root. Create a dedicated user or use www-data for PHP applications.
  • WorkingDirectory=: Set this explicitly. Many frameworks expect to run from their project root.
  • EnvironmentFile=: Point to a .env file 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.

Process ExitsCheck Restart= Policyon-successExit 0 → Restarton-failureNon-zero / SignalalwaysAny Exit → RestartRespect RestartSec= & StartLimitBurst=
Systemd restart policy decision flow for production services

Choosing the Right Restart Policy

PolicyBehaviorBest For
on-failureRestarts on non-zero exit, signal termination, timeout, or watchdog triggerMost application services, queue workers
alwaysRestarts regardless of exit code (except manual stop)Critical infrastructure, health-check proxies
on-abnormalRestarts only on signals, timeouts, watchdog failuresServices where clean exits are intentional
noNever auto-restartOne-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.

Edit Unit Filevim /etc/systemd/...Validate Syntaxsystemd-analyze verifyReload Daemonsystemctl daemon-reloadStart & Testsystemctl start + statusInspect Logsjournalctl -u name -fFix & IterateEdit → Reload → Retry
Iterative debugging workflow for systemd service units

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

  1. Permission denied on ExecStart: Verify the binary exists, is executable (chmod +x), and the User has traverse permissions on parent directories.
  2. Failed to load environment files: Check EnvironmentFile path is absolute and readable by the service user. Remember systemd parses this file differently than bash—no variable expansion or command substitution.
  3. Service starts then immediately exits: Usually indicates wrong Type= setting. If your app forks to background, use Type=forking with PIDFile=. If it stays foreground, use Type=simple.
  4. Working directory not found: Ensure WorkingDirectory exists 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 verify without warnings
  • Service runs as non-root user with minimal capabilities
  • All paths are absolute; no shell features assumed
  • Restart policy matches failure semantics (on-failure for 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.

Frequently Asked Questions

Custom service units belong in /etc/systemd/system/ with a .service suffix. Use descriptive names like myapp-web.service rather than generic labels. This directory takes precedence over vendor files in /lib/systemd/system/, ensuring your configuration survives package upgrades on Ubuntu 22.04 or 24.04 servers.

Define [Unit] with Description and After=network.target. In [Service], set Type=notify, User=www-data, ExecStart=/usr/sbin/php-fpm8.3 -F, and Restart=on-failure. Add [Install] with WantedBy=multi-user.target. Always specify absolute paths for binaries since systemd does not inherit shell PATH variables from user profiles.

Type=simple assumes the process started by ExecStart is the main service. Type=forking expects the parent to exit after forking a child daemon. Type=notify waits for an sd_notify signal confirming readiness. For PHP-FPM or Laravel queue workers running in foreground mode, use simple or notify; never use forking unless wrapping legacy daemons that explicitly detach.

Set Restart=on-failure in the [Service] section to restart only on non-zero exit codes or signals. Pair this with RestartSec=5 to prevent rapid crash loops. For critical production services like payment webhook processors, consider Restart=always but always implement application-level health checks to avoid restarting fundamentally broken processes indefinitely without alerting.

Running as root violates least-privilege principles and expands blast radius during compromise. Always set User=www-data and Group=www-data for PHP applications. If the service needs privileged port binding below 1024, use AmbientCapabilities=CAP_NET_BIND_SERVICE instead of root. On legal-tech portals handling sensitive documents, this isolation is non-negotiable for security compliance.

Use EnvironmentFile=/etc/myapp/env.production pointing to a root-owned file with 600 permissions rather than inline Environment= directives. This keeps secrets out of systemctl status output and process listings. For Laravel applications, this file typically mirrors .env values. Never commit these files to Git; manage them via Deployer or Ansible during deployment.

Common causes include incorrect file ownership on executable scripts, missing read permissions on EnvironmentFile, or SELinux/AppArmor denials. Check journalctl -u myapp.service -f immediately after failure. On Ubuntu, verify www-data owns application files and that systemd can traverse all parent directories. I have debugged this repeatedly after deployments where rsync preserved wrong permissions.

Run sudo systemctl daemon-reload after any unit file modification, then restart the service. Skipping daemon-reload means systemd continues using the cached old configuration silently. This is the most frequent mistake I see developers make when troubleshooting why changes to ExecStart or EnvironmentFile appear ignored despite correct syntax and file permissions.

Yes. Create a dedicated laravel-queue.service with Type=simple, ExecStart=/usr/bin/php /var/www/app/artisan queue:work --tries=3 --timeout=90, and Restart=on-failure. Use StandardOutput=journal for logging. Avoid queue:listen in production due to higher overhead. On projects like Nepal Gift Card, this approach proved more reliable than Supervisor for long-running worker management.

Use After= to define ordering and Requires= or Wants= for hard or soft dependencies. For a Laravel app needing MySQL and Redis, set After=mysql.service redis.service and Wants=redis.service. Note that After alone does not guarantee the dependency starts; it only controls sequence if both are activated. Use Requires= for mandatory dependencies where failure should cascade.

Add MemoryMax=512M to prevent runaway processes from exhausting server RAM. Use LimitNOFILE=65535 for applications opening many file handles or database connections. CPUQuota=80% protects co-located services. These guards matter on shared EC2 instances hosting multiple sister sites. Without them, one misbehaving queue worker can take down unrelated production applications on the same host.

Use journalctl -u myapp.service -f for live tailing or journalctl -u myapp.service --since "1 hour ago" for historical entries. Systemd captures stdout and stderr automatically when StandardOutput=journal is set. Avoid redirecting output to custom log files inside the unit; centralize logging through journald for consistent rotation, search, and integration with monitoring tools.

For new Ubuntu 22.04+ deployments, yes. Systemd integrates with boot, cgroups, and journald natively without extra packages. Supervisor remains viable for legacy stacks or containerized environments lacking init systems. In my experience maintaining legal-tech portals and eCommerce platforms, migrating to systemd reduced operational complexity and eliminated a dependency that required separate monitoring and update cycles.

Basic service setup takes 2-4 hours at NPR 3,000-5,000 per hour (~USD 22-37). Complex configurations with resource limits, dependencies, and security hardening run 6-10 hours. Many Nepal-based developers bundle this with deployment automation. Budget separately for ongoing maintenance; systemd units need updates when PHP versions change or application architecture evolves during scaling.

Enable ProtectSystem=strict to make filesystem read-only except ExplicitPaths. Set PrivateTmp=true for isolated temp directories. Use NoNewPrivileges=true to block privilege escalation. Restrict network access with IPAddressDeny=any and IPAddressAllow=localhost for internal-only services. These sandboxing features are available on systemd 249+ shipped with Ubuntu 22.04 and significantly reduce exploitation surface for compromised applications.

Share this article

Quick Contact Options
Choose how you want to connect me: