Host a Discord bot on a VPS with systemd
Stop running your bot in tmux. A proper systemd service for a Node.js or Python Discord bot, with logs, restarts, atomic deploys, and instant rollbacks.

Most people host their first Discord bot in a tmux window on a free-tier cloud VM and wonder why it dies overnight. The fix is twenty lines of systemd. Here is the setup that runs reliably for years.
Sizing
A Discord bot is one of the lightest workloads you can run. Smallest VPS plan is enough for any bot under a few thousand servers.
Layout
/opt/discord-bot/
├── current/ # symlink to active release
├── releases/
│ ├── 2026-04-05-0930/
│ └── 2026-04-05-1445/
├── shared/
│ ├── .env # secrets, never in git
│ └── logs/
Releases-and-symlink layout makes deploys atomic and rollbacks one-line.
Prerequisites
A fresh Ubuntu 24.04 image ships with neither git nor Node.js, and everything below needs both. One note before you run this: the NodeSource line pipes a script from the internet straight into a root shell. That is the vendor's standard install method, but if it bothers you, download the script and read it before running it, or install Ubuntu's own nodejs package instead (currently Node 18, which is fine for discord.js).
sudo apt update
sudo apt install -y git curl
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
Verify the runtime and where the binary landed, because the systemd unit later hardcodes the path:
node -v
command -v node # should print /usr/bin/node
If your node lives somewhere else, either adjust the ExecStart path in the unit to match or use ExecStart=/usr/bin/env node index.js.
Running a Python bot instead? Skip Node.js and install the venv module alongside git: sudo apt install -y git python3-venv.
Service user
sudo useradd -r -m -d /opt/discord-bot -s /usr/sbin/nologin botuser
sudo mkdir -p /opt/discord-bot/{releases,shared/logs}
sudo chown -R botuser:botuser /opt/discord-bot
.env
Resist the temptation to paste your token into an inline tee or echo command. Anything you type at the prompt is recorded in your shell history, and a heredoc also leaves the file world-readable for the moment between creation and a later chmod. Create the file empty with tight permissions first, then add the secret in an editor:
sudo -u botuser install -m 600 /dev/null /opt/discord-bot/shared/.env
sudo -u botuser nano /opt/discord-bot/shared/.env
Put this in it:
DISCORD_TOKEN=your-token-here
NODE_ENV=production
If you have already typed the token on a command line, treat it as exposed: delete the line from ~/.bash_history, run history -c, and regenerate the token in the Discord developer portal to be safe.
GitHub deploy key
The deploy script below clones the repo over SSH as botuser, and GitHub will refuse the connection until it trusts a key belonging to that user. Generate one, and pre-seed GitHub's host key so the first clone doesn't stall on an interactive host-key prompt it can never answer:
sudo -u botuser install -d -m 700 /opt/discord-bot/.ssh
sudo -u botuser ssh-keygen -t ed25519 -f /opt/discord-bot/.ssh/id_ed25519 -N ''
ssh-keyscan github.com | sudo -u botuser tee -a /opt/discord-bot/.ssh/known_hosts
sudo cat /opt/discord-bot/.ssh/id_ed25519.pub
Take the public key that last command prints and add it to the repo on GitHub: Settings, then Deploy keys, then Add deploy key. Leave "Allow write access" unchecked; the deploy only ever reads.
If the repo is public, skip all of this and set REPO_URL in the script below to the HTTPS URL instead, https://github.com/you/your-bot.git. Git clones public repos over HTTPS without any credentials.
Deploy script
One line here deserves a warning before you copy anything: the last command prunes old releases with rm -rf. It permanently and recursively deletes every directory under /opt/discord-bot/releases except the newest five, and there is no undo. If you ever change the path or the variables, preview what would be deleted first by running the ls -1dt /opt/discord-bot/releases/* | tail -n +6 part on its own; whatever that prints is exactly what gets removed.
/usr/local/bin/deploy-bot:
#!/bin/bash
set -euo pipefail
REPO_URL="git@github.com:you/your-bot.git"
REL=$(date +%F-%H%M)
DEST=/opt/discord-bot/releases/$REL
sudo -u botuser git clone --depth 1 "$REPO_URL" "$DEST"
cd "$DEST"
sudo -u botuser ln -sf /opt/discord-bot/shared/.env .env
sudo -u botuser npm ci --production
sudo -u botuser ln -sfn "$DEST" /opt/discord-bot/current
sudo systemctl restart discord-bot
# Keep the last 5 releases.
# WARNING: this permanently and recursively deletes older release
# directories. Double-check the path before editing it; there is no undo.
ls -1dt /opt/discord-bot/releases/* | tail -n +6 | sudo xargs -r rm -rf
sudo chmod +x /usr/local/bin/deploy-bot
systemd unit
Before you copy this, look at the hardening block at the bottom. ProtectSystem=strict makes the entire filesystem read-only to the bot, including its own working directory, except the paths listed in ReadWritePaths, which is currently just shared/logs. If your bot writes anything to disk (a sqlite database, a JSON cache, downloaded images), it will crash with read-only filesystem errors the first time it tries. Add every writable location to that line, for example ReadWritePaths=/opt/discord-bot/shared/logs /opt/discord-bot/shared/data.
/etc/systemd/system/discord-bot.service:
[Unit]
Description=Discord Bot
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=120
StartLimitBurst=5
[Service]
Type=simple
User=botuser
WorkingDirectory=/opt/discord-bot/current
EnvironmentFile=/opt/discord-bot/shared/.env
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=10
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/discord-bot/shared/logs
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
Resist the urge to enable --now at this point. The unit's WorkingDirectory and ExecStart both live under /opt/discord-bot/current, and that symlink does not exist until the deploy script has run once. Start the service now and it fails instantly, retries until it hits the StartLimitBurst cap, and stays dead.
First deploy
Run the deploy script once to create the initial release:
sudo deploy-bot
This clones the repo into a fresh release directory, links in the shared .env, installs dependencies, points current at the new release, and the systemctl restart near the end of the script starts the bot against the unit you just loaded. With the service running, enable it so it comes back after a reboot, then tail the logs to confirm the bot logged in:
sudo systemctl enable discord-bot
sudo journalctl -u discord-bot -f
For Python bots
Same shape. Replace the ExecStart:
ExecStart=/opt/discord-bot/current/.venv/bin/python bot.py
And in the deploy script, swap npm ci for:
sudo -u botuser python3 -m venv .venv
sudo -u botuser .venv/bin/pip install -r requirements.txt
Log management
systemd journal handles this for free. Tail with:
journalctl -u discord-bot -n 200 --no-pager
journalctl -u discord-bot --since "1 hour ago"
journalctl -u discord-bot -p err # errors only
If you really want files, add StandardOutput=append:/opt/discord-bot/shared/logs/bot.log to the service. Rotate with logrotate.
Rollback
sudo -u botuser ln -sfn /opt/discord-bot/releases/2026-04-05-0930 /opt/discord-bot/current
sudo systemctl restart discord-bot
That's the operational setup. Bot survives reboots, restarts on crashes, deploys atomically, and rolls back instantly.
Continue reading