RE:NODE
Browse hosting

App hosting13 min read

Hosting a discord.py bot: setup, cogs, intents and deploys

Running a discord.py bot on a server: pinning requirements, intents, cogs and setup_hook, syncing the command tree, and the blocked event loop that kills bots.

0 readers

A discord.py bot is a Python process holding one WebSocket open to Discord. Hosting it is not complicated, but it fails in a small number of specific ways: the wrong library installed under the same import name, a privileged intent that was never switched on in the Developer Portal, an extension that failed to load and took the bot down with it, and - by a wide margin the most common - a synchronous call that blocks the event loop until Discord gives up on the heartbeat.

This guide covers the version of the library to install, what belongs in requirements.txt, how setup_hook and cogs fit together, how to sync the application command tree without getting rate limited, and what the start command on a server should look like. The general principles of keeping a bot resident - outbound connection only, no inbound port, restart on exit - are the same as for a Node bot and are set out in how to host a Discord bot 24/7. Memory and CPU are in how much RAM and CPU a Discord bot needs.

Choosing the library, and the Python version#

Three libraries share one import name. import discord may be discord.py, py-cord or the legacy discord stub package on PyPI, and pip will happily install more than one of them into the same environment, at which point the files overwrite each other and the errors stop making sense.

PackageImportNotes
discord.pydiscordThe original. Version 2.x has app commands and cogs
py-corddiscordA fork with its own decorators for slash commands
nextcordnextcordA fork that renamed itself, so it can coexist
discorddiscordA stub that pulls in discord.py. Do not depend on it

If your imports have gone strange, the fix is to uninstall all of them and install exactly one:

bash
$ pip uninstall -y discord.py py-cord discord$ pip install -U discord.py

The two APIs are not interchangeable. py-cord uses @bot.slash_command and discord.Option; discord.py uses @bot.tree.command, @app_commands.command and discord.app_commands.describe. Code copied from a tutorial written for one will not run on the other, and the resulting AttributeError reads like a broken install rather than a wrong library. Decide which one you are using, pin it, and check that any snippet you paste matches.

On the Python version: discord.py 2.x needs Python 3.8 or newer. Use 3.11 or 3.12 unless you have a reason not to. Python 3.13 removed the standard-library audioop module that the voice support relied on, so older discord.py releases fail to import at all on 3.13, and newer ones pull in a backport package instead. If your bot plays audio, pin the interpreter version deliberately and read your library's changelog before moving up.

requirements.txt and installing on a server#

A server install has no interactive shell and no chance to answer a prompt. Everything it needs is in one file.

requirements.txt
# example pins - use the versions you actually tested againstdiscord.py==2.5.2python-dotenv==1.1.1aiosqlite==0.21.0

Pin exact versions with ==. The alternative - unpinned or >= - means the server installs whatever was released this morning, and a bot that has not changed in six weeks breaks on a restart. Pinning also makes the failure reproducible: you can install the same set locally and see the same error. Python requirements and virtualenvs goes through pinning tools properly; pip freeze is a blunt starting point because it records the whole transitive tree, including things you did not ask for.

The start command on an application server typically installs and then runs:

bash
pip install --no-cache-dir -r requirements.txt && python -u bot.py

Two flags earn their place. --no-cache-dir stops pip keeping a wheel cache that serves no purpose on a container that reinstalls from scratch anyway, and on a 5 GB plan that cache is a real fraction of the disk. python -u makes stdout unbuffered, which is the difference between seeing your log lines in the console as they happen and seeing nothing for several minutes until a buffer fills. Setting PYTHONUNBUFFERED=1 as an environment variable does the same thing.

If you need voice, install discord.py[voice], which adds PyNaCl, and make sure the ffmpeg binary exists in the container - the library shells out to it and fails at playback time, not at import time, so this is a mistake you discover in front of an audience.

The bot, its intents and setup_hook#

Intents are the subscription you declare at connect time. discord.Intents.default() gives you everything except the three privileged ones, which have to be enabled on the Bot tab of the Developer Portal as well as in your code.

bot.py
import osimport discordfrom discord.ext import commandsintents = discord.Intents.default()intents.message_content = True   # privileged: needed for prefix commandsintents.members = False          # privileged: the expensive oneclass Bot(commands.Bot):    def __init__(self):        super().__init__(command_prefix="!", intents=intents)    async def setup_hook(self):        for extension in ("cogs.moderation", "cogs.levels"):            await self.load_extension(extension)bot = Bot()bot.run(os.environ["DISCORD_TOKEN"], log_handler=None)

setup_hook runs once, after login but before the bot is ready, and it is where asynchronous start-up work belongs: loading extensions, opening a database pool, registering persistent views. It replaces the pattern of doing work inside on_ready, which is wrong in a way that is easy to miss - on_ready can fire more than once, because it fires again after a reconnect that could not be resumed. Anything you do there happens twice.

