Deploy a service on a server (and keep it running 24/7)

One pattern that works for a Telegram bot, a Discord bot, n8n or a Python script: run it as a systemd service, not in a terminal you'll close. With the checks that tell you it survived a reboot.

The bot runs, the script works, you tested it in your terminal and it did exactly what it should. Then you close the SSH session and it stops. This is the real question behind "how to deploy a service on a server": not how to get the code running once, but how to make it keep running after you log out, after a reboot, after the process itself crashes. One pattern covers a Telegram bot, a Discord bot, a Python script, n8n: wrap it in a systemd service instead of a terminal you can accidentally close.

TL;DR. Rent a VPS, create a dedicated non-root user, and clone the code into a subdirectory inside its home directory, not the home directory itself, it already holds files from /etc/skel, and keep secrets in a separate .env next to it. Describe how to start it in a systemd unit file (a config at /etc/systemd/system/name.service) with Restart=always and RestartSec=5, then enable it with systemctl enable --now. Check the status (systemctl status), watch live logs (journalctl -u name -f), and, the part people skip, reboot the box and confirm the service comes back on its own. For Telegram bots, start with long polling; it needs no domain or certificate, and it is not inherently slower than a webhook. Bring in Docker Compose once you have more than one service to run.

Why a VPS, not your own machine, if you want to deploy a service on a server for real

Laptops go to sleep. Home routers reboot themselves after a firmware update. Wi-Fi drops for five minutes and the bot you just tested goes quiet with it. A VPS (Virtual Private Server) is a machine that sits in someone else's data center, stays powered on, and does not care what your laptop lid is doing. It usually has a persistent public IP or some other stable network address, that part depends on the provider and the plan, some VPS offerings are NAT-only or IPv6-only, plus a persistent connection and, on average, a more stable uplink than a home network ever will.

You do not need much hardware for one bot on long polling or a small script. A $2-3/month plan with 1 vCPU and 1 GB of RAM is a fine starting point. Many providers let you scale resources up later without a reinstall, but that is a policy of the specific host and plan, not a property of virtualization itself, check the upgrade rules before you rely on it. Rough memory figures for common background services are below, but treat them as illustrative ranges, not a guarantee for your specific code: actual usage depends on the language, the library, and what the process is actually doing.

Service

Typical memory footprint

Long-polling bot (Python/Node)

roughly 60-150 MB, heavily dependent on language and library

Uptime Kuma (monitoring)

roughly 80-150 MB

n8n (a few workflows)

roughly 200-400 MB

Small API (FastAPI, Express)

roughly 100-250 MB

The practical takeaway: on a 1 GB box, a long-polling bot and something lightweight like Uptime Kuma fit together with room to spare. Anything heavy, a local language model, a frontend build, a headless browser for automation, does not belong on that box. Those run in gigabytes, not hundreds of megabytes.

No server yet? There is a step-by-step guide to creating a server that covers the account, the plan, and the first SSH login. From here on, this guide assumes the VPS already exists and you can log in over SSH as root or a sudo user.

Step 1. Prepare the server and a service user

The first thing worth doing is not running the bot as root. If there is a bug in the code, or a token leaks, whoever exploits it inherits the permissions of whatever user ran the process. Create a dedicated system user instead: no password, no interactive shell, existing only to own this one process.

sudo useradd --system --create-home --home-dir /opt/mybot --shell /usr/sbin/nologin botuser

  • --system: creates a system account rather than a regular login user.
  • --create-home --home-dir /opt/mybot: puts the home directory exactly where the code will live, not under /home.
  • --shell /usr/sbin/nologin: blocks interactive logins for this user. It exists purely to run the process.

Confirm the user exists and owns its directory.

id botuser
ls -ld /opt/mybot

What you should see. id botuser prints a UID/GID line with no no such user error. ls -ld /opt/mybot shows the owner as botuser botuser. If it still shows root root, fix it with sudo chown -R botuser:botuser /opt/mybot.

Step 2. Put the code and its dependencies in place

--create-home in step 1 did not just create an empty /opt/mybot directory, it copied the contents of /etc/skel into it (.bashrc.profile, and the like), that is standard useradd behavior. The directory is no longer empty, and cloning a repository straight into it produces exactly this error (confirmed on a live server):

fatal: destination path '/opt/mybot' already exists and is not an empty directory.

