Supervisord on Ubuntu: keep any process alive
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
| Directive | What it does |
|---|---|
command | The exact command to run. Use absolute paths — Supervisor doesn’t load your shell’s $PATH. |
directory | Working directory before running the command. |
user | Run the process as this user. Critical for permission-sensitive apps. |
autostart | Start when Supervisor starts. |
autorestart | Restart on exit. Set to true, false, or unexpected (only restart on non-zero exits). |
startsecs | How long the process must stay up to be considered “successfully started”. |
startretries | How many times to retry before giving up and marking it FATAL. |
stopwaitsecs | How long to wait for graceful shutdown after SIGTERM before sending SIGKILL. |
stopasgroup / killasgroup | Send signals to the whole process group, not just the parent. Important if your command spawns children. |
numprocs | How many copies of the program to run. |
process_name | Naming pattern when running multiple. The %(process_num)02d gives _00, _01, etc. |
environment | Inline env vars. Comma-separated KEY="value" pairs. |
stdout_logfile / stderr_logfile | Where to write logs. |
stdout_logfile_maxbytes / _backups | Built-in log rotation. Set this or your disk will fill. |
Note: Always use absolute paths for
command.python3will work in your shell because of$PATH, but Supervisor runs commands directly. Usewhich python3to 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:
- Use the venv’s Python directly. Don’t
source activatethen run — Supervisor doesn’t run a shell. Pointingcommandat/opt/scraper/.venv/bin/pythonis the venv. PYTHONUNBUFFERED=1forces 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.
Groups: managing related processes together
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.envfile 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