Discord Bots

Hosting a discord.py Bot: From Local Script to Always-On Server

Turn a discord.py 2.x script into a production bot — project layout, slash commands, cogs, logging, clean shutdowns and deployment to an always-on server.

On this page
  1. Pin your dependencies
  2. A production-ready project layout
  3. The bot class
  4. A cog with a slash command
  5. Sync commands deliberately
  6. Logging you can actually read
  7. Storing data
  8. Deploy it
  9. Troubleshooting
  10. Summary

Most discord.py bots start life as a single bot.py file run from a terminal. That’s fine for experimenting, but a bot other people rely on needs a little more structure: a dependency file, a token that isn’t in the code, logs you can read, and a process that shuts down cleanly when the host restarts it.

This guide walks through that transition for discord.py 2.x and ends with a deployment to an always-on server.

Pin your dependencies

Create a requirements.txt next to your code. Pin discord.py to the 2.x line so a future major release can’t break a deploy:

discord.py>=2.4,<3
aiosqlite>=0.20

Test it in a fresh virtual environment before deploying — that’s the fastest way to catch a package you installed globally and forgot to list:

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python main.py

Kerit Cloud supports Python 3.9 through 3.12, and discord.py 2.x runs on all of them. If you use newer syntax such as match statements or X | Y type unions, make sure the server’s Python version is at least the one you develop on. There’s more on this in Python virtual environments and requirements.txt.

A production-ready project layout

my-bot/
├── cogs/
│   ├── __init__.py
│   └── general.py
├── main.py
├── requirements.txt
└── .gitignore

Cogs keep features in separate files that can be loaded independently. The .gitignore should include .venv/, __pycache__/, .env and any local database file.

The bot class

Subclassing commands.Bot gives you a setup_hook — the right place to load cogs and open resources before the bot connects:

import asyncio
import logging
import os
import signal

import discord
from discord.ext import commands

log = logging.getLogger("bot")


class MyBot(commands.Bot):
    def __init__(self):
        super().__init__(
            command_prefix=commands.when_mentioned,
            intents=discord.Intents.default(),
        )

    async def setup_hook(self):
        await self.load_extension("cogs.general")
        # Close cleanly when the host sends SIGTERM (restarts, redeploys).
        loop = asyncio.get_running_loop()
        loop.add_signal_handler(signal.SIGTERM, lambda: asyncio.create_task(self.close()))

    async def on_ready(self):
        log.info("Ready as %s in %d servers", self.user, len(self.guilds))


bot = MyBot()


@bot.command()
@commands.is_owner()
async def sync(ctx: commands.Context):
    synced = await bot.tree.sync()
    await ctx.send(f"Synced {len(synced)} commands.")


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

A few details are doing real work here:

  • discord.Intents.default() excludes privileged intents. Slash commands don’t need message content, so you avoid enabling it in the Developer Portal. See gateway intents explained.
  • commands.when_mentioned as the prefix means owner commands like @YourBot sync still work without the message content intent, because Discord includes content for messages that mention your bot.
  • The SIGTERM handler. Hosts stop processes with SIGTERM during restarts and redeploys. Without a handler, Python exits immediately and open database connections are cut mid-write. With it, bot.close() runs your cleanup. Graceful shutdowns covers this pattern in more depth.
  • os.environ["DISCORD_TOKEN"] fails loudly with a KeyError if the variable is missing — much easier to diagnose than a vague login failure.

A cog with a slash command

cogs/general.py:

import discord
from discord import app_commands
from discord.ext import commands


class General(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot

    @app_commands.command(name="ping", description="Show the gateway latency.")
    async def ping(self, interaction: discord.Interaction):
        await interaction.response.send_message(f"Pong! {round(self.bot.latency * 1000)} ms")


async def setup(bot: commands.Bot):
    await bot.add_cog(General(bot))

Sync commands deliberately

A common mistake is calling bot.tree.sync() inside on_ready or setup_hook. That re-uploads every command on every restart — and with an auto-restarting host, that can be many times a day. Discord rate-limits command syncing, so eventually syncs start failing.

Instead, sync only when your commands change, using an owner-only command like the sync example above. Global syncs apply everywhere; while developing, you can sync to a single test guild for instant updates. If commands still don’t show up, work through our slash command troubleshooting guide.

Logging you can actually read

bot.run() configures logging for the discord logger by default, printing to standard output. Log your own events through the standard logging module too, so everything lands in the same stream with timestamps:

log.info("Command %s used in guild %s", interaction.command.name, interaction.guild_id)

One Python-specific trap: when output isn’t a terminal, Python buffers print() calls, so log lines can appear late or not at all before a crash. Either use logging (which flushes each record) or start the bot with python -u main.py, or set the environment variable PYTHONUNBUFFERED=1.

Storing data

Writing to a JSON file works until two commands save at the same moment and one overwrites the other. For anything beyond trivial config, use a real database:

  • SQLite with aiosqlite — a single file on disk, no server needed. It persists across restarts on Kerit Cloud because storage is persistent NVMe.
  • MySQL or PostgreSQL — better when data grows, when you run more than one process, or when a dashboard needs access. Every Kerit plan includes a MySQL database, and higher tiers add PostgreSQL.

Whatever you use, open the connection in setup_hook and close it in an overridden close() method so shutdowns are clean.

Deploy it

  1. Push the project to GitHub (the .gitignore keeps secrets and the virtual environment out).
  2. Create a server on Kerit Cloud’s free tier or a Discord bot plan and pick a Python runtime that matches your local version.
  3. Link the repository for git deploys, or upload files through the file manager or SFTP.
  4. Add DISCORD_TOKEN as an environment variable in the panel.
  5. Set the start command to python -u main.py if it isn’t detected automatically.
  6. Start the server and watch the console.

Dependencies install from requirements.txt automatically. Once you see your “Ready as…” log line, run @YourBot sync in a server once, and your slash commands will register.

From then on, pushing to your repository redeploys the bot in under a minute, and a watchdog restarts it within seconds if it crashes. The full workflow is in git deploy for Discord bots.

Troubleshooting

discord.errors.PrivilegedIntentsRequired — your code requests an intent (members, presences or message content) that isn’t enabled in the Developer Portal. Enable it there or remove it from your intents.

ModuleNotFoundError — a package is missing from requirements.txt, or the file wasn’t uploaded. Check the install output at the top of the console.

KeyError: 'DISCORD_TOKEN' — the environment variable isn’t set on the server. Add it in the panel and restart.

The bot answers every command twice — two copies are running, often one on your PC and one on the server. Stop the local one.

Summary

A production discord.py bot pins its dependencies, subclasses commands.Bot to load cogs in setup_hook, requests only default intents, syncs commands on demand rather than on every start, logs through logging, and shuts down cleanly on SIGTERM. Deploy it with a requirements.txt, a DISCORD_TOKEN environment variable and python -u main.py, and it will stay online through restarts and redeploys.