So the code goes in a dedicated app subdirectory, and the .env secret stays one level up, code and secret physically separated:

/opt/mybot/
├── .env
└── app/
├── bot.py
├── requirements.txt
└── venv/

A fresh VPS might not have git installed - install it alongside python3-venv in one go:

sudo apt update
sudo apt install -y git python3-venv

Then get the code onto the server, either git clone if there is a repository, or scp if it is simpler to copy files from your local machine. Do it as the service user from the start so file ownership does not turn into a separate cleanup job.

sudo -u botuser git clone https://github.com/you/mybot.git /opt/mybot/app

For a Python project, install dependencies into a virtual environment rather than the system Python: an isolated set of packages scoped to this one project, so it does not clash with whatever else is already installed on the machine or needed by other software running on the same server. On minimal Ubuntu and Debian images the venv module is sometimes missing from the base install. Confirmed on a clean Ubuntu 24.04 box: without the package, the command fails with exactly this -

The virtual environment was not created successfully because ensurepip is not
available. On Debian/Ubuntu systems, you need to install the python3-venv
package using the following command.

apt install python3.12-venv

- the generic, version-agnostic python3-venv package (already installed by the command above) installs the same thing and is confirmed working too:

cd /opt/mybot/app
sudo -u botuser python3 -m venv venv
sudo -u botuser ./venv/bin/pip install -r requirements.txt

  • python3 -m venv venv: creates a venv directory with its own interpreter and its own set of packages.
  • ./venv/bin/pip install -r requirements.txt: installs dependencies into that environment, not system-wide.

Do not hardcode a bot token, API key, or database password directly into the code; if the repository ever goes public or ends up in the wrong hands, the secret goes with it. Keep values like that in a .env file one level above the code directory, a plain text file of VARIABLE=value lines that the service reads at startup. Create it like this rather than with echo and the token typed straight into the command, that leaves the token sitting in your shell history for a while:

sudo -u botuser touch /opt/mybot/.env
sudo chmod 600 /opt/mybot/.env
sudo -u botuser nano /opt/mybot/.env

Type a line like BOT_TOKEN=your_token in the editor and save. chmod 600 restricts read and write to the owner, botuser; other unprivileged users cannot read it (root can, of course - you cannot hide a secret from root on the same box). touch leaves an existing file alone, unlike a command such as install ... /dev/null ..., which silently wipes an already-filled .env and its token if you run it again later, say when revisiting this step a month on.

For a small single-purpose VPS, a .env file at mode 600 is a practical baseline. If the threat model is stricter (a shared server, several admins, sensitive production secrets), pass secrets through systemd's LoadCredential= or a dedicated secret manager instead - environment variables are not a great mechanism for this, as systemd's own documentation notes.

Step 3. Write the systemd unit

systemd is the standard Linux service manager: it starts a process on boot, watches it while it runs, and restarts it if it dies. Instead of typing python bot.py into a terminal by hand - a process started that way can die when the terminal disconnects, and either way it has no restart on boot, no automatic recovery, and no centralized logs - describe how the process starts in a unit file, a plain-text config that systemd reads.

sudo nano /etc/systemd/system/mybot.service

Put this in it, with your own paths and username substituted in:

