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 (withWants=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 showfailed) rather than looping forever.
[Service]: running the app
Type=simple— the process inExecStartis 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-failurerestarts on non-zero exit, signals and timeouts;alwaysalso restarts after a clean exit.RestartSec=5waits 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
Secrets Management Beyond .env Files
.env files are fine on a laptop and fragile everywhere else. Where secrets should live in production and CI, secret managers vs platform env vars, OIDC to remove long-lived CI credentials, rotation, least privilege, keeping secrets out of logs and AI agent context, and a practical maturity path.
How Automatic SSL Actually Works (and Why It Sometimes Doesn't)
The padlock in the browser comes from a certificate that has to be issued, installed, and renewed on a schedule that keeps getting shorter. How Let's Encrypt and ACME prove you own a domain, how tools like Traefik and Caddy automate it, and the five reasons a certificate fails to issue or renew.