...

How to Host a Telegram Bot on a VPS: Python/Node.js, systemd, Docker, Webhook, and HTTPS

Martin Klein

Reading time 1 minute

A Telegram bot can be run on a VPS in two main ways: with polling or with a webhook. For a simple project, a personal bot, or a small MVP, a polling bot managed by systemd is usually enough. For a production scenario, it is better to use Docker, a webhook, HTTPS, a reverse proxy, and a separate .env file for tokens.

A simple polling-based architecture looks like this: Telegram API ← polling ← bot process on the VPS

The bot regularly contacts the Telegram API and retrieves new messages itself. This setup does not require a domain, HTTPS, or a reverse proxy. The key is to run the bot as a service, configure automatic restarts, and set up logs.

A production webhook-based setup looks different: Telegram API → HTTPS webhook → reverse proxy → bot container

In this case, Telegram sends updates to the bot’s HTTPS endpoint. A webhook requires a domain, an SSL certificate, a reverse proxy, and a valid public URL.

A minimal working setup for a VPS should include:

  • A dedicated project directory;
  • Python or Node.js dependencies;
  • A .env file for the token and settings;
  • Startup via systemd or Docker Compose;
  • Automatic restart after a crash;
  • Log viewing;
  • A clear deployment process after code updates;
  • Restrictions on admin commands;
  • Protection to keep the token out of the repository.

For a polling bot, a systemd unit is usually enough, or even running the script from cron. A systemd unit starts the process after a server reboot, restarts it on failure, and lets you view logs through journalctl.

In a Docker-based setup, the project usually contains a Dockerfile, docker-compose.yml, .env, and the bot code. The container gets the token from the env file, runs with a restart policy, and is updated by rebuilding after git pull.

The main rule for secrets is that the Telegram bot token must not be stored directly in the code or committed to Git. It is better to keep it in a .env file that is added to .gitignore. If the token is accidentally committed to the repository, it must be regenerated through BotFather.

Polling and webhooks should not be mixed unless you understand the implications. If the bot uses polling, the webhook should usually be disabled. If a webhook is used, the bot should not also try to retrieve updates through polling.

After launch, you need to check more than just whether the bot responds in Telegram. Make sure the service starts after a reboot, logs are available, the env file is being read, the token is not stored in the repository, the restart policy works, the webhook uses valid HTTPS, and admin commands are available only to the intended users.

Common mistakes include running the bot manually in an SSH session, storing the token in the repository, failing to configure automatic restarts, confusing polling with webhooks, not checking SSL for the webhook, opening unnecessary ports, and not restricting access to admin commands.

The right approach is as follows: for a simple bot, use polling with systemd; for a more serious deployment, use Docker, a webhook, HTTPS, a reverse proxy, .env, a restart policy, logs, and a clear deployment process after code updates.

Two Architectures for Running a Telegram Bot

A Telegram bot can be hosted on a VPS in different ways. The most common options are polling via systemd and a webhook with Docker, HTTPS, and a reverse proxy.

Both approaches work, but they address different needs. Polling is easier to set up and debug. The webhook approach requires more configuration, but is better suited to production scenarios where a domain, containerization, HTTPS, and managed deployment are important.

Polling via systemd

Polling is a model in which the bot contacts the Telegram API itself and retrieves new updates. It does not need a public HTTPS address, a domain, or a reverse proxy.

In simplified form, it looks like this: VPS → bot process → Telegram API

The bot runs as a regular application on the server. For example, a Python bot might be run with python main.py, or a Node.js bot with node index.js.

However, starting it manually in an SSH session should not be considered a proper deployment approach. If you close the terminal, the connection is interrupted, or the server reboots, the bot may stop.

Alternatively, the bot can be started with cron: once at startup if the polling loop is implemented in the code, or on a recurring schedule by moving the loop to the scheduler. However, this kind of setup is harder to diagnose when failures occur.

For this reason, polling bots are usually run through systemd. The unit file defines which user the bot should run as, which directory it should run in, which command should start it, which environment variables it should use, and what should happen if the process crashes.

The basic flow is: bot code → systemd service → autostart → restart on failure → logs via journalctl

For a simple Telegram bot, this is often the most practical option. It does not require a domain, SSL, or webhook configuration, while still providing autostart, service management, and proper logs.

Polling is especially convenient for personal bots, internal utilities, MVPs, small projects, and scenarios that do not require complex infrastructure.

Webhook with Docker and HTTPS

A webhook works differently. In this setup, Telegram sends updates directly to the bot’s public HTTPS endpoint.

The flow looks like this: Telegram API → https://bot.example.com/webhook → reverse proxy → bot container

A webhook requires a public address that is accessible from the internet. This is usually a domain or subdomain, for example:

bot.example.com

On a VPS, the request is handled by a reverse proxy: Nginx, Caddy, Traefik, or another server. It handles HTTPS, receives the request from Telegram, and forwards it to the application or container.

In a production setup, it is convenient to run the bot with Docker Compose. This makes the structure more predictable:

  • The bot code is stored in the project;
  • Dependencies are packaged into a Docker image;
  • Tokens are passed through .env;
  • The container runs with a restart policy;
  • Logs are viewed through Docker;
  • Deployment is performed through git pull, rebuild, and restart.

The webhook approach requires more preparation, but it is better suited when the bot becomes part of the operational infrastructure. This is especially true when there is also a database, a queue, an API, a reverse proxy, other containers, and a unified deployment process.