Two intent details cause most of the confusion. If message_content is off, prefix commands silently do nothing, because the bot receives the message event with an empty content field; discord.py logs a warning about the missing privileged intent on start-up, and that warning is the whole answer. If members is on, the library will chunk the member list of every guild at connection, which is slow and memory-hungry on a bot in large servers - pass chunk_guilds_at_startup=False unless you actually need the cache.

Cogs: splitting the bot up without breaking it#

A cog is a class that groups commands, listeners and state. An extension is the module that loads it. In discord.py 2.x both the module's setup function and load_extension are asynchronous, which is the single biggest difference from 1.x tutorials.

cogs/moderation.py
import discordfrom discord.ext import commandsfrom discord import app_commandsclass Moderation(commands.Cog):    def __init__(self, bot: commands.Bot):        self.bot = bot    @app_commands.command(description="Remove recent messages")    @app_commands.describe(count="How many messages, 1-100")    async def purge(self, interaction: discord.Interaction, count: int):        await interaction.response.defer(ephemeral=True)        deleted = await interaction.channel.purge(limit=count)        await interaction.followup.send(f"Deleted {len(deleted)}.")async def setup(bot: commands.Bot):    await bot.add_cog(Moderation(bot))

The practical rules for cogs on a server:

  • A failing extension stops the bot. An exception raised in setup propagates out of load_extension, and if that call is in setup_hook with nothing around it, the process exits. If the bot must survive one broken cog, wrap each load in a try and log the failure loudly rather than letting one module take the rest down.
  • Reloading is for development. await bot.reload_extension("cogs.levels") re-imports the module, which is useful locally and a trap in production: objects created by the old version stay alive in listeners and tasks you did not clean up. On a server, restart instead.
  • Clean up in `cog_unload`. A tasks.loop started by a cog keeps running after the cog goes away unless you cancel it there.
  • Background loops need `before_loop`. A loop that talks to Discord must wait for the bot to be ready first, or its first iteration runs against a client with no cache.
python
from discord.ext import tasks@tasks.loop(minutes=15)async def sweep(self):    ...@sweep.before_loopasync def before_sweep(self):    await self.bot.wait_until_ready()

Slash commands and syncing the tree#

Application commands live on Discord's side. bot.tree holds your local definitions, and sync uploads them. Nothing happens until you sync, and syncing is a deployment action rather than a start-up one.

python
# Development: instant, one guildGUILD = discord.Object(id=123456789012345678)bot.tree.copy_global_to(guild=GUILD)await bot.tree.sync(guild=GUILD)# Production: global, propagates within about an hourawait bot.tree.sync()

Guild commands appear immediately, which is why every tutorial uses them. Global commands are documented as taking up to an hour to propagate, so a missing command right after a global sync is usually just early. A sync replaces the entire set for that scope, so a command you deleted from the code disappears on the next sync, and a partial list wipes the rest.

Do not call sync() in on_ready or setup_hook. Discord limits how many commands you can create per day, and a bot that syncs on every start while stuck in a restart loop can lock itself out for the day. The common pattern is an owner-only prefix command that syncs on demand, so a deploy that did not change commands costs nothing:

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

If a sync fails with a validation error, read the field path: names must be lowercase, 1-32 characters, with no spaces, and descriptions are 1-100 characters. Hybrid commands - @commands.hybrid_command() - register as both a prefix command and a slash command from one function, which is the least painful way to support both without writing everything twice.

Never block the event loop#

This is the section that matters most in production, and it is the one that is missing from most tutorials.

discord.py runs on asyncio. One thread, one loop, interleaving coroutines. Every await is a point where the loop can do something else, including sending the heartbeat that keeps the gateway connection alive. A synchronous call does not have that point, so while it runs, nothing else does - not your other commands, not the heartbeat.

The symptom is a log line from the library:

code
WARNING discord.gateway Heartbeat blocked for more than 10 seconds.

Followed, if it goes on long enough, by Discord closing the socket and the bot reconnecting. Users see a bot that "randomly goes offline" on a server whose CPU graph looks fine.

The usual culprits, and what to use instead:

BlockingUse instead
requests.get(...)aiohttp, already a dependency
time.sleep(5)await asyncio.sleep(5)
open(...).read() on a large fileawait asyncio.to_thread(...)
A synchronous database driverasyncpg, aiosqlite, motor
Image processing, zipping, parsingawait asyncio.to_thread(...)

asyncio.to_thread (Python 3.9 and later) runs a function in a worker thread and awaits the result, which is the one-line fix for CPU-light but slow work. Genuinely CPU-heavy work should not be on the bot at all; push it to a queue and a separate worker, as in background jobs on a small server.

The same discipline applies to rate limits. discord.py handles 429s for you by waiting out retry_after, but a loop that edits a message every second, or a command that sends fifty messages in a burst, will spend most of its time waiting. Batch what you can, and never put an API call inside a tight loop over a member list.

Running it on a host#

The deployment shape for a Python bot is the same as for any long-lived process: pull the code, install the dependencies, run one command, restart it when it exits.

