Hosting a Telegram bot on Replit or a free PaaS works fine until your bot suddenly stops responding at 3 AM because the platform paused it, throttled it, or quietly migrated your container to a region your users can't reach. A VPS is the honest answer: dedicated resources, predictable uptime, and full control of the rate-limit math that determines whether your bot scales to ten users or ten thousand.
This guide walks through the full path: setting up a Linux VPS, writing a starter bot with python-telegram-bot, deploying it under systemd so it restarts on failure, configuring a webhook for production (instead of polling), and hardening the server against the brute-force attempts that hit every public IP within hours of deployment. By the end you'll have a bot that survives reboots, kernel updates, and the occasional misbehaving library.
- A Linux VPS (Ubuntu 22.04 or Debian 12 — even the smallest plan works)
- A domain name pointed at your VPS (an A record, for webhook HTTPS)
- A Telegram account and 5 minutes with @BotFather
- Roughly 35 minutes
Looking for a turnkey option? Our Telegram Bot VPS plans ship with Python, PM2, and systemd templates pre-installed — skip steps 1–3 of this guide entirely.
1. Create the bot with @BotFather
Open Telegram, search for @BotFather, and start a chat. Send /newbot. BotFather will ask for a display name (free text — what users see) and a username (must end in bot, e.g. olivevps_test_bot). When you're done, BotFather hands you a token that looks like this:
7651234567:AAHfHsAbCdEfGhIjKlMnOpQrStUvWxYzAB
Treat this token like a password. Anyone with it can impersonate your bot, read all its messages, and ban your users. We'll put it in an environment variable, never in source code, never committed to git.
While you're chatting with BotFather, send /setdescription, /setcommands (defines the slash-menu users see), and /setprivacy (turn this OFF if your bot needs to read all group messages, ON if it only responds to direct mentions).
2. Prepare the VPS
SSH in as root, update, install the basics, set up a firewall, and create a non-root user. The bot will run under that user — never as root.
apt update && apt upgrade -y
apt install -y python3 python3-pip python3-venv ufw git curl ca-certificates
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
adduser telegram
usermod -aG sudo telegram
rsync --archive --chown=telegram:telegram ~/.ssh /home/telegram
Set the timezone — useful for log timestamps and scheduled tasks:
timedatectl set-timezone UTC
timedatectl status
3. Install Python and create a virtual environment
Switch to the telegram user and set up an isolated Python environment. Always use a virtualenv for bots — keeps dependencies pinned and avoids "works on my machine" disasters.
su - telegram
mkdir -p ~/mybot
cd ~/mybot
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install python-telegram-bot[webhooks]==21.10 python-dotenv
The [webhooks] extra pulls in Tornado, which we'll need in step 6 for the webhook server. The version pin (21.10) is the latest stable as of writing — check the official docs for the current version.
Save your bot token to a .env file (and add it to .gitignore immediately):
cat > ~/mybot/.env <<'EOF'
BOT_TOKEN=PASTE_YOUR_BOTFATHER_TOKEN_HERE
WEBHOOK_DOMAIN=bot.example.com
WEBHOOK_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 ~/mybot/.env
4. Write the starter bot (polling mode)
Create ~/mybot/bot.py. We'll start with polling mode — easier to debug, no webhook setup, perfect for local testing. We'll switch to webhooks for production in step 6.
import logging
import os
from dotenv import load_dotenv
from telegram import Update
from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, ContextTypes, filters
load_dotenv()
BOT_TOKEN = os.environ["BOT_TOKEN"]
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
)
log = logging.getLogger(__name__)
async def start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
await update.message.reply_text(
f"🐾 Hello {user.first_name}! I'm running on OliveVPS. Try /help."
)
async def help_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(
"Commands:\n"
"/start - greet me\n"
"/help - this message\n"
"/ping - latency check"
)
async def ping(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
import time
sent_at = update.message.date.timestamp()
now = time.time()
latency_ms = int((now - sent_at) * 1000)
await update.message.reply_text(f"Pong! Latency: {latency_ms}ms")
async def echo(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(f"You said: {update.message.text}")
def main():
app = ApplicationBuilder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("help", help_cmd))
app.add_handler(CommandHandler("ping", ping))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
log.info("Bot starting in polling mode...")
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()
Test it:
cd ~/mybot
source venv/bin/activate
python bot.py
Open Telegram, search for your bot's username, send /start. You should see your greeting back within a second. Send /ping — the latency reading tells you how far your VPS is from Telegram's MTProto endpoints (typically 30–80ms from a well-placed region).
Ctrl+C to stop the bot. Time to make it permanent.
🐾 Skip the boilerplate
Our Telegram Bot VPS ships pre-configured with Python 3.12, a virtualenv template, systemd unit, and fail2ban filter ready to go. Same plans, same hardware, just 30 minutes of setup saved.
See Telegram Bot Plans →5. Run the bot as a systemd service
Polling mode works, but if the bot crashes or the VPS reboots, you're offline until you SSH back in. systemd fixes that — it watches the process, restarts on failure, and starts the bot at boot.
Create the unit file as root (back out of the telegram user shell first with exit):
sudo tee /etc/systemd/system/mybot.service <<'EOF'
[Unit]
Description=My Telegram Bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=telegram
Group=telegram
WorkingDirectory=/home/telegram/mybot
EnvironmentFile=/home/telegram/mybot/.env
ExecStart=/home/telegram/mybot/venv/bin/python bot.py
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=mybot
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/telegram/mybot
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable mybot
sudo systemctl start mybot
sudo systemctl status mybot
Watch the logs in real time:
sudo journalctl -u mybot -f
Test resilience: sudo systemctl stop mybot, send a message — no response. sudo systemctl start mybot again, wait 3 seconds, send another message — instant reply. Now kill the Python process directly with sudo pkill -f bot.py — systemd restarts it within 5 seconds.
6. Switch to webhooks for production
Polling works but it's wasteful: your bot opens an HTTPS connection to Telegram every 30 seconds asking "any messages?" Webhooks flip the model — Telegram POSTs to YOUR server whenever a message arrives. Lower latency (typically 100–300ms vs 500–2000ms for polling), much lower bandwidth, scales to high message rates without breaking.
Webhooks require HTTPS — Telegram refuses plaintext URLs. So first, set up Nginx + Let's Encrypt. If you haven't done this before, our Nginx + Let's Encrypt tutorial walks through it in detail. Quick version:
sudo apt install -y nginx certbot python3-certbot-nginx
sudo tee /etc/nginx/sites-available/bot <<'EOF'
server {
listen 80;
server_name bot.example.com;
location /telegram-webhook {
proxy_pass http://127.0.0.1:8443;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOF
sudo ln -s /etc/nginx/sites-available/bot /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d bot.example.com
Now update bot.py to run as a webhook server instead of polling:
def main():
app = ApplicationBuilder().token(BOT_TOKEN).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("help", help_cmd))
app.add_handler(CommandHandler("ping", ping))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
domain = os.environ["WEBHOOK_DOMAIN"]
secret = os.environ["WEBHOOK_SECRET"]
webhook_url = f"https://{domain}/telegram-webhook"
log.info(f"Bot starting in webhook mode on {webhook_url}")
app.run_webhook(
listen="127.0.0.1",
port=8443,
url_path="/telegram-webhook",
webhook_url=webhook_url,
secret_token=secret,
allowed_updates=Update.ALL_TYPES,
)
The secret_token is sent in a header on every Telegram webhook POST. The library verifies it automatically — any request from a non-Telegram source is rejected. This is critical for production because your webhook URL is public.
Restart the bot to pick up the new code:
sudo systemctl restart mybot
sudo journalctl -u mybot -n 20
You should see Bot starting in webhook mode on https://bot.example.com/telegram-webhook followed by Telegram acknowledging the webhook registration. Send a message — response should arrive in under 300ms.
7. Persistence with SQLite
Real bots remember things — user preferences, conversation state, scheduled reminders, message logs. SQLite is the perfect bot database: zero configuration, single file, durable, plenty fast for any bot serving under a million messages a day.
Add SQLite usage to bot.py:
import sqlite3
from pathlib import Path
DB_PATH = Path(__file__).parent / "bot.db"
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
with get_db() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY,
username TEXT,
first_name TEXT,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
message_count INTEGER DEFAULT 0
)
""")
conn.commit()
async def track_user(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
with get_db() as conn:
conn.execute("""
INSERT INTO users (user_id, username, first_name)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
message_count = message_count + 1,
username = excluded.username
""", (user.id, user.username, user.first_name))
conn.commit()
Call init_db() at startup, and call track_user() as a global update handler. For larger workloads — bots with millions of records or heavy concurrent writes — consider migrating to PostgreSQL with our PostgreSQL setup guide.
8. The rate-limit math you need to know
Telegram enforces strict rate limits. Hit them and your bot gets temporarily banned (typically 30 seconds to 5 minutes, scaling with severity). Plan around them from day one.
| Action | Limit | Window |
|---|---|---|
| Messages to same user/chat | 1 message | per second |
| Messages to same group | 20 messages | per minute |
| Total outbound messages | 30 messages | per second |
| Broadcast to many users | ~30 users | per second sustained |
| Bulk media uploads | 20 files | per minute |
The library handles per-chat throttling automatically if you set rate_limiter=AIORateLimiter() on the ApplicationBuilder. For broadcasts to thousands of users, write your own queue: chunk recipients into batches of 25, sleep 1 second between batches, retry on RetryAfter exceptions.
9. Security hardening
fail2ban for SSH — every public VPS gets brute-forced. Install fail2ban with the SSH jail enabled by default:
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
Disable password SSH — use keys only:
sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sudo systemctl restart sshd
Rotate logs — bot logs can grow indefinitely under heavy traffic. systemd journal handles this if configured:
sudo sed -i 's/^#SystemMaxUse=.*/SystemMaxUse=500M/' /etc/systemd/journald.conf
sudo systemctl restart systemd-journald
Backup the database — a nightly rsync to remote storage takes 30 seconds to set up and saves you the day your VPS disk dies. Schedule a cron under the telegram user:
crontab -e
# Add:
0 3 * * * sqlite3 /home/telegram/mybot/bot.db ".backup '/home/telegram/mybot/backups/bot-$(date +\%F).db'"
FAQ
Polling vs webhooks — which should I use?
Polling for development (no HTTPS needed, no public IP, easy to debug). Webhooks for production (lower latency, less bandwidth, scales better, lower API quota usage). A bot doing real work for real users should use webhooks. A learning bot or weekend project can stay on polling forever and it'll be fine.
Can I run multiple bots on one VPS?
Yes — each bot gets its own user, systemd unit, virtualenv, and webhook path. A Starter Telegram Bot VPS handles 3–4 small bots comfortably; Pro handles 10–15; Premium handles 25+. The bottleneck is RAM (~80–150 MB per Python bot idle), not CPU.
How do I store secrets safely?
For a single bot: the .env file with chmod 600 is fine. For production systems with multiple bots or sensitive data, use a secrets manager — Vaultwarden works well for small teams, or HashiCorp Vault for larger setups. Never commit secrets to git, never log the token, never echo it in error messages.
What about Mini Apps (TWA)?
Telegram Mini Apps are essentially regular web apps loaded inside Telegram. Host them like any other static or dynamic site (Nginx or our Coolify VPS works great), then register the URL with @BotFather via /setdomain. The bot side is unchanged — Mini Apps just send data back to your bot via the Web App API.
Will Telegram throttle my bot based on the VPS IP?
No — rate limits are per-bot, not per-IP. You can run 50 bots from the same VPS without sharing limits. The only IP-related concern is webhook latency: pick a region close to Telegram's MTProto endpoints (Europe and Singapore are well-positioned globally).
How do I handle long-running tasks without blocking the bot?
Use asyncio.create_task() for fire-and-forget background work. For real queues, add Redis (or Postgres with pg-boss / arq) and a separate worker process — the bot enqueues jobs, the worker drains them. This pattern scales smoothly to millions of tasks/day.