The key requirement is valid HTTPS. If the certificate is invalid, the domain does not resolve, or the reverse proxy is misconfigured, Telegram will not be able to reliably send updates to the webhook.

When to choose polling

Polling is a good choice when you need a simple, reliable deployment without extra infrastructure. It is a suitable option for the first deployment of a Telegram bot on a VPS.

Polling is suitable if:

  • The bot is small;
  • The project is personal or internal;
  • There is no domain;
  • You do not want to configure SSL and security;
  • There is no heavy load;
  • You need simple debugging;
  • The bot can run as a single process;
  • Automatic startup via systemd is sufficient.

This approach is especially convenient for Python bots built with aiogram, python-telegram-bot, or pyTelegramBotAPI, as well as for Node.js bots built with Telegraf or node-telegram-bot-api.

The key point is not to leave the process running in an SSH session. Even a simple polling bot should run as a service:

systemctl start bot

systemctl enable bot

journalctl -u bot -f

Polling does not require inbound connections from Telegram, so the firewall configuration can be simpler. In most cases, administrator SSH access and outbound access to the Telegram API are sufficient.

However, polling is not always convenient for more complex production scenarios. If the project already uses Docker, a domain, HTTPS, and a reverse proxy, it makes more sense to consider a webhook.

When to Choose a Webhook

A webhook is the better choice when the bot needs to be part of a more structured production architecture. This is especially useful if the project is already deployed with Docker Compose and runs behind a reverse proxy.

A webhook is suitable if:

  • You have a domain and HTTPS;
  • Docker is used;
  • A single production deployment is required;
  • The bot runs alongside an API, a database, or other containers;
  • A controlled network topology is important;
  • Updates need to be received through a public endpoint;
  • The project is intended for a team or a business;
  • Monitoring, logs, and regular updates are in place.

A webhook requires more discipline. You need to check DNS, SSL, the reverse proxy, the webhook path, environment variables, open ports, and the endpoint’s availability from the internet.

It is also important not to mix modes. If the bot uses a webhook, polling should not be started at the same time. Conversely, if polling is used, it is best to remove the old webhook to avoid conflicts and lost updates.

For a small bot, polling with systemd is usually faster and simpler. For a production service with a domain, HTTPS, Docker, and predictable deployment, a webhook is a better fit.

Preparing the VPS

Before launching the Telegram bot, you need to prepare the server: update the system, install the required runtime, create a dedicated user, organize the project into a clear directory structure, and move tokens into .env.

These steps are required for both deployment options: a simple polling-based bot managed with systemd and a production setup using Docker, a webhook, and HTTPS.

Updating the system

Start by updating the packages. For Ubuntu or Debian:

sudo apt update

sudo apt upgrade -y

After that, it is a good idea to install the basic utilities: sudo apt install -y git curl nano ufw

If the bot will be run via systemd, it is also important at this stage to verify that the server reboots correctly and that services start automatically.

For basic protection, enable the firewall and allow SSH. If a webhook will be used later, ports 80 and 443 will also be required for HTTP/HTTPS.

sudo ufw allow OpenSSH

sudo ufw allow 80/tcp

sudo ufw allow 443/tcp

sudo ufw enable

sudo ufw status

For a polling bot, ports 80 and 443 are not required because Telegram does not send requests to the server. The bot connects to the Telegram API itself. However, if the same VPS will host a website, a reverse proxy, or a webhook, these ports will be needed.

After updating the system, you can install Python or Node.js, depending on what the bot is written in.

Installing Python or Node.js

A Python bot typically requires Python, pip, and a virtual environment.

sudo apt install -y python3 python3-pip python3-venv

Check the versions:

python3 –version

pip3 –version

For a Node.js bot, it is best to use the current LTS version of Node.js. One option is to install it via NodeSource or another official method suitable for your system.

After installation, check:

node -v

npm -v

If the bot will run with Docker, you do not need to install Python or Node.js directly on the VPS. The runtime will be inside the container. In that case, the server needs Docker and Docker Compose.

However, for a simple systemd-based setup, the runtime must be installed on the VPS itself because the service will start the Python or Node.js process directly.

Creating a User for the Bot

Do not run the bot as root. It is better to create a dedicated system user, such as botuser.

sudo adduser –system –group –home /opt/telegram-bot botuser

This makes it easier to restrict the project’s permissions and isolate the bot from system files. If there is a bug in the code, it should not give the application unnecessary access to the entire server.

You can place the project directory under /opt:

sudo mkdir -p /opt/telegram-bot

sudo chown -R botuser:botuser /opt/telegram-bot

Next, you can clone the code from the repository or upload it manually:

cd /opt/telegram-bot

sudo -u botuser git clone https://github.com/example/telegram-bot.git .

If the repository is private, it is better to configure access using SSH keys or a deploy token rather than a password on the command line.

A dedicated user is especially important for systemd: in the unit file, you can explicitly specify which user the process should run as.

User=botuser

Group=botuser

WorkingDirectory=/opt/telegram-bot

After setting up the user and permissions, you need to organize the project into a clear structure.

Project structure

The project structure should be simple: a separate directory for the code, a separate dependency file, a separate .env file for tokens, and a clear entry point. This makes the bot easier to run, update, and move to another server.

For a Python bot, a minimal structure might look like this:

