Skip to content

How to host a Discord bot 24/7 on a VPS ​

A Discord bot only answers while its process is connected to the Discord gateway. Run it on a laptop and it goes offline when the lid closes. A small VPS keeps it connected day and night, restarts it after crashes and survives reboots. This guide goes through the Discord side first (application, token, intents, invite link), then builds a minimal bot with discord.py and runs it with Docker Compose. A short discord.js version is included for Node.js users.

What you need ​

  • A VPS with Ubuntu 24.04. A small bot needs very little memory, see the sizing table below. DataPasa servers are KVM virtual machines with NVMe storage in Falkenstein, Germany. Plans are on the bot hosting page.
  • Docker with the Compose plugin. Follow how to install Docker on Ubuntu.
  • SSH access. See how to connect via SSH.
  • A Discord account and a server (guild) where you have the Manage Server permission, so you can add the bot.

How a Discord bot connects ​

A bot opens an outgoing WebSocket connection to the Discord gateway and receives events through it: new messages, slash command calls, members joining. Replies go out over HTTPS to the Discord API. Nothing connects to your server from outside, so a bot built this way needs no open inbound ports, no domain and no TLS certificate. Only bots that use the optional interactions endpoint URL (HTTP webhooks instead of the gateway) need a public web server, and this guide does not use one.

Step 1. Create the application and the bot ​

  1. Open the Discord Developer Portal and click New Application. Give it a name and accept the terms.
  2. On the General Information page, copy the Application ID. You need it for the invite link.
  3. Open the Bot page. Here you can set the bot's username and avatar.
  4. Click Reset Token, confirm, and copy the token. Discord shows it only once. If you lose it, reset it again and update the server.
  5. If the bot is only for your own servers, turn off Public Bot so nobody else can add it.

The token is the bot's password. Whoever has it can log in as the bot and do everything the bot is allowed to do in every server it has joined.

Step 2. Enable the intents you need ​

Intents decide which events Discord sends to the bot. Most are enabled in code, but three are privileged and must also be switched on in the portal, on the Bot page under Privileged Gateway Intents:

IntentTurn it on if the bot
Presence IntentNeeds online status or activity of members
Server Members IntentTracks member joins and leaves, or needs the full member list
Message Content IntentReads the text of messages, for example for prefix commands like !hello or for moderation

Without the Message Content Intent the bot still receives messages, but the text, embeds and attachments arrive empty. The exceptions are direct messages to the bot, messages that mention it and messages the bot sent itself. Slash commands do not need this intent at all, which is one reason Discord recommends them.

The example bot below has a slash command and one prefix command, so turn on Message Content Intent. Leave the other two off unless you need them.

Bots in 100 servers or more

Once a bot reaches 100 servers it must be verified by Discord, and privileged intents then need separate approval. Design new bots around slash commands so they keep working without Message Content access.

Open OAuth2 in the portal and use the URL Generator:

  1. Under Scopes, tick bot and applications.commands. The first adds the bot user to a server, the second allows it to register slash commands there.
  2. Under Bot Permissions, tick only what the bot needs. For the example: View Channels, Send Messages and Read Message History.
  3. Copy the generated URL, open it in a browser, pick your server and confirm.

The URL looks like this, with your Application ID in place of APP_ID:

https://discord.com/oauth2/authorize?client_id=APP_ID&permissions=68608&scope=bot+applications.commands

68608 is the sum of the three permissions above. Avoid Administrator: if the token ever leaks, a bot with that permission can delete channels and ban members in every server it is in.

Step 4. Write the bot with discord.py ​

Connect to the server and create a folder for the project:

bash
ssh root@YOUR_SERVER_IP
mkdir -p /opt/discord-bot && cd /opt/discord-bot

If your bot already lives in a Git repository, clone it into this folder instead and skip to Step 5.

Create requirements.txt. Pinning the version makes rebuilds predictable:

discord.py==2.7.1

Create bot.py:

python
import os

import discord
from discord.ext import commands

intents = discord.Intents.default()
intents.message_content = True


class Bot(commands.Bot):
    async def setup_hook(self) -> None:
        synced = await self.tree.sync()
        print(f"Synced {len(synced)} slash commands")


bot = Bot(command_prefix="!", intents=intents)


@bot.event
async def on_ready() -> None:
    print(f"Logged in as {bot.user} in {len(bot.guilds)} servers")


@bot.tree.command(description="Check that the bot is online")
async def ping(interaction: discord.Interaction) -> None:
    await interaction.response.send_message(f"Pong! Gateway latency {round(bot.latency * 1000)} ms")


@bot.command()
async def hello(ctx: commands.Context) -> None:
    await ctx.send(f"Hello, {ctx.author.display_name}!")


bot.run(os.environ["DISCORD_TOKEN"])

What the code does:

  • discord.Intents.default() enables all intents that are not privileged, and message_content = True adds the one you switched on in the portal. If the code asks for a privileged intent that is off in the portal, the connection fails with a PrivilegedIntentsRequired error.
  • setup_hook runs once before the bot connects and registers the /ping slash command with Discord. Syncing on every start is fine for a small bot. For a larger one, sync only when the list of commands changes, because the sync endpoint is rate limited.
  • /ping is a slash command, !hello is a classic prefix command that needs the Message Content Intent.
  • The token is read from the DISCORD_TOKEN environment variable, never written in the code.

Step 5. Package it with Docker ​

Create a Dockerfile:

dockerfile
FROM python:3.12-slim

ENV PYTHONUNBUFFERED=1
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
RUN useradd --create-home bot
USER bot

