Laptops sleep; free tiers idle your process. Here is how to put a bot on a VPS so it restarts itself after a crash or reboot, with every command explained.
What you need: a VPS with Ubuntu 22.04 or 24.04 (1 vCPU and 512 MB to 1 GB of RAM is plenty), a bot token from @BotFather, and about 15 minutes. The result: the bot stays up across reboots, crashes, and disconnects, with logs you can actually read.
The whole path, short: put the code in
/opt/mybotunder a dedicated user, build a virtualenv next to it, keep the token in an env file, write asystemdunit withRestart=always, thensystemctl enable --now mybot. Logs go tojournalctl -u mybot. Everything below is that, spelled out and explained.
A long-polling bot, meaning one that asks Telegram for new messages in a loop, has to be reachable every second: no running process means no replies. A laptop sleeps, drops Wi-Fi, and reboots for updates. Free always-on web-service tiers idle your process after typically 15 to 30 minutes with no inbound HTTP requests, and a long-polling bot receives none, so it gets suspended and wakes late. A $2 to $3 VPS simply runs, and it gives you a real service manager (covered in step 4), so "keep it alive" becomes one config file instead of a pile of hacks. The bot here is one case of a general pattern - running any process as a systemd service; if you later need the same for something that is not a bot, there is a separate walkthrough on deploying a service on a server and keeping it 24/7.
@BotFather in Telegram: a string like 123456:AA....The commands below run as root. If you logged in as a regular user, prepend sudo.
Lock the box down first. On a real server, do the minimum before anything else: a non-root sudo user, SSH-key login with password auth off, and a firewall that allows only SSH (plus port 443 if you later go the webhook route). See "Secure a fresh VPS".
Install the system packages first: Python with virtualenv support, and git to fetch the code.
apt update && apt install -y python3-venv python3-pip git
Now put the code in /opt/mybot. Clone the repository before you create the user: if the user exists first, its home directory is already populated, and git clone into a non-empty directory fails.
mkdir -p /opt/mybot
git clone https://your.repo/mybot.git /opt/mybot
Next, create a dedicated unprivileged user for the bot to run as. The reason is containment: if the bot has a hole, it is limited to this user's permissions and cannot reach the rest of the server. --system makes a service account with no password. You cannot log in as it with su - mybot (its shell is /usr/sbin/nologin), but you can run commands as it with sudo -u mybot, which is how you debug.
adduser --system --group --home /opt/mybot mybot
chown -R mybot:mybot /opt/mybot
chown -R hands the directory to the new user, otherwise it cannot write to its own folder. Now build the virtualenv: a folder with a private copy of Python and its libraries, so the bot's dependencies never mix with the system ones or break on an Ubuntu upgrade. Run these as mybot with -H, or pip floods the log with a warning about a HOME it does not own.
sudo -H -u mybot python3 -m venv /opt/mybot/.venv
sudo -H -u mybot /opt/mybot/.venv/bin/pip install --upgrade pip
sudo -H -u mybot /opt/mybot/.venv/bin/pip install -r /opt/mybot/requirements.txt
Upgrading pip first removes half of the strange wheel-build errors. If pip install finishes with no red lines, the dependencies are in place.
Your requirements.txt pins the bot library, e.g. python-telegram-bot==22.8 or aiogram==3.31.1. Current python-telegram-bot v22 and aiogram 3.31 need Python 3.10 or newer: fine on Ubuntu 24.04 (Python 3.12) and Debian 12 (3.11), too old on Ubuntu 20.04 and Debian 11, where you install a newer Python first (deadsnakes or pyenv).
The token is the bot's password: whoever holds it controls the bot, so it does not belong in source, least of all in a public repository. Keep it in an env file: a plain text file of NAME=value lines, read by the shell or by the code at startup. Create /opt/mybot/.env:
BOT_TOKEN=123456:AA...your-token
TZ=Europe/Amsterdam
chown mybot:mybot /opt/mybot/.env
chmod 600 /opt/mybot/.env
chmod 600 closes the file to everyone but its owner. The code reads the token from the environment (os.environ["BOT_TOKEN"]), never from a hardcoded string. For a public repo, that is the difference between a working bot and a hijacked one.
The .env file sits inside the git working tree, so a careless git add -A can commit the token into history. Guard against it once:
echo '.env' >> /opt/mybot/.gitignore
About TZ in that same file: it only affects naive datetime objects in your Python code. Timestamps in journalctl stay in the server's system timezone. If you want those to match too, set the timezone for the whole machine: timedatectl set-timezone Europe/Amsterdam.
Before wrapping the bot in a service, run it by hand once to confirm it starts and answers. The naive command sudo -u mybot --preserve-env .venv/bin/python bot.py will not work: --preserve-env keeps root's environment, not mybot's, and nothing reads the .env file. An env file is not magic: something has to load it before its variables exist. So os.environ["BOT_TOKEN"] raises KeyError and the bot crashes on every run. This command loads .env itself:
sudo -u mybot bash -c 'set -a; cd /opt/mybot; . ./.env; exec .venv/bin/python bot.py'
set -a: everything assigned after it automatically becomes an environment variable, so the child Python process can see the lines from .env.. ./.env: the leading dot (the source command) runs the file in the current shell and sets the variables. Without it, .env is just text.exec: replaces the shell with the Python process, so no extra process lingers.Message the bot in Telegram. If it answers and the terminal shows no traceback, stop it with Ctrl+C and move on to the service. If it is silent or throwing errors, fix that now: the service wrapper will not fix a broken bot. It only restarts it in a loop.
systemd is the standard service manager on Linux: it starts a process on boot, watches it, and restarts it if it dies. This is the part that makes it "24/7". A service is described by a text file called a unit. Create /etc/systemd/system/mybot.service:
[Unit]
Description=My Telegram bot
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
User=mybot
WorkingDirectory=/opt/mybot
EnvironmentFile=/opt/mybot/.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/mybot/.venv/bin/python /opt/mybot/bot.py
Restart=always
RestartSec=5
TimeoutStopSec=25
[Install]
WantedBy=multi-user.target
Type=exec: modern systemd recommends this over Type=simple for long-running services. The service only counts as started after a successful exec(), so a typo in the path to Python or the script shows up as a start failure rather than "started, then died instantly". Confirmed on a live server.Restart=always with RestartSec=5: if the process exits on its own (crash or clean exit), systemd brings it back in five seconds. The exception is an explicit systemctl stop: the bot does not restart after that, it is a deliberate stop.TimeoutStopSec=25: after systemctl stop / restart, systemd waits this long for a clean exit before it sends SIGKILL. The 90-second default stalls deploys; a bot needs a few seconds. python-telegram-bot and aiogram already catch SIGTERM and shut down cleanly on Linux, so you do not need any signal handling in your own code.StartLimitIntervalSec=60 and StartLimitBurst=5: allow at most five start attempts per minute; after that the unit goes failed, so a broken bot (bad token, missing dependency) is visible immediately instead of looping silently. systemd also has a manager-wide default (DefaultStartLimitIntervalUSec=10s, DefaultStartLimitBurst=5); the explicit values just make the unit's behavior predictable. If the bot must come back at any cost and you never want a failed state, set StartLimitIntervalSec=0, but then a crash loop quietly floods the journal.After=network-online.target and Wants=network-online.target: do not start before the network is up. "Network up" does not guarantee DNS works or that Telegram answers, so the code still has to survive a network error and retry.EnvironmentFile=/opt/mybot/.env: systemd reads the env file itself and passes the variables to the bot. The path has no leading dash on purpose: the bot is useless without its token, so if .env is gone or unreadable the service should refuse to start rather than launch tokenless. Confirmed on a live server: with the file missing, the journal shows Failed to load environment files: No such file or directory. A leading dash (EnvironmentFile=-path) belongs only on a genuinely optional file.Environment=PYTHONUNBUFFERED=1: without it, Python buffers print() and journalctl -f shows output much later, which defeats the point of a live log.ExecStart: absolute paths only, to the virtualenv's Python and to the script.Reload systemd's configuration and enable the service, for now and for every boot:
systemctl daemon-reload
systemctl enable --now mybot
Check: systemctl status mybot should show active (running). If it shows failed, read the logs (step 5): the reason is always there.
Once it works, harden it. Add to
[Service]:NoNewPrivileges=yes,ProtectSystem=strict(whole filesystem read-only; the venv stays readable),ProtectHome=yes,PrivateTmp=yes,RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX(keepAF_UNIXor you break DNS resolution and journald logging),SystemCallFilter=@system-service,CapabilityBoundingSet=(empty),MemoryMax=256M. If the bot writes a SQLite file or local logs, also addReadWritePaths=/opt/mybot/data-ProtectSystem=strictblocks writes everywhere else, and the classic symptom of forgetting it isunable to open database file. Check the result withsystemd-analyze security mybot: this set took the demo bot from the un-hardened default to an exposure level of 4.0 OK on a live server.
One last check, the one people skip: reboot the whole server, reconnect, and confirm the service is back on its own.
systemctl status mybot
journalctl -u mybot -f
journalctl replaces the log file. -f follows new lines live, -u mybot limits output to this service. journalctl -u mybot -b shows logs from the current boot (not the "last" one, the current one); the previous boot is -b -1. journald handles rotation. On the bot's side, write everything to stdout (or point Python's logging at stdout) and it lands here. Thanks to PYTHONUNBUFFERED=1 from step 4, lines appear immediately rather than in batches.
One caveat if your bot uses python-telegram-bot or aiogram: their HTTP client (httpx or aiohttp) logs every request URL at INFO, and that URL carries your bot token in clear text - so the token ends up in journalctl. Anyone you then hand logs to for help gets your token with them. Silence it in your code before anything else: logging.getLogger("httpx").setLevel(logging.WARNING) (or "aiohttp"). Verified on a live server - without this line every getUpdates call prints the full api.telegram.org/bot<token>/... URL.
Two ways to receive messages. With long polling, the bot asks Telegram for new updates in a loop, and nothing is exposed. With a webhook, Telegram posts updates to your HTTPS address: lower latency, but you need a domain, a certificate, and an open port.
Long polling | Webhook | |
|---|---|---|
Setup | None: the bot calls Telegram | Usually an HTTPS domain and a reverse proxy (a bare IP with a self-signed cert also works, rarely) |
Inbound ports | None | 443 (or 80, 88, 8443), always over TLS |
Latency | Low: the update arrives on the open | Low: Telegram makes the inbound request itself |
Connection | the bot holds an outbound | Telegram makes inbound HTTPS requests to you |
Good for | a simple bot, one instance, no domain | multiple instances or services, a web-style architecture that scales |
Start with long polling. It needs no domain and no open ports, and its latency is not necessarily higher than a webhook's: Telegram returns an update on the already-open getUpdates call. Teams switch to a webhook for architectural reasons (several bot instances behind a load balancer, a shared domain with other services), not because polling is "slow". When you do, put a reverse proxy in front - Caddy or Nginx - and register the address with setWebhook. The webhook port is usually 443, with 80, 88 and 8443 also allowed, but even on port 80 the connection must be TLS, not plain HTTP.
Pull the new code, install any new dependencies, and restart the service, all as mybot:
sudo -H -u mybot git -C /opt/mybot pull
sudo -H -u mybot /opt/mybot/.venv/bin/pip install -r /opt/mybot/requirements.txt
systemctl restart mybot
After systemctl restart, check journalctl -u mybot -f and confirm the bot came back up and answers.
journalctl -u mybot -b (logs from the current boot; the previous one is -b -1). It is usually a bad token, a missing dependency, or the network not being up at start. Run systemctl restart mybot after a fix.409 Conflict: terminated by other getUpdates request; make sure that only one bot instance is running. Two instances are polling the same token: for example, the hand-run process from step 3, or a second server. Only one can poll - there is no take-over mechanism, "exactly one instance" is the only fix. Stop the extra one. One or two 409s right after systemctl restart are normal and clear on their own: the old instance's long-poll lingers on Telegram's side for a few seconds, then the new one wins. Keep pending-update dropping off in production (the default) so Telegram redelivers the roughly 24-hour backlog after a restart. Reproduced on a live server: a second poller against the running service's token immediately gets HTTP 409 and telegram.error.Conflict, and both instances then thrash until one exits.TZ= in the env file for your code, or set the timezone for the whole machine with timedatectl set-timezone.Restart=always masks it. While you track down the cause, add RuntimeMaxSec=86400 for a once-a-day clean restart. The interval counts from the last start, not a wall-clock hour, so the restart moment drifts; for a precise hour, use a systemd.timer. For reading memory and load on the box itself, see the slow Linux server diagnostics guide.Bot type | vCPU / RAM |
|---|---|
Text commands, small groups | 1 / 512 MB |
Inline queries, a database, a few thousand users | 1 / 1 GB |
Media processing, image or audio conversion | 2 / 2 GB, and disk headroom |
For most bots the CPU sits idle, and RAM is the only thing to watch. For the full picture, see how much CPU and RAM you actually need.
Put it on a VPS and run it as a systemd service with Restart=always. systemd starts it on boot and restarts it within seconds if it crashes. The token lives in an env file, and logs go to journalctl.
The smallest VPS. 1 vCPU and 512 MB to 1 GB of RAM handles most bots (the CPU is nearly idle). Only media-heavy bots need 2 vCPU and more memory.
Create a systemd unit whose ExecStart points, with absolute paths, at your virtualenv's Python and your script, sets User= to a dedicated account, loads secrets via EnvironmentFile, and sets Restart=always. Then systemctl enable --now mybot.
Long polling to start: no domain, no open ports, fine for thousands of users. Move to a webhook only when latency or update volume becomes a real constraint.
Yes. One user, one directory, and one systemd unit per bot. A 1 GB VPS carries a handful of small bots comfortably.
systemd service with Restart=always is the whole "24/7" story.chmod 600 env file, never in code.journalctl -u mybot: no log files to manage.