← Back to blog

Supervisord on Ubuntu: keep any process alive

linuxdevopsself-hostedhow to

If you’ve ever started a long-running process with nohup, &, screen, or tmux and called it production — Supervisor is the upgrade. It manages any process you point it at, restarts it on crash, captures its logs, and gives you a clean control interface. It’s old, boring, and rock-solid.

This post is the general-purpose tour: install, config anatomy, a few real example programs, supervisorctl in depth, logging, env vars, and groups.


What Supervisor actually is

Supervisor is a process control system written in Python. You hand it a config file describing a program, and it runs that program as a child process — keeping it alive, restarting it on failure, and streaming its stdout/stderr to log files. It’s not as fancy as systemd, and that’s the point: configs are short, fast to write, and live in one directory you control.

When to reach for it:

  • You need to run multiple instances of the same script (queue workers, scrapers, bots).
  • You want logs and restart policy in one config file you can drop into a deploy.
  • You’re not the box’s root admin and can’t easily write systemd units.
  • You want a single tool managing a stack of mixed-language processes (Node + Python + Bash all in one place).

Installing Supervisor

sudo apt update && sudo apt install supervisor -y

It registers itself as a system service. Confirm it’s running:

sudo systemctl status supervisor

If it’s not active:

sudo systemctl enable --now supervisor

That’s the whole install.


How configs are organised

