Blog
3 min read

Writing a systemd Service File for Your App (With Hardening)

A production-ready systemd unit for a Node.js or Python app, explained: Type, User, WorkingDirectory, EnvironmentFile, Restart and backoff, graceful stop, resource limits, logging, and the sandboxing options (ProtectSystem, NoNewPrivileges, PrivateTmp) that limit damage if the app is compromised.

systemd can run your app as a service: start it on boot, restart it on crashes, capture its logs, cap its resources and sandbox it. Here's a solid unit file, then each section explained. New to systemd? Start with What is systemd?

The unit file

/etc/systemd/system/myapp.service:

[Unit]
Description=My web app
After=network-online.target postgresql.service
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=10

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp
EnvironmentFile=/etc/myapp/env
Environment=NODE_ENV=production PORT=3000
ExecStart=/usr/bin/node dist/server.js

Restart=on-failure
RestartSec=5

TimeoutStopSec=30
KillSignal=SIGTERM

MemoryMax=1G
CPUQuota=150%
LimitNOFILE=65536

# Sandboxing
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/srv/myapp/uploads /var/log/myapp
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp
journalctl -u myapp -f

[Unit]: ordering

  • After=network-online.target — start once the network is actually up (with Wants= to pull it in).
  • After=postgresql.service — start after the local database, if it's on the same machine. This orders start-up; it doesn't wait for the database to accept connections, so the app should still retry.
  • StartLimitIntervalSec / StartLimitBurst — if it fails 10 times in 5 minutes, stop trying (and show failed) rather than looping forever.

[Service]: running the app

  • Type=simple — the process in ExecStart is the service. Right for Node, Python (Gunicorn/Uvicorn), Go. Don't let the app daemonise itself.
  • User / Group — a dedicated, unprivileged user. Never run web apps as root: sudo useradd --system --home /srv/myapp --shell /usr/sbin/nologin myapp. (Principle of least privilege)
  • WorkingDirectory — so relative paths work. (File paths explained)
  • EnvironmentFile — secrets in a file only root and the service can read (chmod 600), outside the repository. Environment= for non-secret values. (Secrets management)
  • ExecStart — absolute paths. For Python: /srv/myapp/.venv/bin/gunicorn app:app --bind 127.0.0.1:8000. (Deploy a Flask app)

Restarts

  • Restart=on-failure restarts on non-zero exit, signals and timeouts; always also restarts after a clean exit.
  • RestartSec=5 waits between attempts, so a crash loop doesn't spin the CPU.

Stopping gracefully

On systemctl stop or a deploy, systemd sends SIGTERM, waits TimeoutStopSec, then SIGKILL. Make your app finish in-flight requests on SIGTERM. (Graceful shutdown in Node.js)

Resource limits

systemd uses cgroups, so you can cap a service:

  • MemoryMax=1G — killed if it exceeds this (better than taking the whole server down). (Container CPU and memory limits)
  • CPUQuota=150% — at most 1.5 cores.
  • LimitNOFILE — more open files/sockets for busy servers.

Sandboxing: limit the blast radius

If your app is compromised, these options limit what the attacker can do — at the cost of a few minutes' testing:

Option Effect
NoNewPrivileges Can't gain privileges via setuid binaries
ProtectSystem=strict Whole filesystem read-only, except ReadWritePaths
ProtectHome /home, /root inaccessible
PrivateTmp Its own /tmp
PrivateDevices No access to physical devices
ProtectKernel*, ProtectControlGroups Can't modify kernel settings

Check how exposed a service is:

systemd-analyze security myapp

It scores each service and lists options you could add. If the app breaks after hardening, the journal usually shows EROFS or EACCES for a path you need to add to ReadWritePaths. (Container hardening)

Logging

Anything printed to stdout/stderr goes to the journal. Log one line per event, ideally JSON, and query with journalctl -u myapp --since "1 hour ago". Make sure the journal has a size cap (SystemMaxUse= in journald.conf) so logs can't fill the disk. (Structured logging, No space left on device)

Running several instances

A template unit myapp@.service with --port=%i lets you run myapp@3001 and myapp@3002 behind a load balancer for zero-downtime restarts. (Zero-downtime deploys)


EasySpawn runs your apps as managed services with restarts, resource limits and logs handled — inside their own VM — so you don't maintain unit files by hand. See how it works or join the waitlist.

Related: What Is systemd? · PM2 vs systemd · Graceful Shutdown in Node.js · How to Secure a New VPS

Keep reading