systemd Restart=always for Python App Services
Ship a Python app as a systemd unit with Restart=always, or use a socket unit, so the process comes back after crashes without a babysitter.
Netbay Cloud Team
Netbay Engineering
On this page
A Python app that you start in tmux is not deployed. The next kernel update, OOM, or unhandled exception leaves the site down until a human notices. systemd is already pid 1 on Ubuntu. Give it a unit file, set Restart=always, and let it own the process. Socket activation is optional; Restart=always is not.
This post is the Python-shaped unit, not a systemd tour. You already have a venv at /srv/app/.venv and a WSGI or ASGI server that binds 127.0.0.1. nginx stays a separate unit. Do not fold the proxy into the app unit.
A Simple Service Unit That Survives Crashes
Put the file in /etc/systemd/system/app.service so it is clearly yours, not a package file.
[Unit]
Description=Python WSGI app
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=notify
User=app
Group=app
WorkingDirectory=/srv/app
Environment=PYTHONUNBUFFERED=1
EnvironmentFile=-/srv/app/.env
ExecStart=/srv/app/.venv/bin/gunicorn --bind 127.0.0.1:8000 --workers 3 --timeout 30 wsgi:app
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=3
TimeoutStopSec=20
KillMode=mixed
PrivateTmp=true
NoNewPrivileges=true
[Install]
WantedBy=multi-user.targetRestart=always means systemd brings the process back on any exit, including a clean 0. Restart=on-failure is tempting and wrong for a web app: a Gunicorn master that exits 0 after a bug in the boot path will stay dead. RestartSec=3 stops a crash loop from burning CPU. If the app cannot start because of a bad migration, you want a pause and a log line, not 400 restarts a second.
Type=notify needs the gunicorn systemd extra (pip package gunicorn[systemd] or sdnotify). If you do not want that dependency, Type=exec is the honest default on Ubuntu 24.04. Type=simple also works. Type=forking is for old pidfile daemons; Gunicorn and Uvicorn should not daemonize. Pass --pid or -D and you fight systemd instead of using it.
EnvironmentFile=-/srv/app/.env with the leading dash means a missing file is not a failure. PYTHONUNBUFFERED=1 so print() lands in the journal immediately. ExecStart uses the venv absolute path. There is no bash -lc, no source, no workon.
Socket Units: When They Help, When They Do Not
A .socket unit lets systemd listen on the port and hand the fd to the service on first connection. That is useful for rarely used tools and for reducing boot race conditions. For a public API that must be hot, a socket unit is extra moving parts.
# /etc/systemd/system/app.socket
[Unit]
Description=Python app socket
[Socket]
ListenStream=127.0.0.1:8000
NoDelay=true
[Install]
WantedBy=sockets.targetThe matching service then uses gunicorn --bind fd://0 or Uvicorn's fd mode. Many teams skip this and start the service at boot with After=network.target. That is the recommended default for FastAPI and Django on a single VPS. Use a socket when you have many idle internal tools and want them spawned on demand.
What you should not skip is After= and Requires= for Postgres when Postgres is on the same box. Without After=, the app can start before the database listens, crash, and Restart=always will retry — which works, but you spend the first 15 seconds of boot serving 500s. After=postgresql.service is cheaper.
journalctl Is the Log File
Do not configure a FileHandler to /var/log/app.log unless you also own rotation, permissions, and disk fill. systemd-journald already collects stdout and stderr.
- journalctl -u app -f follows live output.
- journalctl -u app --since "10 min ago" is the first response to a 5xx spike.
- journalctl -u app -p err -b limits to errors this boot.
StandardOutput=journal is the default. If the app logs JSON, keep it on stdout; do not wrap it in a second logger that writes the same line to a file. High-Speed SSD disk still fills if you log request bodies.
Protect the unit: PrivateTmp=true, NoNewPrivileges=true, and a dedicated User. ProtectSystem=strict plus ReadWritePaths=/srv/app/media is a later hardening pass. Get Restart=always and the user right first.
Enable, Reload, and Stop Fighting the Master
After every unit edit:
sudo systemctl daemon-reload
sudo systemctl enable --now app
sudo systemctl status app --no-pager
sudo systemctl restart appenable --now is enable plus start. After a code deploy, restart app, not reboot. nginx is independent; reloading nginx does not pick up a new Python worker, and restarting Python does not reload TLS.
KillMode=mixed sends SIGTERM to the main process, then SIGKILL to leftovers after TimeoutStopSec. That is the right setting for Gunicorn: the master should drain workers. KillMode=control-group SIGKILLs everyone at once, which drops in-flight requests. TimeoutStopSec=20 should exceed your graceful timeout.
On a Netbay Ubuntu VPS in Lucknow, this unit plus nginx is the whole supervisor story. You do not need supervisord beside systemd. You do not need a Docker restart policy if you are not using containers. One pid 1, one unit, Restart=always.
Takeaway
Write one unit, run it as the app user, Restart=always, logs to the journal, absolute venv path. Socket activation is optional. A tmux session is not a process manager. systemd already is.
You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and drop this unit on it — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance