A trading bot that only runs when your laptop is open is not a trading bot, it is a demo. Getting it onto a server that stays up is the easy part; the part that actually matters is boring operations work — restart policy, clock sync, and knowing the bot is still alive without staring at a terminal.
What this covers, and what it doesn't
This is an operations guide: running a Python trading bot as a long-lived Linux process that restarts sanely, keeps credentials off disk in the wrong place, agrees with the exchange about what time it is, and tells you when it dies. It does not cover strategy, order types, backtesting, or position sizing — that is your call to make, and nothing here is financial advice. Assume you already have a working bot that talks to an exchange's API and just need it to run unattended without becoming a liability.
Sizing the machine
A single bot process is a light workload. A Python process holding a websocket connection, a small in-memory order book, and a logging pipeline typically sits well under 300 MB resident. The 1 GiB Starter tier covers one bot with headroom for the OS and chrony. What actually pushes you up a tier is running several bots on one box, keeping a local database of fills and candles that grows for months, or backtesting on the same machine you trade from — batch work and a live process fight over the same cores. Keep those separate if you can.
Disk is rarely the constraint: a bot's code, a virtualenv, and months of rotated JSON logs fit inside a few gigabytes. The 25 GB on Starter is plenty unless you're recording full market data ticks, a different sizing conversation entirely.
Step 1: a clean, outbound-only machine
Deploy an Ubuntu LTS image, then do the basics before anything touches the network: a non-root user, an SSH key, password login off, and a firewall that defaults to deny. Walkthroughs for both are at connect to your VPS over SSH and secure your VPS.
This workload only calls out — REST calls and a websocket to the exchange, nothing listening for inbound connections — so the shared, NAT-style IPv4 most budget VPS plans hand out by default is irrelevant here. No dedicated address or forwarded port needed for anything in this guide.
Create a dedicated system user for the bot rather than running it as your own login or as root:
sudo useradd --system --create-home --shell /usr/sbin/nologin botuser
sudo mkdir -p /opt/trading-bot/data
sudo chown -R botuser:botuser /opt/trading-bot
Deploy your code under /opt/trading-bot and build the virtualenv as botuser, not as yourself:
sudo -u botuser python3 -m venv /opt/trading-bot/venv
sudo -u botuser /opt/trading-bot/venv/bin/pip install -r /opt/trading-bot/requirements.txt
Step 2: the bot as a systemd service
A bot running in a tmux session survives an SSH disconnect but not a reboot, a crash, or you forgetting which pane it's in. A systemd unit survives all three and gives you a restart policy for free:
[Unit]
Description=trading-bot
After=network-online.target chrony.service
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
User=botuser
Group=botuser
WorkingDirectory=/opt/trading-bot
EnvironmentFile=/etc/trading-bot/env
ExecStart=/opt/trading-bot/venv/bin/python /opt/trading-bot/bot.py
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/trading-bot/data
PrivateTmp=true
[Install]
WantedBy=multi-user.target
Two choices here are deliberate. StartLimitIntervalSec=300 with StartLimitBurst=5 stops a crash loop from hammering the exchange's API forever — after five restarts in five minutes, systemd gives up and leaves the unit stopped instead of retrying into a rate-limit ban. And Restart=on-failure, not Restart=always, only restarts the process on a non-zero exit status. That distinction is the whole point of the kill switch in Step 7, so hold onto it.
Enable it now. Don't start it yet — the unit's EnvironmentFile doesn't exist until Step 3, and systemd refuses to start a service whose EnvironmentFile is missing:
sudo systemctl daemon-reload
sudo systemctl enable trading-bot.service
The systemd.service manual documents every directive above if you want the exact semantics rather than my summary of them.
Step 3: API keys in a root-only file, never in the repo
The exchange API key and secret don't belong in your code, in a .env committed by accident, or in a config file readable by every user on the box. They belong in an EnvironmentFile only root can read:
sudo mkdir -p /etc/trading-bot
sudo touch /etc/trading-bot/env
sudo chown root:root /etc/trading-bot/env
sudo chmod 600 /etc/trading-bot/env
EXCHANGE_API_KEY=your-key-here
EXCHANGE_API_SECRET=your-secret-here
This works even though the service runs as the unprivileged botuser: systemd, running as root, reads EnvironmentFile before dropping privileges to start your process, and hands the resulting variables to the already-running process. The file itself never needs to be readable by botuser, so chmod 600 owned by root is the correct, final permission — not a temporary one you loosen later because something "couldn't read it."
Add the file's path to .gitignore before you write anything into it, and check with git status that it never shows up as untracked or staged. A key that reaches a private repo is still a key that reached a repo; rotate it if you're ever unsure.
Now that /etc/trading-bot/env exists, start the service you enabled in Step 2:
sudo systemctl start trading-bot.service
sudo systemctl status trading-bot.service
Step 4: clock sync with chrony
Most exchange APIs sign requests with a timestamp and reject anything outside a tolerance window, often a few seconds. A drifted VPS clock gets -1021-style "timestamp outside recvWindow" errors on every signed call — it looks exactly like a broken bot and is actually a broken clock.
Ubuntu 24.04 ships systemd-timesyncd by default, which is fine for most things but coarser than you want here. Chrony is the better fit for a machine that needs a tight, actively-monitored offset:
sudo apt update
sudo apt install -y chrony
sudo systemctl disable --now systemd-timesyncd
sudo systemctl enable --now chrony
Confirm it's actually tracking, not just running:
chronyc tracking
chronyc sources -v
chronyc tracking should show a System time offset in the low milliseconds within a minute or two of starting. If it stays wide, check outbound UDP 123 isn't blocked by your firewall rules. The chrony documentation covers tuning makestep and source selection if the defaults don't converge fast enough.
Step 5: structured logging you can actually grep at 3am
Plain print() statements are fine for development and useless at 3am when you need the one line that explains why an order didn't go through. Log as JSON, one object per line, and let journald capture stdout — no separate log file needed:
import json
import logging
import sys
import time
class JsonFormatter(logging.Formatter):
def format(self, record):
payload = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(record.created)),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
Because systemd already timestamps everything it captures, ask journalctl for the raw JSON without its own prefix when you want to pipe it somewhere:
journalctl -u trading-bot.service -o cat -f
journalctl rotates and caps its own storage by default, so you're not manually managing log files. If you add a second destination — a file, a shipper off-box — cap its size explicitly; an unbounded log file is a slow, quiet way to run out of disk.
Step 6: a heartbeat so a dead bot doesn't stay dead
systemctl status tells you the process is running, not that it's doing anything useful — a bot can be alive, connected, and stuck in a loop that stopped placing orders hours ago, and Restart=on-failure never fires because nothing crashed.
A dead man's switch fixes this, and it's a natural fit for an outbound-only bot: at the end of every successful loop, ping a monitoring URL.
import urllib.request
def send_heartbeat(url: str) -> None:
try:
urllib.request.urlopen(url, timeout=5)
except Exception:
pass # a missed heartbeat should never crash the bot
Call send_heartbeat() once per loop, after the real work succeeds, not before it. Pair it with a service that expects a ping on a schedule and pages you when one doesn't arrive — healthchecks.io is the standard example, and it's open source if you'd rather run it yourself. The mechanism matters more than the service: silence is the alert, which is exactly what catches a bot that's technically running but has quietly stopped doing anything.
Step 7: test the kill switch before you need it
Every bot needs a way to stop trading immediately, without SSH access being the only lever. The simplest version is a file the bot checks each loop:
from pathlib import Path
HALT_FILE = Path("/etc/trading-bot/HALT")
def should_halt() -> bool:
return HALT_FILE.exists()
When should_halt() is true, the bot should cancel open orders it's responsible for, stop placing new ones, log that it halted and why, and exit cleanly with status 0. That last detail is why Restart=on-failure from Step 2 matters: a clean exit is not a failure, so systemd leaves the service stopped instead of bringing it straight back up mid-halt. With Restart=always the kill switch would look like it worked for about five seconds.
Test this on a schedule, not just once after you write it: sudo touch /etc/trading-bot/HALT, watch the bot log its halt and exit, confirm with systemctl status that it's inactive and staying that way, then remove the file and start it again. A kill switch you've never triggered is a theory, not a control. Do this after every deploy that touches the shutdown path — that code is the least exercised by normal operation.
Back up /etc/trading-bot and the bot's own data directory the same way you'd back up anything else that would hurt to lose — back up your VPS covers what that involves on our machines. A kill switch and a key file are both small, and both are exactly what people forget when "backup" means only the code in git.
On overnight.host
Full disclosure: this is what we sell. A 1 GiB Starter runs a single Python bot process comfortably around the clock; move up a tier once you're running several bots at once, logging heavily, or keeping a local database of fills.
Linux KVM VPS — EUR 4.99 to EUR 59.99 a month, on our own single-tenant bare metal in Dallas, TX and Charlotte, NC. Full hardware virtualisation (KVM), your own kernel, full root. Six tiers, vps-starter to vps-ultra. Starter is 1 vCPU, 1 GiB RAM, 25 GB disk.
You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.
Order vps-starter → · Linux KVM VPS overview
FAQ
How much RAM does a trading bot actually need?
Comfortably under 300 MB for a single bot holding one exchange connection and a modest amount of in-memory state. A 1 GiB VPS covers that with room for the OS and chrony; multiple bots or a growing local database is what pushes you to size up, not the baseline footprint.
Does NAT IPv4 matter for a trading bot?
No, as long as the bot only makes outbound calls to the exchange's REST and websocket endpoints, which is how virtually every exchange API works. NAT and forwarded ports only matter for services that accept inbound connections, and a trading bot doesn't.
Why use chrony instead of the default time sync?
systemd-timesyncd is fine for general use, but chrony tracks and corrects drift more tightly and gives you chronyc tracking to verify the offset. Exchanges that sign requests with a timestamp and a tolerance window reject calls from a clock that's drifted, and that failure looks like a bot bug until you check the clock.
Why did my kill switch stop working after I changed the restart policy?
Almost always because the unit is set to Restart=always instead of Restart=on-failure. Restart=always restarts on any exit, including a clean, deliberate one, so a kill switch that exits with status 0 gets undone within seconds. Restart=on-failure only restarts on a non-zero exit: crashes recover on their own, deliberate halts stay halted.
Is this guide going to tell me what strategy to run?
No. This is entirely about keeping a bot you've already built running reliably and safely on a server — restart behaviour, credentials, clock sync, logging, and a kill switch. What the bot decides to trade, and whether it should, is a decision for you to make with your own risk tolerance, not something to take from a hosting guide.
Written by the person who runs overnight.host: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at up.overnight.host.
Top comments (0)