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

> Create a Discord bot in the Developer Portal, set intents and the invite link, write it with discord.py and keep it online 24/7 on a VPS with Docker Compose.

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](https://datapasa.com/en/bot-hosting) page.
- **Docker with the Compose plugin.** Follow [how to install Docker on Ubuntu](https://datapasa.com/wiki/server-management/install-docker-on-ubuntu).
- **SSH access.** See [how to connect via SSH](https://datapasa.com/wiki/server-management/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](https://discord.com/developers/applications) 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**:

| 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**:

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"
```

| 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 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](https://datapasa.com/wiki/guides/deploy-telegram-bot-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](https://datapasa.com/wiki/server-management/secure-vps-ufw-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](https://datapasa.com/en/pricing) page, and the general sizing rules are in [how much RAM your VPS needs](https://datapasa.com/wiki/getting-started/how-much-ram-does-my-vps-need).

## 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](https://datapasa.com/en/bot-hosting) page.
