Appearance
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
- Open the Discord Developer Portal and click New Application. Give it a name and accept the terms.
- On the General Information page, copy the Application ID. You need it for the invite link.
- Open the Bot page. Here you can set the bot's username and avatar.
- Click Reset Token, confirm, and copy the token. Discord shows it only once. If you lose it, reset it again and update the server.
- 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:
| Intent | Turn it on if the bot |
|---|---|
| Presence Intent | Needs online status or activity of members |
| Server Members Intent | Tracks member joins and leaves, or needs the full member list |
| Message Content Intent | Reads 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.
Step 3. Create the invite link
Open OAuth2 in the portal and use the URL Generator:
- Under Scopes, tick
botandapplications.commands. The first adds the bot user to a server, the second allows it to register slash commands there. - Under Bot Permissions, tick only what the bot needs. For the example: View Channels, Send Messages and Read Message History.
- 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.commands68608 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-botIf 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.1Create 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, andmessage_content = Trueadds 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 aPrivilegedIntentsRequirederror.setup_hookruns once before the bot connects and registers the/pingslash 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./pingis a slash command,!hellois a classic prefix command that needs the Message Content Intent.- The token is read from the
DISCORD_TOKENenvironment 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 .envDISCORD_TOKEN=PASTE_YOUR_TOKEN_HEREbash
chmod 600 .envCreate compose.yaml:
yaml
services:
bot:
build: .
restart: unless-stopped
env_file: .env
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"| Setting | Purpose |
|---|---|
restart: unless-stopped | Start the bot again after a crash and after a server reboot, unless you stopped it yourself with docker compose stop |
env_file: .env | Pass DISCORD_TOKEN to the container at runtime, so it is not stored in the image |
logging options | Rotate 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 psThe 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 -fShow only the last 100 lines, or the last hour:
bash
docker compose logs --tail 100
docker compose logs --since 1hdiscord.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 -fTo 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.jsindex.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 enableDocker 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.
| Bot | Plan |
|---|---|
| One or two small bots for your own communities | VPS-1 (1 vCPU, 1 GB RAM), $1.81 per month |
| Several bots, a database next to them, or a music bot with FFmpeg | VPS-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 cache | VPS-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.Botwithcommands.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
.envto Git. Add it to.gitignoreas 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
.envand rundocker 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.