A bot started in screen or with nohup stops at the first error and does not come back after the server reboots. Below is how to run a Telegram bot on a server properly: as a systemd service that starts with the system, restarts after a crash, writes logs to the system journal and runs as a separate user with no extra privileges. We cover a bot in Python and one in Node.js; only the installation and one line in the service settings differ.

Tested on Ubuntu 24.04 and 26.04: Python 3.12 and 3.14 with aiogram 3.31.0, Node.js 24.21.0 with grammY 1.46.0, systemd 255 and 259. The bot code was tested against a test server that responds like the Telegram Bot API.

Prerequisites

  • A server running Ubuntu 24.04 or 26.04 and an admin user with sudo rights; how to create one is covered in initial server setup.
  • A bot token from @BotFather, a string like 123456789:AA….
  • The bot code, preferably in a git repository. If you do not have code yet, use the example from this article.

We will call the bot echobot: the service is echobot.service, the user echobot, the directory /opt/echobot. Substitute your own name.

Step 1. Create a separate user for the bot

The bot should not run as root or as your own user: if a vulnerability turns up in the code or a library, an attacker gets only the bot's privileges.

sudo useradd --system --no-create-home --shell /usr/sbin/nologin echobot
id echobot

--system creates a system account, and nologin prevents logging in with it. The code will be owned by admin, so the bot can read it but not change it:

sudo install -d -o admin -g admin /opt/echobot
git clone https://github.com/you/echobot.git /opt/echobot    # if the code is in git

The bot can write only to the data directory: put everything it saves to files there (SQLite, uploads). Add data/ and .venv/ to .gitignore.

sudo install -d -o echobot -g echobot -m 750 /opt/echobot/data

Step 2. Python bot: virtual environment and aiogram

We use aiogram, a popular asynchronous library for Python bots. As of October 11, 2026, the latest version is 3.31.0; it works with Python 3.10–3.14, that is, with the system Python on both Ubuntu versions.

sudo apt update
sudo apt install -y python3-venv git
cd /opt/echobot
python3 -m venv .venv
echo 'aiogram==3.31.0' > requirements.txt    # if the repository does not have this file yet
.venv/bin/pip install -r requirements.txt
.venv/bin/python -c 'import aiogram; print(aiogram.__version__)'

The .venv virtual environment is a separate copy of the libraries for this project, so system package updates will not break it. The exact version in requirements.txt ensures the server runs the same thing you tested locally.

The bot itself is the file /opt/echobot/bot.py. It replies to /start and repeats any text it receives:

import asyncio
import logging
import os

from aiogram import Bot, Dispatcher, F
from aiogram.filters import CommandStart
from aiogram.types import Message

dp = Dispatcher()


@dp.message(CommandStart())
async def start(message: Message) -> None:
    await message.answer("The bot is running. Send some text and I will repeat it.")


@dp.message(F.text)
async def echo(message: Message) -> None:
    await message.answer(message.text)


async def main() -> None:
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
    bot = Bot(token=os.environ["BOT_TOKEN"])
    await dp.start_polling(bot)


if __name__ == "__main__":
    asyncio.run(main())

Note that the token is not in the code: the bot reads it from the BOT_TOKEN environment variable. The log format does not need a timestamp, because the system journal adds one.

Step 3. Keep the token in a separate file with mode 600

The token gives full control of the bot. It does not go into the code or into git, only into a file on the server that only root can read:

sudo install -d -m 700 /etc/echobot
sudo install -m 600 /dev/null /etc/echobot/bot.env
sudo nano /etc/echobot/bot.env

The file contains one line, with no quotes and no spaces around =:

BOT_TOKEN=123456789:AA-your-token

Use an editor rather than echo … | sudo tee, so that the token does not stay in the command history. Check the permissions: sudo ls -l /etc/echobot/bot.env should show -rw------- 1 root root. systemd reads this file as root before starting the bot and passes the value into the process environment, so the echobot user does not need access to the file, and it does not have it.

Step 4. The systemd unit file

A unit file describes a service: what to run, as which user, and what to do on failure. Create /etc/systemd/system/echobot.service:

sudo nano /etc/systemd/system/echobot.service
[Unit]
Description=Echo Telegram bot
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=echobot
Group=echobot
WorkingDirectory=/opt/echobot
EnvironmentFile=/etc/echobot/bot.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/echobot/.venv/bin/python bot.py
Restart=on-failure
RestartSec=5

NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/opt/echobot/data
ProtectHome=yes
PrivateTmp=yes

[Install]
WantedBy=multi-user.target

What matters here:

  • After= and Wants=network-online.target start the bot after the network is actually up, not just after the networking service starts.
  • EnvironmentFile is the token file from step 3.
  • PYTHONUNBUFFERED=1: without this line, Python buffers print() output and it does not show up in the journal. We checked: without it, print() lines did not reach the journal.
  • ExecStart is the full path to Python in the virtual environment; there is no need to activate the environment separately.
  • Restart=on-failure restarts the bot if the process exits with an error or is killed. If the bot exits on its own with code 0, systemd does not restart it. If you need a restart in every case, use Restart=always.
  • RestartSec=5 is the pause before a restart. By default, systemd gives up after 5 starts within 10 seconds; with a 5-second pause, that limit is never reached, and the bot keeps restarting until it works.

The last block is a set of restrictions that cost almost nothing and greatly narrow what a compromised bot can do:

  • NoNewPrivileges=yes: the process and its children cannot gain more privileges, for example through sudo.
  • ProtectSystem=strict: the entire file system is read-only for the bot, except the directories in ReadWritePaths.
  • ProtectHome=yes: /home and /root are inaccessible to the bot.
  • PrivateTmp=yes: the bot gets its own /tmp, not shared with other programs.

Check the file for errors:

sudo systemd-analyze verify /etc/systemd/system/echobot.service

Empty output means there are no errors.

Step 5. Start the bot and enable it at boot

sudo systemctl daemon-reload
sudo systemctl enable --now echobot
systemctl status echobot

daemon-reload rereads the unit files, and enable --now enables start at boot and starts the bot right away. The status output should show Active: active (running) and enabled in the Loaded line. Send /start to the bot in Telegram, and it will reply.

We tested the restrictions with a test script run in this same service. Writing to /opt/echobot/data works; writing to /opt/echobot and /etc fails with Read-only file system; /home gives Permission denied; sudo replies The "no new privileges" flag is set; the bot cannot read /etc/echobot/bot.env, but it does have the BOT_TOKEN variable.

Bot logs: journalctl

Everything the bot writes to the console goes to the system journal. The admin user cannot see service logs without the adm group (-- No entries --), so run the commands with sudo:

sudo journalctl -u echobot -f                    # follow in real time, Ctrl+C to exit
sudo journalctl -u echobot -n 100                # last 100 lines
sudo journalctl -u echobot --since "1 hour ago"  # the last hour
sudo journalctl -u echobot --since today -g Error   # only lines containing Error

After startup, the journal shows aiogram lines:

INFO aiogram.dispatcher: Start polling
INFO aiogram.dispatcher: Run polling for bot @echo_test_bot id=123456789 - 'Echo'
INFO aiogram.event: Update id=1 is handled. Duration 14 ms by bot id=123456789

Python errors also reach the journal in full, with the traceback. The -p err filter will not help with them: the journal records everything a program writes to the console at the normal priority, so search the text with -g.

How to test auto-restart

Simulate a crash by killing the bot process:

sudo kill -9 $(systemctl show -p MainPID --value echobot)
sleep 7
sudo journalctl -u echobot -n 4 --no-pager

The journal will show the lines Scheduled restart job, restart counter is at 1 and Started echobot.service. systemctl show echobot -p NRestarts shows how many times the service has restarted since it was started.

Test start at boot with sudo reboot: after you log back in, systemctl is-active echobot should reply active.

How to update the bot

If the code is in git:

cd /opt/echobot
git pull --ff-only
.venv/bin/pip install -r requirements.txt
sudo systemctl restart echobot
sudo journalctl -u echobot -n 20 --no-pager

--ff-only stops git from silently creating a merge if someone edited files on the server by hand. Installing dependencies is needed only if requirements.txt changed, but it does no harm. The last command confirms that the new version started without errors. During the restart, the bot does not respond for a few seconds; Telegram keeps messages sent during that time (for up to 24 hours) and delivers them after startup.

If you changed the unit file itself, run sudo systemctl daemon-reload before restarting.

Node.js bot: installation and startup

The Node.js versions in the Ubuntu repositories are outdated: 18.19 in 24.04 (support ended in April 2025) and 22.22 in 26.04. As of October 11, 2026, the current LTS version is Node.js 24, supported until April 2028 (release schedule). Node.js 26 becomes LTS on October 28, 2026; after that, you can install it by replacing 24 with 26 in the repository URL.

