Host a Discord bot on your own server

A Discord bot on Debian 13, written with discord.js or discord.py, running as a systemd service under a user of its own, with the token kept out of the code, automatic restarts, hardening and clear logs.

Tested on discord.js 14.27.0 on Node.js 20.19.2 and discord.py 2.7.1 on Python 3.13.5, on Debian 13 (trixie) on a Melonslab server Updated October 2, 2026

Recommended server for this guide

VC-S Micro · 2 vCPU · 8 GB Memory · 250 GB Storage

Month to month, no lock-in 7-day money-back guarantee

€7.99/mo

Deploy now
On this page

What you will set up

A Discord bot that runs day and night on your server instead of on your own computer. The example bot answers a slash command, /ping, and is small enough to grow into your own. You write it in JavaScript with discord.js or in Python with discord.py: pick one below, and every step shows the commands for it.

Runtime

The bot runs as a systemd service under a user of its own, discordbot. systemd starts it at boot, restarts it if it crashes, and keeps its output in the journal. The token, the bot's password, sits in a file only root can read.

A bot like this needs no open ports. It connects out to Discord's gateway and keeps that connection open, and Discord sends commands and events down it. So the firewall stays as it is, with only SSH open.

discord.js and discord.py are open source community projects: discord.js under the Apache 2.0 licence, and discord.py under the MIT licence, started by the developer known as Rapptz. Discord itself is run by Discord Inc., a US company, and every message and command your bot handles passes through Discord's servers.

Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13, once with each runtime:

  • The bot logged in, registered /ping in a test server, and answered it.
  • With a wrong token the service stopped after one try with Login failed in the log, instead of restarting over and over.
  • The bot reconnected by itself after its connection to Discord was cut for two minutes, and started again by itself after a reboot.
  • The hardening options in step 6 left the bot working, and nothing was listening on any port.
  • A wrong server ID and a privileged intent that was not switched on each gave a clear error in the log.

Idle in one server, the bot used about 50 MB of memory with Node.js and 35 MB with Python.

Before you start

You need:

  • a server with Debian 13, secured as in steps 1 to 4 of the security guide, with ufw switched on and only SSH allowed. Skip the HTTP and HTTPS rules: the bot does not need them;
  • a Discord account, and a Discord server where you may add bots. A test server of your own is the easiest: in Discord, choose + in the server list.

The steps on Discord's side follow Discord's own getting started guide. Our test bot was made this way, but Discord changes its Developer Portal now and then, so the labels may differ a little.

1. Create the bot in Discord

In your browser, open the Developer Portal and log in with your Discord account.

  1. Choose Create App, give it a name and choose Create. The name is what people see in the member list.
  2. Open the Bot page and choose Reset Token. Discord shows the token once: copy it and keep it somewhere safe, such as a password manager, until step 5. Anyone with the token can act as your bot, so never put it in your code, a screenshot or a Git repository.
  3. On the same page, leave the three Privileged Gateway Intents switched off. A bot that only answers slash commands needs none of them. You only need Message Content Intent if the bot reads the text of ordinary messages, and you then also have to ask for it in the code.
  4. Open the Installation page. Under Installation Contexts, make sure Guild Install is ticked. Under Default Install Settings, add the scopes applications.commands and bot. When you add bot, a Permissions list appears: choose View Channels and Send Messages, and nothing more. Then Save Changes.
  5. Copy the Install Link from the same page, open it, choose Add to server, pick your server and confirm. The bot shows up in the member list, offline until step 6.

Last, you need the server's ID. In Discord, open User Settings with the cogwheel, then Advanced, and switch on Developer Mode. Right-click your server in the server list and choose Copy Server ID.

2. Install the runtime

As root:

With Node.js

apt update
apt install -y --no-install-recommends nodejs npm
node --version
v20.19.2

--no-install-recommends keeps out compilers and build tools that npm only needs for some packages, and saves about 760 MB. discord.js needs Node.js 18 or newer, so Debian's version 20 works, and its updates come from Debian, through the automatic updates from the security guide.

With Python

Debian 13 has Python 3.13 already. Add the module that creates virtual environments:

apt update
apt install -y python3-venv
python3 --version
Python 3.13.5

discord.py is installed with pip in step 4, in a virtual environment of the bot's own, so it does not mix with Debian's Python packages.

3. Create the user

The bot runs as discordbot, a system user that cannot log in. Its home directory, /opt/discordbot, holds the code:

useradd --system --create-home --home-dir /opt/discordbot --shell /usr/sbin/nologin discordbot

If someone finds a hole in your bot or one of its libraries, they get this user, not root, and step 6 limits what it can do further.

4. Write the bot

With Node.js

Install discord.js as discordbot:

cd /opt/discordbot
runuser -u discordbot -- npm install discord.js

Create /opt/discordbot/bot.js:

const { Client, Events, GatewayIntentBits, SlashCommandBuilder } = require('discord.js');

// Slash commands need no privileged intents. Guilds is enough.
const client = new Client({ intents: [GatewayIntentBits.Guilds] });

const ping = new SlashCommandBuilder()
  .setName('ping')
  .setDescription('Check that the bot answers');

client.once(Events.ClientReady, async (c) => {
  console.log(`Logged in as ${c.user.tag}`);
  // Register the commands for one server. They appear there at once.
  await c.application.commands.set([ping], process.env.GUILD_ID);
  console.log('Registered /ping');
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand() || interaction.commandName !== 'ping') return;
  console.log(`/ping from ${interaction.user.username}`);
  // The latency is -1 until the first heartbeat, about 40 seconds after login.
  const ms = client.ws.ping;
  await interaction
    .reply(ms >= 0 ? `Pong! Gateway latency: ${ms} ms` : 'Pong! (latency not measured yet)')
    .catch((err) => console.error(`Reply failed: ${err.message}`));
});

client.login(process.env.DISCORD_TOKEN).catch((err) => {
  console.error(`Login failed: ${err.message}`);
  // 78 tells systemd not to restart: a wrong token stays wrong.
  process.exit(err.code === 'TokenInvalid' ? 78 : 1);
});

With Python

Create the virtual environment and install discord.py in it, as discordbot:

cd /opt/discordbot
runuser -u discordbot -- python3 -m venv venv
runuser -u discordbot -- venv/bin/pip install discord.py

Create /opt/discordbot/bot.py:

import math
import os
import sys

import discord
from discord import app_commands

guild = discord.Object(int(os.environ["GUILD_ID"]))


class Bot(discord.Client):
    def __init__(self):
        # Slash commands need no privileged intents. Guilds is enough.
        super().__init__(intents=discord.Intents(guilds=True))
        self.tree = app_commands.CommandTree(self)

    async def setup_hook(self):
        # Register the commands for one server. They appear there at once.
        await self.tree.sync(guild=guild)
        print("Registered /ping", flush=True)

    async def on_ready(self):
        print(f"Logged in as {self.user}", flush=True)


bot = Bot()


@bot.tree.command(guild=guild, description="Check that the bot answers")
async def ping(interaction: discord.Interaction):
    print(f"/ping from {interaction.user.name}", flush=True)
    # The latency is unknown until the first heartbeat, about 40 seconds after login.
    if math.isfinite(bot.latency):
        text = f"Pong! Gateway latency: {round(bot.latency * 1000)} ms"
    else:
        text = "Pong! (latency not measured yet)"
    await interaction.response.send_message(text)


try:
    bot.run(os.environ["DISCORD_TOKEN"])
except (discord.LoginFailure, discord.PrivilegedIntentsRequired) as err:
    print(f"Login failed: {err}", file=sys.stderr)
    # 78 tells systemd not to restart: a wrong token stays wrong.
    sys.exit(78)

The bot reads its token and your server's ID from the environment, so neither is in the code. It registers /ping for your server only, each time it starts. Commands for one server appear there at once, and registering the same commands again does not count towards Discord's limit of 200 new commands a day. To offer the command in every server the bot joins, register it without the server ID; Discord calls those global commands.

The last lines turn a wrong token into exit code 78, which step 6 tells systemd not to restart. Every other failure, such as a network problem at start, gets a new try.

When the connection to Discord drops, both libraries reconnect by themselves. In the Node.js version, the .catch after reply matters: without it, a reply that fails during a network problem stops the whole bot. In our test systemd then started it again, but a running bot is better. discord.py catches such errors in commands by itself and writes them to the log.

5. Store the token

Create the file empty and readable only by root, then open it:

install -m 600 /dev/null /etc/discord-bot.env
nano /etc/discord-bot.env

Add the token from step 1 and the server ID, with your own values:

DISCORD_TOKEN=paste-the-token-here
GUILD_ID=123456789012345678

systemd reads the file as root when it starts the bot and hands the values to it, so the discordbot user cannot read the file itself.

6. Run it as a service

With Node.js

Create /etc/systemd/system/discord-bot.service:

[Unit]
Description=Discord bot
Wants=network-online.target
After=network-online.target

[Service]
User=discordbot
WorkingDirectory=/opt/discordbot
EnvironmentFile=/etc/discord-bot.env
ExecStart=/usr/bin/node bot.js
Restart=on-failure
RestartSec=5
# Wait longer after each failure in a row, up to 5 minutes.
RestartSteps=6
RestartMaxDelaySec=300
# Exit code 78 means a wrong token: stop instead of retrying.
RestartPreventExitStatus=78

# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
CapabilityBoundingSet=
SystemCallArchitectures=native
SystemCallFilter=@system-service @pkey
SystemCallFilter=~@privileged
UMask=0077

[Install]
WantedBy=multi-user.target

With Python

Create /etc/systemd/system/discord-bot.service:

[Unit]
Description=Discord bot
Wants=network-online.target
After=network-online.target

[Service]
User=discordbot
WorkingDirectory=/opt/discordbot
EnvironmentFile=/etc/discord-bot.env
ExecStart=/opt/discordbot/venv/bin/python bot.py
Restart=on-failure
RestartSec=5
# Wait longer after each failure in a row, up to 5 minutes.
RestartSteps=6
RestartMaxDelaySec=300
# Exit code 78 means a wrong token: stop instead of retrying.
RestartPreventExitStatus=78

# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
CapabilityBoundingSet=
SystemCallArchitectures=native
SystemCallFilter=@system-service @pkey
SystemCallFilter=~@privileged
UMask=0077

[Install]
WantedBy=multi-user.target

What the lines do:

  • Restart=on-failure starts the bot again when it crashes. RestartSec, RestartSteps and RestartMaxDelaySec wait 5 seconds after the first crash, then longer after each one in a row, up to 5 minutes, so a bot that cannot start does not hammer Discord. RestartPreventExitStatus=78 stops the retries for a wrong token.
  • ProtectSystem=strict makes the whole file system read-only for the bot, its own code included, and ProtectHome hides /home and /root. If your bot needs to write files, such as a small database, allow one directory with StateDirectory=discordbot, which gives it /var/lib/discordbot.
  • RestrictAddressFamilies lets it open internet connections and nothing else, CapabilityBoundingSet= takes away every root power, and SystemCallFilter blocks system calls a bot has no use for. @pkey is there for Node.js, which needs memory protection keys and is stopped without them.

Start it, and make it start at boot:

systemctl daemon-reload
systemctl enable --now discord-bot
systemctl status discord-bot
● discord-bot.service - Discord bot
     Loaded: loaded (/etc/systemd/system/discord-bot.service; enabled; preset: enabled)
     Active: active (running) since Fri 2026-10-02 19:01:26 CEST; 12s ago

To see how far the hardening goes, systemd-analyze security discord-bot rates the service. Ours scored 1.4 on its scale from 0 to 10, which it calls OK. Lower is better.

7. Check that it answers

The log shows the bot logging in:

journalctl -u discord-bot -n 20

With Node.js

Logged in as MyBot#4850
Registered /ping

With Python

[2026-10-02 19:01:27] [INFO    ] discord.client: logging in using static token
Registered /ping
[2026-10-02 19:01:28] [INFO    ] discord.gateway: Shard ID None has connected to Gateway (Session ID: 3878203fc3aeef7e07dc8f9a19e5dfce).
Logged in as MyBot#4850

In Discord, the bot now shows as online. Type /ping in a channel and pick the bot's command: it answers with Pong! and its latency, and the log gets a line /ping from with your name.

From our server in Sweden the latency was about 130 ms. Discord's gateway address leads to a Cloudflare edge only a few milliseconds away, so most of that is the round trip on from there to Discord's own gateway servers. The number is measured once per heartbeat, about every 41 seconds, so pings close together show the same value, and right after a start the bot answers that it has not measured it yet. It only affects how quickly the bot sees a command, not whether it works.

Check that nothing listens for connections:

ss -tlnp | grep -E 'node|python'

This prints nothing. The bot's connection to Discord goes out from the server, so ufw lets it through without a rule.

8. Read the logs

Everything the bot prints ends up in the journal. To follow it live, and to see the last hour:

journalctl -u discord-bot -f
journalctl -u discord-bot --since "1 hour ago"

The journal keeps old logs until it reaches its size limit, so you do not need to clean up after the bot. The /ping from lines contain Discord user names: leave such lines out if you do not need them.

9. Update

Debian's automatic updates keep Node.js or Python patched. The library you update yourself. Discord changes its API now and then, and an old library can stop working, so check for updates every month or so:

With Node.js

cd /opt/discordbot
runuser -u discordbot -- npm outdated
runuser -u discordbot -- npm update
systemctl restart discord-bot

npm update stays within discord.js 14. A new major version can change how bots are written: read its upgrade guide before you move.

With Python

cd /opt/discordbot
runuser -u discordbot -- venv/bin/pip list --outdated
runuser -u discordbot -- venv/bin/pip install --upgrade discord.py
systemctl restart discord-bot

After you change the code, restart the bot the same way. The only files to back up are your code in /opt/discordbot and /etc/discord-bot.env. The installed libraries can always be fetched again.

Troubleshooting

The service is failed with status=78/CONFIG, and the log shows Login failed: An invalid token was provided. or Login failed: Improper token has been passed. The token is wrong, or Discord has reset it. Discord resets a token by itself when it finds it published, for example in a public Git repository. Make a new one with Reset Token on the Bot page, put it in /etc/discord-bot.env, and run systemctl restart discord-bot.

The log shows Login failed: Used disallowed intents (Node.js) or Login failed: Shard ID None is requesting privileged intents (Python). The code asks for a privileged intent, such as message content, that is switched off on the Bot page. Switch it on there, or take it out of the code, and restart the bot. The Python bot stops with exit code 78. discord.js gives this error no code of its own, so the Node.js bot keeps trying, with up to 5 minutes between tries.

The service fails with status=31/SYS. The system call filter stopped the bot. This happens when you leave out @pkey for Node.js, or when a library needs a system call the filter blocks. journalctl -k | grep audit shows the number of the system call, and systemd-analyze syscall-filter lists which group it is in.

The bot logs in, then stops with DiscordAPIError[50001]: Missing Access. It could not register /ping in the server in GUILD_ID. Either the ID is wrong, or the bot was added without the applications.commands scope: copy the server ID again, or open the Install Link from step 1 again, and restart the bot. Until then it keeps trying, with up to 5 minutes between tries.

Discord says "The application did not respond". The bot did not answer within three seconds. Check systemctl status discord-bot: the bot may be restarting, or reconnecting after a network problem, which took it less than 15 seconds after the network came back in our tests. A command that needs longer than three seconds has to tell Discord it is working first, with interaction.deferReply() in discord.js or interaction.response.defer() in discord.py.

Run it on your own server

VC-S Micro

€7.99/mo

vCPU
2
Memory
8 GB
Storage
250 GB
Transfer
10 TB
Standard
HDD · RAID 10
  • Full root access
  • Native /64 IPv6
  • RAID-protected storage
  • Malmö, Sweden
  • Month to month, no lock-in
  • 7-day money-back guarantee
All guides