CMD ["python", "bot.py"]

PYTHONUNBUFFERED=1 makes print output appear in the logs immediately. The bot runs as an unprivileged bot user inside the container.

Create .dockerignore so the token file and Git history never end up inside the image:

.env
.git
__pycache__

Put the token in .env and make the file readable only by root:

bash
nano .env
DISCORD_TOKEN=PASTE_YOUR_TOKEN_HERE
bash
chmod 600 .env

Create compose.yaml:

yaml
services:
  bot:
    build: .
    restart: unless-stopped
    env_file: .env
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
SettingPurpose
restart: unless-stoppedStart the bot again after a crash and after a server reboot, unless you stopped it yourself with docker compose stop
env_file: .envPass DISCORD_TOKEN to the container at runtime, so it is not stored in the image
logging optionsRotate the log file at 10 MB and keep three files, so logs never fill the disk

There is no ports: section. The bot does not listen on anything.

Step 6. Start the bot ​

bash
docker compose up -d --build
docker compose ps

The status should be running or Up. In Discord, type /ping in a channel the bot can see. If the slash command does not show up right away, restart your Discord client. Then try !hello.

Logs ​

Follow the output live:

bash
docker compose logs -f

Show only the last 100 lines, or the last hour:

bash
docker compose logs --tail 100
docker compose logs --since 1h

discord.py logs its own connection events, such as gateway reconnects and rate limits, to the same output. Short reconnects from time to time are normal: Discord asks bots to reconnect during maintenance, and the library handles it automatically.

Update the bot ​

When your code changes, pull it and rebuild. The old container keeps running until the new image is ready:

bash
cd /opt/discord-bot
git pull
docker compose up -d --build
docker image prune -f

To update discord.py, change the version in requirements.txt and rebuild the same way. Read the library changelog before jumping to a new major version.

The same bot in Node.js with discord.js ​

If you prefer JavaScript, the Docker setup stays the same and only the code and the image change. discord.js 14 requires Node.js 18 or newer. With Node.js installed on your computer or the server, create the project and install the library. npm install also writes the package-lock.json that the image build needs:

bash
npm init -y
npm install discord.js

index.js with the same /ping command:

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

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

client.once(Events.ClientReady, async (readyClient) => {
  await readyClient.application.commands.set([
    { name: 'ping', description: 'Check that the bot is online' },
  ]);
  console.log(`Logged in as ${readyClient.user.tag}`);
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  if (interaction.commandName === 'ping') {
    await interaction.reply(`Pong! Gateway latency ${client.ws.ping} ms`);
  }
});

client.login(process.env.DISCORD_TOKEN);

This version uses only the Guilds intent, so it does not need any privileged intent in the portal. The Dockerfile:

dockerfile
FROM node:22-slim

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .
USER node

CMD ["node", "index.js"]

Add node_modules to .dockerignore. The compose.yaml and .env from Step 5 work unchanged.

Running it with systemd instead ​

Docker is convenient when you run several bots or want the same environment everywhere. A single bot also runs fine as a plain systemd service with a virtual environment (Python) or a system Node.js install. The setup is described step by step in how to deploy a Telegram bot with systemd. Only two things change for Discord: the env file holds DISCORD_TOKEN instead of the Telegram token, and pip install installs discord.py instead of aiogram.

Close all inbound ports ​

Because the bot only makes outgoing connections, the firewall can block every inbound port except SSH:

bash
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw enable

Docker publishes container ports past UFW rules, but this setup publishes none, so nothing is exposed. For SSH hardening and Fail2ban, see how to secure your VPS with UFW and Fail2ban.

How much RAM the bot needs ​

Memory grows mostly with the number of servers and members the bot caches, not with the number of commands.

BotPlan
One or two small bots for your own communitiesVPS-1 (1 vCPU, 1 GB RAM), $1.81 per month
Several bots, a database next to them, or a music bot with FFmpegVPS-2 (1 vCPU, 2 GB RAM), $3.54 per month
A public bot in thousands of servers, or with the Server Members Intent and a large member cacheVPS-3 (2 vCPU, 4 GB RAM), $7.09 per month

Check real usage with docker stats --no-stream once the bot has been running for a while. Billing is hourly from a prepaid balance, so moving to a larger plan later costs only the hours you use it. All plans are on the pricing page, and the general sizing rules are in how much RAM your VPS needs.

Sharding for large bots ​

One gateway connection (a shard) can serve at most 2,500 servers. When a bot reaches that number, Discord requires it to split into several shards. Both libraries handle it for you:

  • discord.py: replace commands.Bot with commands.AutoShardedBot. Discord tells the library how many shards to open, and all of them run in one process.
  • discord.js: use the ShardingManager, which starts each shard as a separate Node.js process. Plan RAM for every process.

Most bots never reach this size, so start without sharding and switch when the bot approaches the limit.

Keep the token secret

  • Never commit .env to Git. Add it to .gitignore as well as .dockerignore.
  • Do not paste the token into screenshots, support chats or public code. Discord may reset tokens it finds in public GitHub repositories, which takes the bot offline until you update it.
  • If the token leaks, click Reset Token on the Bot page at once. The old token stops working immediately. Put the new one in .env and run docker compose up -d.

Summary ​

Create the application in the Developer Portal, enable only the intents the bot uses, invite it with the bot and applications.commands scopes and minimal permissions. On the server, keep the token in a protected .env file and let Docker Compose with restart: unless-stopped keep the bot running around the clock. No inbound ports are needed, so the firewall stays closed except for SSH. A plan for one or many bots is on the bot hosting page.