Running python bot.py in an SSH session works until you log out. To keep a Python program running 24/7 — a Telegram or Discord bot, a scraper, a worker, a Flask/FastAPI app — create a systemd service. It starts at boot, restarts on crashes and logs everything to the journal.
1. User and virtual environment
sudo useradd --system --create-home --home-dir /srv/bot --shell /usr/sbin/nologin bot
sudo -u bot git clone https://github.com/you/bot.git /srv/bot/app
sudo -u bot python3 -m venv /srv/bot/venv
sudo -u bot /srv/bot/venv/bin/pip install -r /srv/bot/app/requirements.txt
See Python virtual environments.
2. Secrets in an environment file
/etc/bot/env (mode 640, group bot):
BOT_TOKEN=123456:ABC…
DATABASE_URL=sqlite:////srv/bot/data/bot.db
sudo mkdir -p /etc/bot && sudo chown root:bot /etc/bot/env && sudo chmod 640 /etc/bot/env
Read them in Python with os.environ["BOT_TOKEN"].
3. The unit file
/etc/systemd/system/bot.service:
[Unit]
Description=Telegram bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=bot
Group=bot
WorkingDirectory=/srv/bot/app
EnvironmentFile=/etc/bot/env
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/bot/venv/bin/python -m bot
Restart=always
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/bot/data
[Install]
WantedBy=multi-user.target
The two details that trip people up:
- Use the venv’s Python by absolute path (
/srv/bot/venv/bin/python). Activating a venv in a unit file isn’t how it works; the interpreter path is enough. PYTHONUNBUFFERED=1— otherwiseprint()output is buffered and appears in the logs late or not at all.
For a web app, ExecStart points to Gunicorn or Uvicorn in the venv instead — see Flask with Gunicorn and FastAPI with Uvicorn.
4. Start and check
sudo mkdir -p /srv/bot/data && sudo chown bot: /srv/bot/data
sudo systemctl daemon-reload
sudo systemctl enable --now bot
systemctl status bot --no-pager
journalctl -u bot -f
5. Restart behaviour
Restart=always— restart whenever the process exits (crash or not). For a bot that should never stop, that’s right.Restart=on-failure— only on non-zero exit codes or signals.- If a program crashes in a tight loop, systemd stops trying after 5 starts in 10 seconds (
StartLimitBurst/StartLimitIntervalSec). Raise them in[Unit]if needed, and fix the crash.sudo systemctl reset-failed botclears the state.
6. Graceful shutdown
systemd sends SIGTERM, waits (TimeoutStopSec, default 90 s), then sends SIGKILL. Handle SIGTERM to close connections and save state:
import signal, sys
def shutdown(signum, frame):
# close DB connections, finish current task…
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown)
Async frameworks (aiogram, python-telegram-bot, discord.py) usually handle this for you.
7. Scheduled scripts: use a timer
For a script that runs periodically (every 15 minutes, nightly) rather than continuously, pair a Type=oneshot service with a timer instead of cron:
# /etc/systemd/system/report.service
[Service]
Type=oneshot
User=bot
EnvironmentFile=/etc/bot/env
ExecStart=/srv/bot/venv/bin/python /srv/bot/app/report.py
# /etc/systemd/system/report.timer
[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl enable --now report.timer. Persistent=true runs a missed job after downtime. See systemd timers.
8. Updating
cd /srv/bot/app && sudo -u bot git pull --ff-only
sudo -u bot /srv/bot/venv/bin/pip install -r requirements.txt
sudo systemctl restart bot
Troubleshooting
status=203/EXEC—ExecStartpath wrong or not executable.ModuleNotFoundError— not using the venv’s Python, or wrongWorkingDirectory.PermissionError— the service user can’t write somewhere; check ownership andReadWritePaths.- No log output — missing
PYTHONUNBUFFERED=1.
Related: Telegram bot on a VPS, systemd unit files and Supervisor. Bots and scripts run happily on the smallest VPS plans.