Supervisor reads a main config at /etc/supervisor/supervisord.conf, which by default includes everything in /etc/supervisor/conf.d/*.conf. You almost never edit the main file. Just drop one .conf file per program (or per group of related programs) into conf.d/.

/etc/supervisor/
├── supervisord.conf       # main config — leave it alone
└── conf.d/
    ├── api-server.conf
    ├── telegram-bot.conf
    └── scrapers.conf

Anatomy of a program block

Every config file contains one or more [program:name] blocks. Here’s the full shape with the directives you’ll actually use:

[program:my-worker]
command=/usr/bin/python3 /opt/myapp/worker.py
directory=/opt/myapp
user=ubuntu
autostart=true
autorestart=true
startsecs=5
startretries=3
stopwaitsecs=30
stopasgroup=true
killasgroup=true
numprocs=1
process_name=%(program_name)s_%(process_num)02d
environment=PYTHONUNBUFFERED="1",APP_ENV="production"
stdout_logfile=/var/log/myapp/worker.log
stderr_logfile=/var/log/myapp/worker.err.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=5
DirectiveWhat it does
commandThe exact command to run. Use absolute paths — Supervisor doesn’t load your shell’s $PATH.
directoryWorking directory before running the command.
userRun the process as this user. Critical for permission-sensitive apps.
autostartStart when Supervisor starts.
autorestartRestart on exit. Set to true, false, or unexpected (only restart on non-zero exits).
startsecsHow long the process must stay up to be considered “successfully started”.
startretriesHow many times to retry before giving up and marking it FATAL.
stopwaitsecsHow long to wait for graceful shutdown after SIGTERM before sending SIGKILL.
stopasgroup / killasgroupSend signals to the whole process group, not just the parent. Important if your command spawns children.
numprocsHow many copies of the program to run.
process_nameNaming pattern when running multiple. The %(process_num)02d gives _00, _01, etc.
environmentInline env vars. Comma-separated KEY="value" pairs.
stdout_logfile / stderr_logfileWhere to write logs.
stdout_logfile_maxbytes / _backupsBuilt-in log rotation. Set this or your disk will fill.

Note: Always use absolute paths for command. python3 will work in your shell because of $PATH, but Supervisor runs commands directly. Use which python3 to find the real path.


Example 1: a Node.js app

[program:api-server]
command=/usr/bin/node /var/www/api/server.js
directory=/var/www/api
user=www-data
autostart=true
autorestart=true
environment=NODE_ENV="production",PORT="3000"
stdout_logfile=/var/log/api/server.log
stderr_logfile=/var/log/api/server.err.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=5

This is essentially “PM2 without PM2”. For a single Node process, Supervisor is lighter and integrates with the rest of your stack.


Example 2: a Python script with multiple workers

[program:scraper]
command=/opt/scraper/.venv/bin/python /opt/scraper/run.py
directory=/opt/scraper
user=ubuntu
autostart=true
autorestart=true
numprocs=4
process_name=%(program_name)s_%(process_num)02d
environment=PYTHONUNBUFFERED="1"
stdout_logfile=/var/log/scraper/%(program_name)s_%(process_num)02d.log
stderr_logfile=/var/log/scraper/%(program_name)s_%(process_num)02d.err.log
stopwaitsecs=60

Two things worth noting:

  1. Use the venv’s Python directly. Don’t source activate then run — Supervisor doesn’t run a shell. Pointing command at /opt/scraper/.venv/bin/python is the venv.
  2. PYTHONUNBUFFERED=1 forces Python to flush stdout immediately. Without it, your logs lag or appear empty.

Example 3: a generic binary (Go, Rust, anything)

[program:notifier]
command=/usr/local/bin/notifier --config /etc/notifier/config.toml
user=notifier
autostart=true
autorestart=true
stdout_logfile=/var/log/notifier/notifier.log
stderr_logfile=/var/log/notifier/notifier.err.log

Compiled binaries are the easiest case — no interpreters, no virtual envs, just a path and arguments.


Example 4: a Laravel worker/agent

[program:laravel-queue]
command=/usr/bin/php /var/www/myapp/artisan queue:work --sleep=1 --tries=3 --timeout=120
directory=/var/www/myapp
user=www-data
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stdout_logfile=/var/log/myapp/queue.log
stderr_logfile=/var/log/myapp/queue.err.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=5

If you’re running Nightwatch, the same pattern applies:

[program:nightwatch-agent]
command=/usr/bin/php /var/www/myapp/artisan nightwatch:agent
directory=/var/www/myapp
user=www-data
autostart=true
autorestart=true
stdout_logfile=/var/log/myapp/nightwatch-agent.log
stderr_logfile=/var/log/myapp/nightwatch-agent.err.log

Use absolute paths for both php and artisan, and keep these commands in the foreground (which they already do by default).


Loading a new config

After dropping a file into /etc/supervisor/conf.d/, you need to tell Supervisor about it:

sudo supervisorctl reread   # detects new and changed configs
sudo supervisorctl update   # applies them — starts new programs, restarts changed ones

reread alone doesn’t start anything. You need both. A common mistake is editing a config, running reread, and then wondering why nothing changed.


supervisorctl: the daily driver

supervisorctl is the CLI you’ll actually use day to day. Run it without arguments to drop into an interactive shell, or pass commands directly:

sudo supervisorctl status                  # show all programs
sudo supervisorctl status api-server       # show one
sudo supervisorctl start api-server
sudo supervisorctl stop api-server
sudo supervisorctl restart api-server
sudo supervisorctl tail -f api-server      # follow stdout
sudo supervisorctl tail -f api-server stderr   # follow stderr
sudo supervisorctl reload                  # restart the supervisor daemon itself

For programs running multiple instances (numprocs > 1), use the wildcard:

sudo supervisorctl restart scraper:*       # restart all 4 workers
sudo supervisorctl restart scraper:scraper_02   # just one

The status output looks like this:

api-server                       RUNNING   pid 12453, uptime 2 days, 04:11:09
scraper:scraper_00               RUNNING   pid 12455, uptime 2 days, 04:11:09
scraper:scraper_01               RUNNING   pid 12456, uptime 2 days, 04:11:09
scraper:scraper_02               FATAL     Exited too quickly (process log may have details)
scraper:scraper_03               RUNNING   pid 12458, uptime 2 days, 04:11:09

FATAL means the process failed to start startretries times in a row. Check the log file — it’s almost always a config typo, missing dependency, or permission error.


If you have several related programs you want to start, stop, and restart as a unit, use a [group:...] block:

[program:scraper-feed]
command=/opt/scraper/.venv/bin/python /opt/scraper/feed.py
user=ubuntu
autostart=true
autorestart=true

[program:scraper-detail]
command=/opt/scraper/.venv/bin/python /opt/scraper/detail.py
user=ubuntu
autostart=true
autorestart=true

[group:scrapers]
programs=scraper-feed,scraper-detail

Now you can manage them as one:

sudo supervisorctl restart scrapers:*
sudo supervisorctl status scrapers:*

This is cleaner than restarting each individually, especially in a deploy script.


Logs and rotation

By default Supervisor writes logs to /var/log/supervisor/ — both its own daemon log (supervisord.log) and per-program logs if you don’t override stdout_logfile.

Always set stdout_logfile_maxbytes and stdout_logfile_backups. A noisy worker can fill a small VPS disk in days otherwise:

stdout_logfile_maxbytes=20MB
stdout_logfile_backups=5

That keeps a maximum of ~120MB of rotated logs per process. If you’d rather use system logrotate, set stdout_logfile_maxbytes=0 to disable Supervisor’s rotation entirely and write a logrotate config — but for most cases, the built-in rotation is plenty.


Environment variables

Three ways to pass env vars, in order of preference:

1. Inline in the config (good for non-secrets):

environment=NODE_ENV="production",PORT="3000",LOG_LEVEL="info"

2. From a .env file your app reads itself. Supervisor doesn’t natively load .env files, but most app frameworks do (Laravel, dotenv-flow, python-dotenv). Just make sure the working directory is right.

3. From the system environment. Set directory= to a path containing your env file, or export vars in /etc/supervisor/supervisord.conf under its [supervisord] block — but this leaks them to every program, which is usually not what you want.

Note: Don’t put secrets directly in /etc/supervisor/conf.d/ files if those configs end up in version control. Use a .env file with restricted permissions and read it from the application instead.


Common pitfalls

Process exits immediately, marked FATAL. Almost always one of: wrong path in command, wrong user, missing dependency, or the program is daemonising itself. Supervisor expects programs to stay in the foreground — if your binary forks to the background, Supervisor will think it crashed. Look for a --foreground or --no-daemon flag.

Logs are empty. Output buffering. For Python, set PYTHONUNBUFFERED=1. For Node, output is unbuffered to TTYs but buffered to pipes — usually not an issue with console.log, but if you see lag, flush manually. For shell scripts, use stdbuf -oL -eL.

Config changes don’t take effect. You ran reread but not update, or you restarted the wrong program. After any config change: sudo supervisorctl reread && sudo supervisorctl update.

supervisorctl: command not found for non-root users. Either run with sudo, or add your user to the appropriate group and configure the unix socket permissions in /etc/supervisor/supervisord.conf.


Quick reference

# Install
sudo apt install supervisor -y

# Add a program
sudo nano /etc/supervisor/conf.d/myapp.conf
sudo supervisorctl reread
sudo supervisorctl update

# Daily commands
sudo supervisorctl status
sudo supervisorctl restart myapp
sudo supervisorctl tail -f myapp
sudo supervisorctl stop myapp

# Multi-process programs
sudo supervisorctl restart myapp:*

# Reload Supervisor itself (rare)
sudo supervisorctl reload

Once you internalise the reread → update → status → tail loop, Supervisor mostly disappears into the background — which is exactly what you want from a process supervisor.


More posts at noukeosombath.com