Environment variables go on the Startup tab rather than in the repository - the token, the database URL, the guild ID you sync to. Read them with a hard failure if they are missing, because discord.LoginFailure three seconds after boot is a much worse error message than "DISCORD_TOKEN is not set". Locally, python-dotenv reads the same names from a .env file that is in .gitignore. Where to put secrets on an application server is the longer argument.

On RE:NODE, the Git integration is GitHub only, through a GitHub App issuing short-lived tokens, so private repositories work without a credential parked on the server. Two switches control it: pull the branch on every start, and deploy on push, which restarts the server when GitHub reports a push to that branch - and only when it was already running. Each deploy is one record, so you can tell a deploy that shipped from one that failed. The same flow is walked through for Node in deploy a Node.js app from GitHub and works identically for Python.

For logging, call discord.utils.setup_logging() or configure logging yourself, and pass log_handler=None to bot.run if you are doing your own setup, otherwise the library installs a second handler and every line appears twice. Log to stdout so the console shows it live; a log file inside the container is a file you have to go and fetch over SFTP, and one that nobody rotates fills the disk in a few months. Logs worth keeping is about deciding what goes in.

Two hosting behaviours are worth knowing before they surprise you. If the process reaches the container's memory limit it is stopped and restarted clean rather than swapping, so an unbounded cache appears as a restart every few hours rather than as a slowdown. And a crash loop is noticed: three unexpected restarts in an hour puts a warning on the server page and opens a ticket automatically, six leads to suspension. Both are described from the diagnostic side in why your server keeps restarting.

Sharding, and when you need it#

Discord requires a bot to shard above 2,500 guilds. Below that it is a solution to a problem you do not have.

When you get there, discord.py makes it easy: commands.AutoShardedBot replaces commands.Bot and runs every shard on one event loop in one process. The gateway is asked how many shards it wants, the caches are shared, and memory grows with the amount of data rather than with the shard count. That is a meaningfully different situation from discord.js, which spawns a process per shard and multiplies the base memory with it.

python
bot = commands.AutoShardedBot(command_prefix="!", intents=intents)

What does change is that one blocked coroutine now affects every shard, so the event loop discipline from the previous section stops being advice and becomes a requirement. Large bots also get a higher max_concurrency from the gateway, which allows several shards to identify at once and makes a full restart much faster. Check what you are entitled to with an authenticated GET /gateway/bot rather than guessing.

Troubleshooting#

`ModuleNotFoundError` after adding a library. It is installed locally and missing from requirements.txt. The server only installs what the file says.

`PrivilegedIntentsRequired` on start. A privileged intent in code but not in the Developer Portal.

`LoginFailure: Improper token has been passed`. An empty or wrong DISCORD_TOKEN, a reset token that was never redeployed, or the client secret pasted instead of the bot token.

Prefix commands do nothing, slash commands work. The message_content intent. The bot is receiving the event with empty content.

`CommandNotFound` on a slash command that exists. It was never synced, or it was synced to a different guild, or a global sync has not propagated yet.

`AttributeError: 'Bot' object has no attribute 'slash_command'`. py-cord code running on discord.py. Pick one library.

The bot stops responding for a minute at a time. A blocked event loop. Search the log for the heartbeat warning and look at what ran just before it.

`RuntimeError: Event loop is closed` at shutdown on Windows. A local development annoyance from the proactor event loop; it does not happen on the Linux container the bot is hosted on.

FAQ#

Should I use discord.py or py-cord?

discord.py is actively maintained again and is what most current documentation and answers refer to, so it is the safer default. py-cord is a reasonable choice if you prefer its slash command syntax. What is not a choice is mixing them: they install under the same import name and break each other.

Do I need a virtualenv on the hosting side?

On a managed application server each container already isolates your dependencies, so a virtualenv inside it adds a directory and little else. Use one locally, where you have several projects sharing one interpreter, and pin requirements.txt so both environments install the same thing.

Where do I put the bot token?

In an environment variable on the Startup tab, read with os.environ["DISCORD_TOKEN"]. Not in the code, not in a committed .env, not in a config file in the repository. If it has already been pushed, reset it in the Developer Portal - rotating is the only fix, because git history keeps the old value.

Why does my bot get slower as more people use it?

Usually one blocking call in a popular command. Every user waiting on that call is waiting on the same event loop. Find the synchronous library call, move it to asyncio.to_thread or an async client, and the problem disappears without more CPU.

Can I run the bot and a small web dashboard on the same server?

Yes, if you keep them in one process with an async web framework, or accept that two processes share one memory limit and one CPU share. A separate small plan keeps the graphs readable and stops a crashing dashboard taking the bot with it. For the web side, deploying FastAPI or Flask has the details.

How do I know the bot is actually running?

A gateway bot has no endpoint to poll, so read the console, which shows live unfiltered output and graphs for memory and CPU against the limits. Log one line on ready with the guild count, and one line when a command errors. How to read a server console without guessing covers telling start-up noise from an actual fault.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000