[Unit]
Description=My bot service
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=exec
User=botuser
WorkingDirectory=/opt/mybot/app
EnvironmentFile=/opt/mybot/.env
ExecStart=/opt/mybot/app/venv/bin/python /opt/mybot/app/bot.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

  • After=network-online.target and Wants=network-online.target: delay the start until the network manager considers the network configured, not right after the kernel boots. On the tested HIP image (Ubuntu 24.04) that is backed by an enabled systemd-networkd-wait-online.service, so the dependency genuinely participates in boot. But "network configured" does not mean DNS works or Telegram answers, the app still needs to survive a network error and retry, not die outright.
  • User=botuser: which account runs the process. Not root, per step 1.
  • WorkingDirectory=/opt/mybot/app: the code directory the process treats as its base for relative file paths; the .env secret lives one level up and loads separately.
  • EnvironmentFile=/opt/mybot/.env: loads environment variables from the secrets file. Without a token in BOT_TOKEN the service is pointless, so the path has no leading dash: if the file disappears, gets renamed, or loses its read permission, systemd refuses to start with a clear error instead of silently launching the bot without a token. Confirmed on a live server: with the file missing and no dash, the log shows exactly this line: Failed to load environment files: No such file or directory. A leading dash (EnvironmentFile=-path) belongs only where the variables file is genuinely optional.
  • ExecStart: the exact launch command, with the full path to the interpreter inside the virtual environment, so it always uses the right libraries instead of falling back to the system Python.
  • Type=exec: modern systemd recommends this over Type=simple for long-running services. systemd only counts the service as started after a successful exec(), so a missing or non-executable ExecStart surfaces as a start failure instead of "started, then immediately died".
  • Restart=always: restart the process on any exit it makes on its own, whether it crashed or exited cleanly. The exception is an explicit systemctl stop: systemd does not restart after that, it treats it as a deliberate stop, not a failure. For a bot that is expected to run continuously and should also come back after an unexpected clean exit (exit 0), Restart=always is a reasonable choice. As a general default for long-running services, modern systemd recommends Restart=on-failurealways is for the case where the service should never terminate on its own.
  • RestartSec=5: a 5-second pause before each restart attempt, so a crashing process is not hammered nonstop.
  • StartLimitIntervalSec=60 and StartLimitBurst=5 set an explicit start limit for this service: no more than five attempts in 60 seconds, and systemd refuses the next start request in that same window and marks the unit as failed. systemd also has a manager-wide default that applies even if you leave these lines out entirely, on the tested server that is DefaultStartLimitIntervalUSec=10s and DefaultStartLimitBurst=5, but its values depend on the manager's own configuration and can differ on another machine. Explicit parameters in the unit make the behavior predictable regardless of system defaults. Tested on a live server with tighter limits (StartLimitIntervalSec=20StartLimitBurst=3): after the third start attempt in a row, systemd genuinely refuses the next one, and journalctl shows exactly this line: Start request repeated too quickly, followed by Failed with result 'exit-code'.

After editing a unit file, systemd needs to re-read it, then you enable it to start on boot and start it now, both in one command with the --now flag.

sudo systemctl daemon-reload
sudo systemctl enable --now mybot

  • daemon-reload: required after any change under /etc/systemd/system, otherwise systemd keeps using the older unit definition it already has in memory.
  • enable --now: enables the service to start on boot (enable) and starts it immediately (--now) in one step.

Step 4. Check it actually comes back on its own

Starting the service is not the same as knowing it works. It might have crashed a second after launch and you would not know from the prompt alone. Start with the status.

systemctl status mybot

What you should see. An Active: active (running) line in green, with an uptime counter. If you see failed, or activating (auto-restart) cycling repeatedly, go straight to the logs.

journalctl -u mybot -f

-f keeps the output open and streams new log lines in real time, like tail -f. Trigger the bot with a message or a command and confirm there are no repeating errors or restarts. Ctrl+C stops watching.

The most reliable practical check is whether the service survives a reboot of the whole box, not just your current SSH session.

sudo reboot

Wait for the box to come back online, on a light KVM plan that is usually a matter of seconds, a busier machine can take longer than a minute, log back in over SSH, and run systemctl status mybot again. What you should see. active (running) again, without you doing anything to start it. That is the actual proof that enable worked and the service is autonomous, not just something you happened to launch manually on top of systemd earlier.

Long polling or webhook for a Telegram bot on a server

If what you are deploying is specifically a Telegram bot, the Bot API gives you two mutually exclusive ways to receive updates: long polling and webhook. (Discord works differently, events arrive in real time over a separate WebSocket connection, the Gateway, not through this pair of modes; everything else in this guide, server prep, systemd, zero-downtime updates, applies to it just the same.) Long polling means the bot repeatedly calls getUpdates with a positive timeout: if an update is already waiting, Telegram returns it right away on the already-open request; if there is nothing new, the request just sits open until the timeout, then the next one goes out. A webhook flips that: Telegram itself pushes a POST request to your server the moment something happens, which means your server has to be reachable from the outside over HTTPS.