Path or filePurpose
/opt/telegram-bot/Project root directory on the VPS
main.pyEntry point: starts the bot
bot/Main code: handlers, configuration, helper modules
requirements.txtPython dependencies
.envBot token, administrator IDs, and environment settings
README.mdBrief instructions for running and deploying the bot

For a Node.js bot, the structure is similar, but npm files are used instead of requirements.txt:

Path or filePurpose
/opt/telegram-bot/Project root directory on the VPS
index.jsEntry point: starts the bot
src/Main code: handlers, configuration, modules
package.jsonDependencies and startup commands
package-lock.jsonPinned package versions
.envBot token, administrator IDs, and environment settings
README.mdBrief instructions for running and deploying the bot

For a Docker-based setup, a few more files are usually added to the project:

  • Dockerfile — instructions for building the container;
  • docker-compose.yml — description of how to run the container or containers;
  • .dockerignore — files that should not be sent to the Docker build context.

It is important not to store tokens, temporary files, or local dependencies in the repository. To do this, .gitignore usually includes:

.env*.log__pycache__/node_modules/.venv/

This keeps the project clean: the code is stored in Git, secrets are kept in .env, dependencies are installed separately, and running the bot through systemd or Docker uses a single, clearly defined directory.

.env file for tokens and settings

The Telegram bot token must not be stored directly in the code. It should be moved to a .env file, and this file should not be added to the repository.

Example .env file:

BOT_TOKEN=123456789:AAExampleToken

ADMIN_IDS=123456789,987654321

ENV=production

LOG_LEVEL=info

For a webhook-based setup, additional values may be required:

BOT_TOKEN=123456789:AAExampleToken

ADMIN_IDS=123456789,987654321

WEBHOOK_URL=https://bot.example.com/webhook

WEBHOOK_SECRET=change_this_secret

PORT=3000

BOT_TOKEN is issued through BotFather. If the token accidentally ends up in a public repository, it must be regenerated, and the old one should be considered compromised.

It is better to store ADMIN_IDS explicitly. This allows admin commands to be restricted by Telegram user ID rather than by username, which the user can change.

In Python, .env files are often read using python-dotenv; in Node.js, they are read using dotenv. The principle is the same in either case: the code reads secrets from the environment instead of storing them in the project files.

It is best to restrict permissions on the .env file:

sudo chown botuser:botuser /opt/telegram-bot/.env

sudo chmod 600 /opt/telegram-bot/.env

For systemd, the env file can be included in the unit file: EnvironmentFile=/opt/telegram-bot/.env

For Docker Compose, the env file is usually placed next to docker-compose.yml and passed into the container as environment variables.

As a result, VPS preparation should provide a clear baseline: the system is updated, the runtime is installed, the bot does not run as root, the project is stored in a separate directory, tokens are moved to .env, and secrets do not end up in Git.

Option 1. Polling bot with systemd

Polling is the simplest way to run a Telegram bot on a VPS. In this setup, the bot calls the Telegram API itself, retrieves new updates, and processes messages.

Polling does not require a domain, HTTPS, or a reverse proxy. However, the bot still should not be started manually in an SSH session. The proper approach is to set it up as a systemd service, with autostart, a restart policy, an env file, and logs.

Project Structure

For the polling option, the project structure should be minimal. The main requirements are a clear entry point, a dependencies file, and a .env file with the token.

For a Python bot:

File or folderPurpose
/opt/telegram-bot/Project directory on the VPS
main.pyEntry point
bot/Bot code: handlers, config, utils
requirements.txtPython dependencies
.envToken, admin ID, and settings

For a Node.js bot:

File or folderPurpose
/opt/telegram-bot/Project directory on the VPS
index.jsEntry point
src/Bot code: handlers, config, utils
package.jsonDependencies and startup commands
.envToken, admin ID, and settings

The bot token should not be stored in main.py, index.js, or any other code file. It is best to keep it in .env:

BOT_TOKEN=123456789:AAExampleTokenADMIN_IDS=123456789,987654321ENV=production

Add the .env file to .gitignore so you do not accidentally commit the token to the repository.

Installing dependencies

For a Python bot, it is convenient to use a virtual environment. Run the commands from the project directory:

cd /opt/telegram-botpython3 -m venv .venvsource .venv/bin/activatepip install -r requirements.txt

If you use aiogram, pyTelegramBotAPI, or python-telegram-bot, include the relevant package in requirements.txt.

Example:

aiogrampython-dotenv

For a Node.js bot, install dependencies via npm:

cd /opt/telegram-botnpm install

It is advisable to add the startup command to package.json right away:

{

“scripts”: {

“start”: “node index.js”

}

}

This makes local testing and running it via a service clearer.

Manual startup for testing

Before configuring systemd, start the bot manually once and make sure it actually works.

For Python:

cd /opt/telegram-botsource .venv/bin/activatepython main.py

For Node.js:

cd /opt/telegram-botnpm start

At this stage, check that:

  • The bot starts without errors;
  • .env is read;
  • The token is correct;
  • The bot responds in Telegram;
  • Admin commands are available only to the intended IDs;
  • polling does not conflict with the old webhook.

If a webhook was previously configured for this bot, it is best to delete it before starting polling: https://api.telegram.org/bot<TOKEN>/deleteWebhook

After a successful check, you can stop the process with Ctrl+C and configure startup through systemd.

systemd unit

A systemd unit defines how to run the bot as a service. For a Python bot, the unit file might look like this:

[Unit]Description=Telegram BotAfter=network.target[Service]Type=simpleUser=botuserGroup=botuserWorkingDirectory=/opt/telegram-botEnvironmentFile=/opt/telegram-bot/.envExecStart=/opt/telegram-bot/.venv/bin/python /opt/telegram-bot/

main.py
Restart=alwaysRestartSec=5

[Install]WantedBy=multi-user.target

You can save the file here: /etc/systemd/system/telegram-bot.service

For a Node.js bot, ExecStart will be different: ExecStart=/usr/bin/npm start

or directly: ExecStart=/usr/bin/node /opt/telegram-bot/index.js

After creating the unit file, reload the systemd configuration, enable automatic startup, and start the service:

sudo systemctl daemon-reloadsudo systemctl enable telegram-botsudo systemctl start telegram-bot

Check the status: sudo systemctl status telegram-bot

If the service does not start, the usual cause is an incorrect path to Python/Node.js, user permissions, an error in the .env file, missing dependencies, or an incorrect working directory.

Automatic Restarts and Logs

The main advantage of systemd is that the bot does not depend on an SSH session. If you close the terminal, the process will keep running. If the VPS reboots, the service will start automatically. If the bot crashes, systemd will try to restart it.

The following block is responsible for this:

Restart=alwaysRestartSec=5

You can view logs with journalctl: sudo journalctl -u telegram-bot -f

The latest log lines: sudo journalctl -u telegram-bot -n 100 –no-pager

Restarting after changes to the code or .env: sudo systemctl restart telegram-bot

Stopping: sudo systemctl stop telegram-bot

If the bot is updated via Git, a basic deployment for the polling option might look like this:

cd /opt/telegram-botgit pullsource .venv/bin/activatepip install -r requirements.txtsudo systemctl restart telegram-botsudo journalctl -u telegram-bot -n 100 –no-pager

For Node.js:

cd /opt/telegram-botgit pullnpm installsudo systemctl restart telegram-botsudo journalctl -u telegram-bot -n 100 –no-pager

After deployment, you need to check not only the service status but also the bot’s actual response in Telegram. active (running) means that the process is running, but it does not guarantee that the token, polling, handlers, and admin commands are working correctly.

Overall, polling through systemd is a good option for simple Telegram bots: minimal infrastructure, straightforward startup, automatic restarts, logs, and simple deployment after code updates.

Option 2. Production with Docker, a webhook, and HTTPS

The production setup differs from a simple polling-based setup in that the bot receives updates via a webhook. Telegram sends requests to a public HTTPS endpoint; the reverse proxy accepts them and forwards them to the bot container.

The basic architecture looks like this: Telegram API → HTTPS webhook → Nginx → bot container

This approach requires more configuration than polling, but it is better suited to projects that already use Docker, a domain, HTTPS, a reverse proxy, and a well-defined deployment process.

Dockerfile and docker-compose.yml

For a Docker-based setup, add a Dockerfile, docker-compose.yml, .env, and .dockerignore to the project. The code remains in the repository, while tokens and secrets are stored separately.

Example Dockerfile for a Node.js bot:

FROM node:22-alpine

WORKDIR /app

COPY package*.json ./

RUN npm ci –omit=dev

COPY . .

CMD [&quot;node&quot;, &quot;index.js&quot;]

Example docker-compose.yml:

services:

telegram-bot:

build: .

container_name: telegram-bot

restart: unless-stopped

env_file:

– .env

ports:

– &quot;127.0.0.1:3000:3000&quot;

In this setup, the container listens on local port 3000, but it is not exposed externally. External requests should be handled by a reverse proxy.

For a Python bot, the Dockerfile will be different:

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install –no-cache-dir -r requirements.txt

COPY . .

CMD [&quot;python&quot;, &quot;main.py&quot;]

The application must include an HTTP endpoint for the webhook. For example: POST /webhook

Telegram will send updates to this exact path.

Reverse proxy and domain

A webhook requires a domain or subdomain that points to the VPS. For example: bot.example.com

The DNS record must point to the server’s IP address: bot.example.com  A  203.0.113.10

Nginx can also be added to the Compose file, but here we will use a simplified setup with the package installed in the operating system.

Nginx accepts requests for the domain and forwards them to the container:

server {

listen 80;

server_name bot.example.com;

location / {

proxy_pass http://127.0.0.1:3000;

proxy_http_version 1.1;

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;

}

}

This configuration can be saved to: /etc/nginx/sites-available/bot.example.com

Then enable the site and test Nginx:

sudo ln -s /etc/nginx/sites-available/bot.example.com /etc/nginx/sites-enabled/

sudo nginx -t

sudo systemctl reload nginx

If the bot is listening on 127.0.0.1:3000 and Nginx is running on the same VPS, direct access to the container from the internet is not required. Only ports 80 and 443 should be open externally.

SSL for the webhook

A Telegram webhook must be accessible over HTTPS. If the certificate is invalid, the domain does not match, or the reverse proxy is configured incorrectly, Telegram will not be able to deliver updates reliably. The official Telegram documentation also describes how webhooks work over HTTPS and a separate option for uploading a self-signed certificate in setWebhook.

For a VPS, Let’s Encrypt is typically used: sudo certbot –nginx -d bot.example.com

After the certificate is issued, verify that the domain is accessible over HTTPS: curl -I https://bot.example.com

It is also worth checking the specific webhook endpoint: curl -I https://bot.example.com/webhook

A GET request may return 404 or 405 if the application only expects POST. This is not always an error. What matters is that HTTPS works, the certificate is valid, and Nginx forwards requests to the container.

