Supervising Python App Workers on a Linux VPS with Supervisor

A web application started with nohup python app.py & is not a service. It does not restart after a crash, it does not survive a reboot, it writes logs to a file that grows without limit, and it keeps running after you deploy new code, which makes debugging a memory dump of a process that disagrees with the repository. On a small instance where every megabyte counts, that ten-line launch command is usually the weakest part of the stack.

systemd can supervise a process correctly, and it should be the default on any modern Linux host. Supervisor is the other common answer, and it remains the better fit when you need to run several instances of the same program with per-instance environment variables, or when the application runs as a non-root user inside an environment where writing system unit files is awkward. This walkthrough configures it properly, with pooling, restart backoff and log rotation.

Decide Between Supervisor and systemd First

Both tools do the same core job: start a program, watch it, and restart it when it exits. systemd is already installed, integrates with journalctl, and supports socket activation and cgroup resource limits without extra software.

Supervisor earns its place when you want one config file to define a pool of identical workers that share a single supervisorctl restart all command, or when the deployment is inside a container where PID 1 is already occupied. If you are running a containerised stack, the process supervision model is different again — how a VPS compares to shared hosting for running long-lived daemons is worth reading before you commit to a pattern you will not be able to change later.

Install and Lay Out the Config Directory

The packaged version is current enough on Debian 12 and Ubuntu 24.04. Install it from the distro repositories rather than pip: the pip-installed copy is tied to a Python environment and disappears when that environment is rebuilt.

sudo apt install -y supervisor
sudo systemctl enable --now supervisor
supervisord --version        # 4.x is fine

ls -l /etc/supervisor/conf.d/

Keep every program in its own file under conf.d. Editing the main supervisord.conf for each application turns it into an un-reviewable pile, and the include directive already points at conf.d/*.conf by default.

Two settings in the [unix_http_server] and [supervisorctl] sections are worth checking before you rely on remote tooling: the socket path and its permissions. On a multi-user host, a world-readable control socket lets any local user restart your application. Set chmod=0700 and keep the socket under /run so it is cleared on reboot rather than left stale on disk.

Write a Program Block That Restarts Correctly

The restart behaviour is controlled by autorestart and startretries. Setting autorestart=true without a delay means a program with a syntax error restarts hundreds of times per minute and fills the disk with tracebacks. startsecs is the settling window: the process must stay alive that long before it counts as started.

[program:api]
command=/opt/app/venv/bin/gunicorn -c /opt/app/gunicorn.conf.py app.wsgi:application
directory=/opt/app
user=app
environment=PATH="/opt/app/venv/bin",DJANGO_SETTINGS_MODULE="app.settings"
autostart=true
autorestart=true
startsecs=5
startretries=3
stopasgroup=true
killasgroup=true
stopsignal=TERM
stopwaitsecs=15
stdout_logfile=/var/log/supervisor/api.out.log
stderr_logfile=/var/log/supervisor/api.err.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=3

The two group settings matter more than they look. Without stopasgroup and killasgroup, a Gunicorn master that forks workers leaves orphaned children holding the listening socket after a stop, and the next start fails with “address already in use”. The environment line replaces rather than appends to the inherited environment.

Run a Pool of Instances Without Duplicating Files

Supervisor supports numeric ranges in program names, which creates independent processes from one block. Use this for CPU-bound workers, or for anything that must run one process per core. Stick to sockets or a queue instead if the processes need to share state.

[program:worker]
command=/opt/app/venv/bin/python -m app.queue_worker
process_name=%(program_name)s_%(process_num)02d
numprocs=2
numprocs_start=0
...

Two processes on a 2 vCPU instance is usually the ceiling; adding more increases context switching without increasing throughput. Verify with supervisorctl status that each instance shows RUNNING, not BACKOFF, and that PIDs differ.

Memory is the real constraint, not CPU count. Each Python worker with a loaded framework typically sits between 80 and 200 MB resident, so two workers can consume a third of a 1 GB instance before your web tier starts. Check with ps_mem -p after a few hours of traffic, when lazily imported modules are resident, rather than ten minutes after start.

Handle Log Rotation Without Losing Lines

Supervisor’s own stdout_logfile_maxbytes rotation truncates and reopens the file by moving it, which some applications handle badly if they keep their own file descriptor. If the application writes its own logs, use logrotate with copytruncate instead, and set supervisor’s own logging to only capture stderr.

/var/log/supervisor/api.err.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    copytruncate
}

Watch for the classic double-rotation problem: if both supervisor and logrotate act on the same file, you get an empty .1 and a missing day of history. Pick one owner per file and document it in a comment at the top of the logrotate stanza.

The alternative is to send application output to the journal with redirect_stderr=true, letting journald handle retention. That is simpler, but it mixes application output into system logs and makes per-program inspection slower under load. For anything beyond a trickle of output, file-per-program plus logrotate remains easier to reason about.

Reload Changes Without Dropping Requests

supervisorctl reread loads changed config files without acting on them; supervisorctl update applies the changes to only the affected programs. That two-step is safer than restart all, which bounces every unrelated service on the host.

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status

# targeted reload after a code deploy
sudo supervisorctl restart api

If the program uses a graceful signal, prefer sending it directly rather than restarting, so in-flight requests complete. Gunicorn’s master handles SIGHUP by re-execing workers one at a time — that is the zero-downtime deploy path on a single instance.

Diagnose BACKOFF, FATAL and Silent Exits

When a program will not stay up, supervisorctl status reports BACKOFF and stops retrying after startretries. The reason is in the program’s own stderr log, not in supervisor’s main log, which is the first place people look and the last place that has useful information.

For a process that exits cleanly and is still restarted, check exitcodes: by default only 0 is considered successful, so a worker that exits 3 on “queue empty” will be restarted forever. Set exitcodes=0,3 and let the worker finish. Once the service stays up under load, the remaining latency work is application-side; understanding what a managed plan does and does not cover helps decide whether tuning this layer is your job or the provider’s.

Leave a Reply