Start with long polling: no domain, no TLS certificate, no inbound port needed, it works even behind NAT as long as outbound internet access exists, and it is not inherently higher-latency than a webhook. Teams move to a webhook not because polling is slow, but for architectural reasons: multiple bot instances behind a load balancer, a shared domain with other services, a predictable profile for inbound traffic. That is when a webhook's permanent piece of infrastructure - typically a domain, a certificate and a reverse proxy (Telegram will technically accept a webhook on a bare IP with a self-signed certificate, and the app can terminate TLS itself, but that is rare in practice) - is worth the cost. Note that even the allowed port 80 must still serve TLS for a webhook; it is not plain HTTP. None of this is because long polling is "too slow."

Aspect

Long polling

Webhook

Domain and certificate

not needed

needs an HTTPS endpoint

Response latency

low, Telegram returns the update right away on the already-open request

low, Telegram makes the inbound request itself

Connection

the bot holds an outbound getUpdates call

Telegram makes inbound HTTPS requests to you

Load

a long-lived outbound getUpdates call; the next one opens right after a reply or the timeout

a request only arrives when there is an event

When to pick it

a simple bot, one instance, no domain yet

multiple instances or services, a web-style architecture that needs to scale

If you do move to a webhook, Telegram sends updates to a specific port on your server, the Bot API supports 443, 80, 88, and 8443 for this (checked against the official documentation as of September 5, 2026). From there, a reverse proxy like Caddy or Nginx typically accepts the request and hands it to your process on localhost, the same way it would with any other web app.

When to move to Docker Compose

For a single process in a single language, Docker is not required. systemd handles it fine on its own, no extra container layer needed. Move to Docker and Docker Compose once you have several services with different, sometimes conflicting dependencies (one needs Node.js 18, another needs 22), or when it matters that you can move the whole setup to another server with one command instead of rebuilding the environment by hand.

Modern Docker Compose is not a separate docker-compose binary with a hyphen; it is a Docker CLI plugin, invoked as docker compose with a space. The package name depends on where you install Docker from. Ubuntu 24.04's own repository names the package docker-compose-v2, and as of September 2026 it carries Compose 2.40.3 (confirmed live):

sudo apt install docker.io docker-compose-v2
docker compose version

If you add Docker's own official repository instead (usually for a fresher version - Docker Compose there is already on the 5.x line), the equivalent package is named docker-compose-plugin. The command stays docker compose either way. The package names are easy to mix up, so check what apt actually sees before installing:

apt-cache policy docker-compose-v2 docker-compose-plugin