Install Node.js 24 from the NodeSource repository:

sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /etc/apt/keyrings/nodesource.asc
sudo tee /etc/apt/sources.list.d/nodesource.sources > /dev/null <<EOF
Types: deb
URIs: https://deb.nodesource.com/node_24.x
Suites: nodistro
Components: main
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/nodesource.asc
EOF
sudo apt update
apt policy nodejs          # Candidate: 24.x.x-1nodesource1
sudo apt install -y nodejs
node -v                    # v24.21.0

Steps 1 and 3 (the user, directories and token file) are the same. The library is grammY, version 1.46.0:

cd /opt/echobot
npm init -y
npm install --save-exact grammy@1.46.0

The code is the file /opt/echobot/bot.mjs (the .mjs extension enables modern import syntax without any configuration):

import { Bot } from "grammy";

const bot = new Bot(process.env.BOT_TOKEN);

bot.command("start", (ctx) => ctx.reply("The bot is running. Send some text and I will repeat it."));
bot.on("message:text", (ctx) => ctx.reply(ctx.message.text));
bot.catch((err) => console.error("Error while handling update:", err.error));

process.once("SIGTERM", () => bot.stop());
process.once("SIGINT", () => bot.stop());

await bot.start({ onStart: (me) => console.log(`Bot @${me.username} started`) });

bot.catch keeps an error in one handler from bringing down the whole bot. Handling SIGTERM lets systemctl stop and restart stop the bot cleanly rather than by timeout.

Two lines differ from the unit file in step 4:

Environment=NODE_ENV=production
ExecStart=/usr/bin/node bot.mjs

Everything else, including the restrictions, stays the same. Updating works as for Python, except that instead of pip you run npm ci --omit=dev, which installs exactly what is recorded in package-lock.json.

Long polling or webhook

Both bots above use long polling: they repeatedly ask Telegram for new messages themselves (the getUpdates method). This is the simplest option: you need no domain, open ports or certificate, and the bot works even behind NAT.

A webhook works the other way around: Telegram sends each message to your URL. It requires a domain and HTTPS with a valid certificate on one of the ports 443, 80, 88 or 8443 (Bot API documentation), usually with a web server in front of the bot. A webhook makes sense with a heavy message flow or when the bot already runs inside a web application. For most bots on a single server, long polling is enough.

Only one method works at a time: while the bot has a webhook set, getUpdates returns an error. grammY removes the webhook itself when it starts in polling mode; aiogram does not.

Troubleshooting

What you see What to do
status=203/EXEC in systemctl status Wrong path in ExecStart. Check that the file exists: ls -l /opt/echobot/.venv/bin/python or which node
KeyError: 'BOT_TOKEN' (Python) or Empty token! (grammY) The variable is missing: check the path in EnvironmentFile and the BOT_TOKEN=… line format, with no spaces
Unauthorized in the journal, the bot restarts every 5 seconds Wrong token. Fix /etc/echobot/bot.env and run sudo systemctl restart echobot
Conflict: terminated by other getUpdates request Another process is polling with the same token, for example the bot is also running on your computer. Stop the second instance
can't use getUpdates method while webhook is active The bot still has a webhook. Remove it by opening https://api.telegram.org/bot<token>/deleteWebhook
Read-only file system or Permission denied when writing The bot writes outside /opt/echobot/data. Move the files there or add the directory to ReadWritePaths
No print() output in the journal The Environment=PYTHONUNBUFFERED=1 line is missing
journalctl replies -- No entries -- Run it with sudo
Changes to the unit file have no effect sudo systemctl daemon-reload, then sudo systemctl restart echobot

To see which other restrictions you can enable for the service, run systemd-analyze security echobot: it lists all protection settings and marks the ones that are enabled.

What's next

  • If the server is new, first go through initial Ubuntu setup and set up SSH key authentication.
  • If the bot gains a database or a web panel, it is easier to run everything together in containers; see Docker Compose in production on a single VPS.
  • How much memory the bot uses and how to limit it for the service (MemoryMax): the articles on server sizing and on running out of memory.
  • Find out the bot is down before your users do: Uptime Kuma with Telegram alerts.
  • If the bot is a "receive a message, process it, reply" chain without complex logic, you can build it without code in n8n on your own server.
  • More on the service journal and its size: server logs with journalctl and logrotate; installing and updating Node.js (Node 26 has no corepack): how to install Node.js on a server; a Python web application set up the same way: FastAPI and Django on a VPS.