If a CDN or external proxy is used, you should also check the SSL mode. An error between the CDN and the origin can result in HTTPS working in the browser while the Telegram webhook does not.

Setting up a Telegram webhook

After the domain, HTTPS, and reverse proxy are ready, you need to register the webhook with Telegram.

Basic request:

curl -X POST “https://api.telegram.org/bot$BOT_TOKEN/setWebhook” \

-d “url=https://bot.example.com/webhook”

If the application uses a secret path or a token in the URL, the webhook might look like this: https://bot.example.com/webhook/secret-path

You can also use a secret header if the library and application validate it. This makes it easier to distinguish incoming requests from random requests to the endpoint.

After configuring the webhook, you should check its status: curl “https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo”

In the response, check the webhook URL, the most recent errors, and the number of pending updates.

If the bot previously used polling, it is important not to run polling in parallel with the webhook. These modes should not be mixed: either the bot retrieves updates itself, or Telegram sends them to the webhook.

Restart policy and container logs

The container should start automatically after the VPS is rebooted and restart if the process crashes. In Docker Compose, this is controlled by the restart policy: restart: unless-stopped

Docker describes unless-stopped as a mode similar to always, but without automatically restarting the container after it has been stopped manually. For services running on a VPS, this is often a convenient option.

Starting the container:

cd /opt/telegram-bot

docker compose up -d –build

Checking the status: docker compose ps

Container logs: docker compose logs -f telegram-bot

Most recent log lines: docker compose logs telegram-bot –tail=100

After updating the code, a production deployment usually looks like this:

cd /opt/telegram-bot

git pull

docker compose up -d –build

docker compose logs telegram-bot –tail=100

After deployment, you need to check not only the container but also the webhook itself:

  • The container is running;
  • HTTPS is working;
  • getWebhookInfo does not show any errors;
  • The bot responds in Telegram;
  • The webhook endpoint receives POST requests;
  • The logs contain no errors related to the token, route, or env file.

As a result, a production setup using Docker, a webhook, and HTTPS is better suited for bots that need to run reliably, be updated predictably, and be part of a standard server infrastructure.

Deployment after code updates

After the bot is launched for the first time, it is important to plan not only the initial installation but also the regular deployment process. The code will change: new commands, fixes, handlers, dependencies, and settings will be added.

A good deployment process should be repeatable. The administrator connects to the VPS, updates the project via Git, installs new dependencies, restarts the service or rebuilds the container, and checks the logs.

Updating the Project via Git

If the code is stored in Git, a basic deployment starts by changing to the project directory and pulling the latest version.

cd /opt/telegram-bot

git pull

Before doing this, make sure there are no uncommitted local changes on the server: git status

If the project dependencies have changed, update them separately. For Python:

source .venv/bin/activate

pip install -r requirements.txt

For Node.js: npm install

The .env file is usually not updated via Git. It should remain local to the VPS and must not be included in the repository. If new environment variables have been added, add them manually and then restart the bot.

After updating the code, the next steps depend on how the bot is running: via systemd or Docker Compose.

Restarting the systemd service

If the bot runs via polling under systemd, you need to restart the service after updating the code.

sudo systemctl restart telegram-bot

Check the status: sudo systemctl status telegram-bot

If the service does not start, check the logs: sudo journalctl -u telegram-bot -n 100 –no-pager

To view logs in real time: sudo journalctl -u telegram-bot -f

A typical deployment for a Python bot using systemd might look like this:

cd /opt/telegram-bot

git pull

source .venv/bin/activate

pip install -r requirements.txt

sudo systemctl restart telegram-bot

sudo journalctl -u telegram-bot -n 100 –no-pager

For a Node.js bot:

cd /opt/telegram-bot

git pull

npm install

sudo systemctl restart telegram-bot

sudo journalctl -u telegram-bot -n 100 –no-pager

After restarting, send a test command to the bot in Telegram. The active (running) status means the process is running, but it does not guarantee that the handlers, token, and admin commands are working correctly.

Rebuilding the Docker container

If the bot runs via Docker Compose, the container must be rebuilt and restarted after the code is updated.

Basic procedure:

cd /opt/telegram-bot

git pull

docker compose up -d –build

If the Dockerfile, dependencies, or code have changed in the project, –build ensures that the image is rebuilt.

Check the container status: docker compose ps

View the latest logs: docker compose logs telegram-bot –tail=100

To follow the logs live: docker compose logs -f telegram-bot

If multiple services are used, such as the bot and a database, you can rebuild only the bot service: docker compose up -d –build telegram-bot

With this deployment approach, the .env file should also not come from Git. It remains on the server next to docker-compose.yml. If new variables have been added, they must be added manually, and then the container must be restarted.

After rebuilding, it is important to check not only the container but also the webhook, if the bot uses one.

Checking logs after deployment

Logs are an essential part of deployment. Without them, you may not notice that the bot started with an error, failed to read the .env file, did not connect to the database, or is not receiving updates.

For systemd: sudo journalctl -u telegram-bot -n 100 –no-pager

For Docker: docker compose logs telegram-bot –tail=100

After deployment, check the following:

  • The process or container is running;
  • There are no module import errors;
  • The token has been read from .env;
  • The bot responds in Telegram;
  • polling does not conflict with the webhook;
  • The webhook URL is accessible over HTTPS;
  • Admin commands are available only to the intended users;
  • New commands or fixes actually work.

