Deploying a Discord Bot via Systemd
I recently found myself needing to monitor a fluctuating price—the kind that changes frequently enough to warrant automation, yet remains stale enough that constant manual observation would prove tedious. A discord bot offered the appropriate solution: lightweight and accessible from any device. The bot is basically a polling watcher that samples a remote endpoint and emits notifications upon materially relevant deltas.
Continuous operation is a precondition for a watcher that is intended to surface time-sensitive changes, because any monitoring loop that is allowed to pause unpredictably will, by construction, miss precisely the events that occur during the pause. These “pauses” occur routinely on personal machines for reasons that are orthogonal to the watcher’s logic. These machines have to deal with updates, sleeps for power management, suspends, account switching; or issues regarding the network, dependencies, etc. A VPS, by contrast, is designed as an always-on execution substrate with stable networking and predictable uptime characteristics.
When running services on a VPS, the immediate temptation involves familiar interactive tools. One opens an SSH session, starts the program, perhaps wraps it in tmux, and let it run indefinitely. This approach, although fine, ends up facing the same issues as a personal workstation. SSH connections terminate unexpectedly, servers reboot for kernel updates, processes crash at inopportune hours. So we must pair it with a service manager, such as systemd, which supplies process supervision, restart semantics, boot integration and a log sink that is independent of terminal state.
A service that only performs outbound HTTPS requests and sends outbound Discord messages has no requirement for elevated privileges, so the service account should be non-interactive and should own a directory that contains only the files required for execution and local state. The service is placed under /opt to keep it distinct from distribution-managed packages.
sudo apt update
sudo apt install -y python3 python3-venv python3-pip git
sudo useradd -r -m -d /opt/disc_bot -s /usr/sbin/nologin disc_bot
sudo mkdir -p /opt/disc_bot/app
sudo chown -R disc_bot:disc_bot /opt/disc_bot
In the useradd command, -r flag creates a system account, one excluded from login prompts and user listings. The -m flag provisions a home directory at /opt/disc_bot. The -s /usr/sbin/nologin denies interactive login entirely, as this account is to exist purely for service operation.
Place main.py and config.json in /opt/disc_bot/app
Discord bots are authenticated via tokens, so its handling should avoid common leakage paths such as embedding in source files, shell commands that become history, etc. So we’ll create a root-owned environment file, readable only by root, referenced by the unit file via EnvironmentFile=
sudo mkdir -p /etc/disc_bot
sudo nano /etc/disc_bot/secrets.env
# inside secrets.env
DISCORD_TOKEN="PASTE_YOUR_TOKEN_HERE"
sudo chown root:root /etc/disc_bot/secrets.env
sudo chmod 600 /etc/disc_bot/secrets.env
Since the file now belongs to root, when systemd initializes our service, it operates as the disc_bot user but reads the environment file before relinquishing privileges. This design allows the bot process to utilize the token without ever having permissions to read it directly.
Then we create /etc/systemd/system/disc_bot.service.
[Unit]
Description=<...> price bot
After=network-online.target
Wants=network-online.target
The [Unit] section establishes context and dependencies. After=network-online.target instructs systemd to defer service initialization until network connectivity exists. This prevents the bot from launching prematurely and failing immediately due to unreachable API endpoints. Wants=network-online.target expresses a weaker dependency—the service prefers network availability yet will proceed regardless, avoiding indefinite blocking if network targets remain unreachable.
[Service]
Type=simple
User=disc_bot
Group=disc_bot
WorkingDirectory=/opt/disc_bot/app
EnvironmentFile=/etc/disc_bot/secrets.env
Environment=PYTHONUNBUFFERED=1
Environment=DEBUG=1
The [Service] section defines execution parameters. Type=simple makes it so that systemd monitors the process it started and does no require daemonization. User and Group establish the security context. WorkingDirectory determines where the process perceives itself executing, thereby affecting all relative path resolution.
ExecStart=/opt/disc_bot/venv/bin/python /opt/disc_bot/app/main.py
Restart=always
RestartSec=5
ExecStart specifies the command to execute; i.e., it runs the main.py. Restart=always implements automatic recovery. Whenever the process exits—whether from crashes, exceptions, or even clean termination—systemd will reinitialize it after a five-second interval.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/opt/disc_bot/app
NoNewPrivileges, PrivateTmp, ProtectSystem, and ProtectHome reduce incidental access to the host.
And finally we have:
[Install]
WantedBy=multi-user.target
[Install] section defines the boot target to which the service will be attached and governs behaviour during systemctl enable invocations.
So putting all that together, we get:
# /etc/systemd/system/disc_bot.service
[Unit]
Description=Oath price watcher
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=disc_bot
Group=disc_bot
WorkingDirectory=/opt/disc_bot/app
EnvironmentFile=/etc/disc_bot/secrets.env
Environment=PYTHONUNBUFFERED=1
Environment=LOG_LEVEL=INFO
ExecStart=/usr/local/bin/uv run python /opt/disc_bot/app/main.py
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/opt/disc_bot/app
[Install]
WantedBy=multi-user.target
Then we reload unit files, enable at boot, and start immediately:
sudo systemctl daemon-reload
sudo systemctl enable --now disc_bot
Check status.
systemctl status disc_bot
Logs:
journalctl -u disc_bot -f
If the daemon fails to send Discord messages, the journal will contain the exception. If the daemon fails to fetch prices, the journal will contain the HTTP exception.
To update dependencies:
sudo -H -u disc_bot /usr/local/bin/uv init --project /opt/disc_bot/app
sudo -H -u disc_bot /usr/local/bin/uv sync --locked --project /opt/disc_bot/app
sudo -H -u disc_bot /usr/local/bin/uv add aiohttp discord.py --project /opt/disc_bot/app
sudo systemctl restart disc_bot