Run a Telegram bot 24/7 on a VPS

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/mybot under a dedicated user, build a virtualenv next to it, keep the token in an env file, write a systemd unit with Restart=always, then systemctl enable --now mybot. Logs go to journalctl -u mybot. Everything below is that, spelled out and explained.

Why a VPS, not your laptop or a free tier

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.

What you need

  • A VPS with a clean Ubuntu 22.04 or 24.04, and root access (or a sudo user).
  • A bot token from @BotFather in Telegram: a string like 123456:AA....
  • Your bot's code in a git repository, or ready to upload.

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".

Step 1. A user, the code, and a virtualenv

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).

Step 2. Keep the token out of the code

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.

Step 3. Run it once by hand

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.

Step 4. The systemd service

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 (keep AF_UNIX or 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 add ReadWritePaths=/opt/mybot/data - ProtectSystem=strict blocks writes everywhere else, and the classic symptom of forgetting it is unable to open database file. Check the result with systemd-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.

Step 5. Logs and service state

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.

Long polling or a webhook

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 getUpdates call

Low: Telegram makes the inbound request itself

Connection

the bot holds an outbound getUpdates

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.

Updating the bot

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.

Common problems

  • The bot is silent. Check 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.
  • Scheduled jobs fire at the wrong hour. The server runs on UTC. Set TZ= in the env file for your code, or set the timezone for the whole machine with timedatectl set-timezone.
  • Memory creeps up day after day. That is a leak, in the bot or in a library. 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.

How much server does a bot need

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.

FAQ

How do I keep a Telegram bot running 24/7?

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.

What server do I need for a Telegram bot?

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.

How do I run a Telegram bot as a service?

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.

Webhook or long polling?

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.

Can I run several bots on one VPS?

Yes. One user, one directory, and one systemd unit per bot. A 1 GB VPS carries a handful of small bots comfortably.

Takeaways

  • A VPS plus a systemd service with Restart=always is the whole "24/7" story.
  • The bot runs under a dedicated unprivileged user. Keep the token in a chmod 600 env file, never in code.
  • Long polling first; a webhook only when you outgrow it.
  • Logs are journalctl -u mybot: no log files to manage.
  • The cheapest VPS is enough. RAM is the only number worth watching.
PUBLISHED
AUTHOR
HIP-HOSTING
LANGUAGES
EN · RU