For a webhook bot, also check the webhook status in Telegram: curl “https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo”

If the response contains last_error_message, check Nginx, SSL, the webhook route, and the container logs.

A proper deployment does not end with a restart command, but with verifying the result: the bot responds, there are no errors in the logs, the webhook works, and the new changes have actually been applied.

Securing a Telegram Bot on a VPS

A Telegram bot running on a VPS usually seems like a small service, but it has access to a token, commands, user messages, a database, external APIs, and sometimes administrative functions. Security should therefore be built in from the start, not after the first incident.

The minimum baseline: store the token in .env, keep secrets out of Git, validate Telegram user IDs for admin commands, avoid opening unnecessary ports in the firewall, and run the bot under a separate user with restricted permissions.

Where to store the token

The Telegram bot token should not be hard-coded directly in the code. Bad example: BOT_TOKEN = “123456789:AAExampleToken”

Or for Node.js: const BOT_TOKEN = “123456789:AAExampleToken”;

A better approach is to store the token in a .env file:

BOT_TOKEN=123456789:AAExampleToken

ADMIN_IDS=123456789,987654321

ENV=production

The code should read the token from environment variables. For Python, python-dotenv is commonly used; for Node.js, dotenv is commonly used.

Example for Python:

import os

from dotenv import load_dotenv

load_dotenv()

BOT_TOKEN = os.getenv(“BOT_TOKEN”)

Example for Node.js:

require(“dotenv”).config();

const BOT_TOKEN = process.env.BOT_TOKEN;

If the bot is run via systemd, you can include the env file in the unit file: EnvironmentFile=/opt/telegram-bot/.env

If the bot is run via Docker Compose, the env file is specified in docker-compose.yml:

env_file:

– .env

The token should be treated as a secret. If it ends up in a public repository, chat, log, or screenshot, it is best to regenerate it immediately through BotFather.

How to Avoid Exposing Secrets in a Repository

The main rule: .env must not be committed to Git. To prevent this, add it to .gitignore.

.env

*.log

__pycache__/

node_modules/

.venv/

For Docker, you should also add .env to .dockerignore so that tokens are not included in the build context:

.env

.git

node_modules

.venv

__pycache__

*.log

You can keep a sample file in the repository without any real secrets:

BOT_TOKEN=your_bot_token_here

ADMIN_IDS=123456789

ENV=production

This type of file is usually named .env.example

It shows which variables the project needs without exposing real tokens.

You should also avoid putting tokens in the README, Dockerfile, package.json, systemd unit, deployment scripts, or commands in your shell history. The fewer places a secret appears, the easier it is to control.

If a secret has already been committed, simply deleting the line in a new commit is not enough. The token may still remain in the Git history. In this case, it is safer to regenerate the token in BotFather and replace it on the server.

Restricting admin commands

If a bot has admin commands, they should not be protected only by a “hidden name.” A command can be guessed, forwarded, or found in the code.

You should check the sender’s Telegram user ID. A username is less suitable for this because it can be changed.

Example logic:

if user_id is in ADMIN_IDS → execute the command

otherwise → deny access

In Python, this might look like this:

ADMIN_IDS = {123456789, 987654321}

if message.from_user.id not in ADMIN_IDS:

return

For Node.js:

const adminIds = [123456789, 987654321];

if (!adminIds.includes(ctx.from.id)) {

return;

}

This type of check should be used to protect everything that affects the bot’s operation:

  • Broadcasts;
  • Viewing requests;
  • Exporting data;
  • Managing users;
  • Restarting scenarios;
  • Changing settings;
  • Accessing service information;
  • Database commands.

If the bot runs in groups, you also need to account for the chat ID. A user may be a bot administrator, but that does not mean every admin command can be executed in any chat.

Firewall and open ports

A polling bot usually does not need inbound HTTP ports. The bot connects to the Telegram API itself, so it is generally enough to leave only SSH open for administration.

At minimum:

sudo ufw allow OpenSSH

sudo ufw enable

sudo ufw status

For a webhook-based setup, ports 80 and 443 are required because Telegram will send updates to the bot’s HTTPS endpoint.

sudo ufw allow 80/tcp

sudo ufw allow 443/tcp

It is better not to expose the application port, for example 3000, directly to the internet. You can bind it only to 127.0.0.1 and make it available externally through Nginx:

ports:

– “127.0.0.1:3000:3000”

This way, external requests go through HTTPS and a reverse proxy instead of reaching the application directly.

You should also avoid exposing database ports, Redis, admin panels, and internal services unless necessary. The fewer open ports there are, the smaller the attack surface.

As an additional security measure, you can restrict incoming HTTP\HTTPS traffic to Telegram addresses and for the LE HTTP-01 method (for automatically obtaining or renewing a certificate). Telegram addresses are published on their website, and LE uses an endpoint of the following form to validate the certificate http://bot.example.com/.well-known/acme-challenge. And while Telegram restrictions are fairly easy to add to the firewall, and https (443) can be closed to everything else, for the certificate you will need to configure a reverse proxy and location blocks. The task may not be entirely trivial; here is a configuration example:server {

listen 80;

server_name bot.example.com wwwbot.example.com;

# allow only HTTP-01 traffic

location ^~ /.well-known/acme-challenge/ {

allow all;

root /var/www/certbot; # Must match your ACME client’s webroot

default_type “text/plain”;

}

# Deny everything else on port 80