Debian is messier still: Debian 12 (Bookworm) ships version 1.29.2-3, the old first-generation Compose; Debian 13 (Trixie) already has 2.26.1-4, where Compose also installs as a Docker CLI plugin (/usr/libexec/docker/cli-plugins/docker-compose), so docker compose works there too (checked against Debian's own package index). There is no single command that works across every OS listed here, check the apt-cache policy output for your system.

What you should see. A Docker Compose version ... line with a version number (from stock Ubuntu, 2.40.x; from Docker's repo, 5.x). From there, several services go into one compose.yaml file. One detail that matters for this article's topic: the Docker daemon does start via systemd after a reboot, but Compose defaults to restart: "no", so the containers themselves do NOT come back on their own. For the service to survive a reboot, each container needs a restart policy:

services:
app:
image: your/image:tag
restart: unless-stopped

Confirmed on a live server: a container with no restart: key stays Exited after the daemon restarts; with restart: unless-stopped it comes back to Up on its own. Everything starts with a single docker compose up -d, and you do not write a separate unit file per container.

Updating a service, and when you actually need zero downtime

For most background services, a bot, a script, one small API, updating is straightforward: pull the new code, refresh dependencies, restart.

cd /opt/mybot/app
sudo -u botuser git pull
sudo -u botuser ./venv/bin/pip install -r requirements.txt
sudo systemctl restart mybot

systemctl restart stops and starts the process, and the gap lasts exactly as long as your app takes to stop and start again, anywhere from a fraction of a second to several seconds or more, depending on what it does on startup (connecting to a database, warming a cache, and so on). For a bot or a personal service, that is rarely a problem.

If you genuinely need zero downtime, the method depends on the service type. For an HTTP service or an API, blue/green works: bring up the new version on a separate port and switch a reverse proxy over once it is ready, then retire the old one. For a Telegram bot on long polling you cannot do this: only one getUpdates can be active per token, and if the old and new copies run at once, Telegram returns the very 409 Conflict error covered in the Telegram bot 24/7 article. For that kind of bot the options are: accept a short gap for systemctl restart, or design the bot around a webhook with a clean cutover. On a single server running a single process, blue/green is almost always more complexity than it is worth - a few seconds of restart downtime is usually cheaper.

FAQ

How do I stop my app from dying the moment I close SSH?

Do not run it by hand in a terminal; a process started that way can die when the terminal disconnects (the exact behavior depends on the shell, job control, and how it was launched), and either way it has no real lifecycle management: no restart on boot, no automatic recovery from a crash, no centralized logs. Wrap it in a systemd service instead, that gets you all of it at once. nohup and screen can keep a process alive through a closed terminal, but they are a stopgap for testing, not something to run in production.

What does Restart=always actually do in a systemd unit?

It tells systemd to restart the process every time it exits on its own, whether that exit was an error or a clean shutdown, except after an explicit systemctl stop: the service does not come back after that, confirmed on a live server. That is correct for a background service meant to run continuously; for a one-off job, use Restart=on-failure instead. Pair it with RestartSec=5 so restarts are not instantaneous back-to-back, and with StartLimitIntervalSec/StartLimitBurst in the [Unit] section to put a predictable cap on start attempts.

Is a $3 VPS enough to run a bot or a couple of small services?

Usually yes for a long-polling bot: real-world memory use tends to sit around 60-150 MB, though that varies a lot by language and library and is not a hard guarantee. A 1 GB box has room left over for something light like Uptime Kuma alongside it. Heavy workloads, a local language model, a frontend build, a headless browser, do not fit; those need gigabytes, not hundreds of megabytes.

Long polling or webhook, which should I use for a bot?

For Telegram, start with long polling: no domain, certificate, or open port needed, the bot fetches updates itself, and it is not inherently slower than a webhook. Teams move to a webhook for architectural reasons, multiple bot instances, a shared domain with other services, not because polling is slow. For most personal and small bots, long polling is all you need. This does not apply to Discord, whose bots receive events over a separate Gateway/WebSocket connection instead.

How do I know a service is actually working?

systemctl status name should show active (running), and journalctl -u name -f should show a steady stream of logs with no repeating errors or restart loops. For a networked service, confirm the port is actually listening: ss -tlnp | grep port (stripped-down cloud images sometimes lack it out of the box; it comes from the iproute2 package). The final check is a full server reboot followed by a status check, to confirm the service really comes back on its own.

Do I need Docker to deploy a service?

Not necessarily. A single process in a single language runs perfectly well under systemd with no containers at all. Docker and Docker Compose earn their keep once you have several services, different dependencies, or need a predictable way to move the setup between machines; that is a different tool for a different problem, not a mandatory step everyone has to take.

How do I update a service without downtime on one server?

For most background services, systemctl restart is enough - a brief gap whose length depends on the app's startup time. True zero downtime is done differently by service type: for an HTTP service, a new version on another port plus a reverse-proxy cutover; for a Telegram bot on long polling you cannot (two concurrent getUpdates on one token give a 409 Conflict), so it is either a short restart or a webhook architecture. For a personal project or a small bot, a full replica setup is almost always overkill.

In short

  • Deploying a service on a server that actually stays up means stopping the habit of running it by hand and wrapping it in a systemd service instead.
  • A dedicated non-root, no-login user limits the damage if there is a bug in the code or a leaked token.
  • A virtual environment for Python and a separate .env file for secrets are baseline hygiene, not optional extras.
  • Restart=always plus RestartSec=5 bring a crashed process back; StartLimitIntervalSec/StartLimitBurst stop an infinite restart loop when it keeps crashing.
  • The most reliable practical check that a service is autonomous is a real server reboot followed by systemctl status.
  • For Telegram bots, start with long polling: no domain or certificate needed, and it is not inherently slower than a webhook. Teams move to a webhook for architecture (multiple instances, a shared domain), not for speed. Discord works differently, through Gateway/WebSocket.
  • Docker Compose is for multiple services with different dependencies, not a mandatory step for one process. If you use it, give containers restart: unless-stopped, or they will not come back on their own after a reboot.

Need a cheap server that just works? HIP plans start at $2.40/month, SSD storage, and setup in under a minute. If you are picking a physical location for lower latency, take a look at the Helsinki location.

PUBLISHED
AUTHOR
HIP-HOSTING
LANGUAGES
EN · RU