location / {

deny all;

}

}

In addition to network access, another important consideration is the principle of least privilege under which the application runs.

User permissions and file access

The bot should not be run as root. It is better to create a dedicated user, for example botuser, and grant that user permissions only for the project directory.

sudo adduser –system –group –home /opt/telegram-bot botuser

sudo chown -R botuser:botuser /opt/telegram-bot

The .env file should be accessible only to the bot user and the server administrator:

sudo chown botuser:botuser /opt/telegram-bot/.env

sudo chmod 600 /opt/telegram-bot/.env

For systemd, the user must be specified explicitly in the unit file:

User=botuser

Group=botuser

WorkingDirectory=/opt/telegram-bot

This prevents the bot from having unnecessary permissions on system directories. If there is a bug or vulnerability in the code, the process should not be able to modify files outside its own project.

For the Docker deployment, it is also important not to mount unnecessary host directories into the container. If the container only needs the code and environment variables, it should not be given access to all of /var, /root, or user home directories.

Ultimately, securing a Telegram bot on a VPS comes down to simple rules: the token is stored in .env, .env is not committed to Git, admin commands validate the user ID, only the required ports are exposed externally, and the bot itself runs under a dedicated user with restricted permissions.

Common mistakes

Mistakes when deploying a Telegram bot on a VPS often stem not from the code, but from how it is operated. The bot runs locally and responds on Telegram, the developer quickly moves it to the server—and leaves it in a “somehow it’s running” state.

That may be acceptable for testing. For a production bot, it is not. The token must be protected, the process must survive VPS reboots, the method for receiving updates must be chosen deliberately, and admin commands must be available only to the appropriate users.

Storing the token in the repository

A Telegram bot token must not be stored in code or committed to Git. If the repository is public, the token can quickly be exposed to outsiders. If the repository is private, the risk still remains: contractors, former employees, CI/CD systems, third-party services, or unintended project participants may gain access.

Bad example: const bot = new Telegraf(&quot;123456789:AAExampleToken&quot;);

Or this: bot = Bot(token=&quot;123456789:AAExampleToken&quot;)

It is better to store the token in .env:

BOT_TOKEN=123456789:AAExampleToken

ADMIN_IDS=123456789,987654321

And read it from environment variables in the code.

If the token has already been committed to the repository, it should be considered compromised. Simply deleting it from the file is not enough: the secret may remain in Git history. It is safer to regenerate the token via BotFather and replace it on the VPS.

Starting the bot manually in an SSH session

A common mistake is to connect to a VPS over SSH and start the bot manually: python main.py

Or: node index.js

The bot runs as long as the terminal remains open. However, if the SSH session drops, the server reboots, or the process crashes, the bot may stop.

For production use, you need a process manager. In a simple polling setup, this is systemd:

sudo systemctl start telegram-bot

sudo systemctl enable telegram-bot

For a Docker-based setup, use Docker Compose with a restart policy: docker compose up -d

Manual startup is useful only for initial verification. After that, the bot should run as a service or container, not as a process tied to an open terminal.

Not configuring a restart policy

The bot may crash because of a code error, a network failure, temporary API unavailability, insufficient memory, or an unexpected exception. If restarts are not configured, it will simply stop.

For systemd, restart settings are needed in the unit file:

Restart=always

RestartSec=5

For Docker Compose: restart: unless-stopped

This is not a substitute for fixing bugs in the code, but it helps the bot ride out temporary failures. Without a restart policy, the bot may silently stop responding, and the owner may only find out from users.

After configuring automatic restarts, you still need to monitor the logs. If the bot crashes every few seconds and keeps restarting, the problem has not been resolved—it has simply been put into a loop.

Confusing polling and webhooks

Polling and webhooks are two different ways to receive updates from Telegram. With polling, the bot retrieves updates itself. With a webhook, Telegram sends updates to the bot’s HTTPS address.

Problems begin when these modes are mixed without understanding how they work. For example, the bot is running with polling, but an old webhook is still active. Or the other way around: a webhook is configured, but the code also starts polling in parallel.

For polling, it is best to delete the webhook before starting: https://api.telegram.org/bot<TOKEN>/deleteWebhook

For a webhook, you need to configure a public HTTPS endpoint: https://bot.example.com/webhook

Then register it using setWebhook.

The rule is simple: in a production setup, one primary mode should be chosen for a single bot. Either polling via systemd, or a webhook via HTTPS and a reverse proxy.

Not checking SSL for the webhook

The webhook must be available over HTTPS. If the SSL certificate is invalid, has expired, was issued for a different domain, or the reverse proxy is configured incorrectly, Telegram will not be able to reliably send updates.

You can check the domain as follows: curl -I https://bot.example.com

Check the webhook endpoint: curl -I https://bot.example.com/webhook

If the endpoint accepts only POST, a GET request may return 404 or 405. This is not always a problem. What matters is that the HTTPS connection is established, the certificate is valid, and the request reaches the application.

You should also check the webhook status through the Telegram API: curl “https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo”

If last_error_message is present there, check SSL, DNS, Nginx, the webhook route, and the container logs.

Not restricting access to admin commands

Admin commands must not be left available to all users. Even if a command is “hidden,” it can be guessed, forwarded, found in the code, or triggered accidentally.

Potentially dangerous commands should be protected by checking the Telegram user ID:

if user_id is in ADMIN_IDS → execute the command

otherwise → deny the request

Access should be restricted for anything that affects the bot’s operation or data:

  • Broadcasts;
  • Exports;
  • Viewing requests;
  • User management;
  • Changing settings;
  • Internal commands;
  • Access to statistics;
  • Database operations.

Using a username for this check is not recommended because it can be changed. It is more reliable to store numeric Telegram user IDs in .env.

If the bot operates in groups, you should check not only the user but also the chat ID. An administrator may be allowed to run a command in a private chat with the bot, but that does not mean the command should run in any group.

Ultimately, most mistakes when deploying a Telegram bot come down to one issue: the bot is launched like a temporary script but used as a production service. A VPS requires a proper setup: the token in .env, startup via systemd or Docker, a restart policy, a clear polling/webhook mode, verified HTTPS, and restricted admin commands.

Conclusion

For a Telegram bot on a VPS, there are two practical options. A simple polling bot can be conveniently run with systemd: it does not require a domain, HTTPS, or a reverse proxy, while still providing automatic startup, a restart policy, and proper logs.

A production setup with Docker, a webhook, and HTTPS requires more configuration, but it is better suited to live projects. In this setup, the bot runs in a container, receives updates through a secure endpoint, sits behind a reverse proxy, and is updated through a clear deployment process.

In both cases, the same basic rules matter: the token is stored in .env, secrets are not committed to Git, the bot is not started manually in an SSH session, a restart policy is enabled, logs are checked after deployment, and admin commands are restricted by Telegram user ID.

FAQ

Which should you choose for a Telegram bot: polling or a webhook?

For a simple bot, polling is often more convenient. It does not require a domain, SSL, or a reverse proxy: the bot connects to the Telegram API itself and retrieves updates. This option works well for personal projects, MVPs, internal utilities, and small bots.

A webhook is a better choice for production scenarios where you already have a domain, HTTPS, a reverse proxy, Docker, and a clear deployment process. In this case, Telegram sends updates directly to the bot’s public HTTPS endpoint. The official Telegram Bot API describes setWebhook as a way to specify the URL to which Telegram will send incoming updates.

Can I run a Telegram bot manually in an SSH session?

For testing, yes. For continuous operation, it is not recommended. If you close the terminal, lose the SSH connection, or reboot the VPS, the process may stop.

For a polling bot, it is better to use systemd; for a Docker-based setup, use Docker Compose with a restart policy. This allows the bot to start after a reboot and restart if it crashes.

Why use systemd for a polling bot?

systemd turns the bot from a manually run process into a managed service. With systemd, you can start, stop, and restart the bot, enable autostart, and view logs.

Basic commands:

sudo systemctl start telegram-bot

sudo systemctl enable telegram-bot

sudo systemctl status telegram-bot

sudo journalctl -u telegram-bot -f

The systemd documentation describes how service units work and how service restarts are configured through unit file settings.

Where should you store a Telegram bot token?

It is best to store the token in .env, not in the code. The .env file should be kept on the VPS and must not be committed to the repository.

Example:

BOT_TOKEN=123456789:AAExampleToken

ADMIN_IDS=123456789,987654321

ENV=production

Only .env.example can be stored in Git, without any real secrets. If the token has ended up in a public repository, it must be reissued through BotFather.

Do you need to use Docker for a Telegram bot?

Not necessarily. For a simple polling bot built with Python or Node.js, systemd is often sufficient. It is simpler and faster.

Docker is useful when you need a more predictable production deployment: a container, docker-compose.yml, .env, a restart policy, rebuilding after git pull, dependency isolation, and a consistent startup process across different servers.

How do you verify a webhook after configuration?

You need to check three layers: HTTPS, the application endpoint, and the webhook status in Telegram.

curl -I https://bot.example.com

curl -I https://bot.example.com/webhook

curl “https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo”

If getWebhookInfo shows last_error_message, check DNS, SSL, Nginx, the webhook route, and the container logs.

Why does a webhook need HTTPS?

A Telegram webhook must be accessible via an HTTPS URL. If the certificate is invalid, the domain does not match, or the reverse proxy is configured incorrectly, Telegram will not be able to deliver updates reliably.

This is typically done using a domain, Nginx as a reverse proxy, and a Let’s Encrypt SSL certificate. Nginx can accept HTTPS requests and forward them to the internal application via proxy_pass.

How do you update the bot after changing its code?

For a systemd-based setup:

cd /opt/telegram-bot

git pull

sudo systemctl restart telegram-bot

sudo journalctl -u telegram-bot -n 100 –no-pager

If the dependencies have changed, run pip install -r requirements.txt or npm install before restarting.

For a Docker-based setup:

cd /opt/telegram-bot

git pull

docker compose up -d –build

docker compose logs telegram-bot –tail=100

Docker also supports restart policies, allowing containers to start automatically after being stopped or after a reboot, depending on the selected policy.

How should admin commands be restricted?

Admin commands should be checked using the Telegram user ID, not the username. A username can be changed, while the numeric ID remains more stable.

Simple logic:

if user_id is in ADMIN_IDS → execute the command

otherwise → deny the request

This approach should be used to protect broadcasts, exports, user management, maintenance commands, statistics, and any actions that affect the bot’s data or operation.

Sources

1. Telegram Bot API — setWebhook and working with updates

2. systemd.service — unit files and service parameters

3. Docker Docs — automatic container startup and restart policy

4. Nginx Documentation — Reverse Proxy

Subscribe to our newsletter and receive articles and news

    Check out our other materials