A Discord bot with slash commands, online in a few minutes. discord.py handles Discord, and a status page tells you whether the bot is connected and hands you its invite link, so there is nothing to go hunting for.
Deploy it on velixir, paste in a bot token, and add the bot to your server from its status page.
It comes with three commands, /ping, /roll and /about: enough to show the pattern, few
enough to delete. The rest is the plumbing you would otherwise write first.
- Slash commands that register themselves on startup, globally or in one test server where changes show up instantly.
- No privileged intents, so setup is a token and nothing needs Discord's approval.
- A status page at
/: online or not, latency, how many servers the bot is in, which commands are registered, the last error, and an invite link built from your application id. It never shows the token. /healthzreturns 200 while the process runs, with the gateway state in the body.- No crash loop on a bad token. Discord's rejection is caught and shown on the status page, and the web server keeps running until you fix the token and redeploy.
- Clean restarts. On a redeploy it closes its gateway connection properly, so the old copy goes offline at once instead of lingering.
- Create an application in the Discord Developer Portal with New Application.
- Copy the bot token. On the application's Bot page, press Reset Token and copy it. Leave the Privileged Gateway Intents switched off: this bot does not use them.
- Deploy this template with the button above. It needs a plan that stays on (see below), so the Free plan is not offered for it.
- Add the token. On the app's Environment tab, add
DISCORD_TOKENwith the token as its value, and tick Treat as secret. - Redeploy, with the Redeploy button on the live release in the Deploys tab.
Open the app's URL. Once the status page says Online, press Add it to a server.
Until the token is set, the app serves those same steps as its home page rather than crashing,
and /healthz stays green.
The status page builds it for you. By hand it is:
https://discord.com/oauth2/authorize?client_id=<application-id>&scope=bot+applications.commands&permissions=0
The application id is on the application's General Information page. The two scopes add
the bot user and its slash commands. permissions=0 asks for nothing, because replying to a
slash command needs no permissions. If you extend the bot to post on its own or manage roles,
build a new link with those permissions in the portal's OAuth2 URL Generator.
With nothing else set, the bot registers its commands globally, for every server that adds it. The catch is speed: a change to a global command can take a while to reach every server, and the Discord app caches the command list, so a renamed option may not show until you restart the app.
Set DISCORD_GUILD_ID to your test server's id and the commands are registered to that
server only, where changes show up as soon as the bot restarts. Use it while developing, then
remove it and redeploy to go global. To copy a server id, turn on Developer Mode in Discord's
Advanced settings, then right-click the server and choose Copy Server ID.
Commands registered to a server stay there after you go back to global, so that server may
show each command twice. The comment in sync_commands in bot.py shows the two lines that
clear them.
Discord keeps three gateway intents behind a switch: Message Content, Server Members and Presence. A bot that uses them has to have them switched on in the portal, and once it is in 100 servers it has to be verified by Discord, with each privileged intent approved.
This bot needs none of them. Slash commands arrive as interactions, which need no intents at
all, and the one intent it asks for is guilds, which is not privileged, so it knows which
servers it is in. Setup stays a single token, and verification has nothing extra to question.
If you later want to read ordinary messages (a ! prefix, moderation), switch on Message
Content on the Bot page, add intents.messages = True and intents.message_content = True in
Bot.__init__, and expect to explain why when the bot is verified.
A Discord bot keeps a WebSocket open to Discord's gateway and hears about everything through it. Discord never sends it an HTTP request. velixir's Free plan puts an app to sleep after 15 minutes without HTTP traffic, and a sleeping bot is simply offline, with nothing that could wake it.
So the template declares "alwaysOn": true, and one-click deploys start on Hobby, the smallest
plan that never sleeps. Memory is not the constraint: in a handful of servers the bot uses
around 35 MB, so Hobby's 512 MB leaves plenty of room to grow.
Discord can also deliver slash commands as HTTP requests to an Interactions Endpoint URL, which is how a bot could sleep between commands. That is a different design (no gateway, no events, and you verify each request's signature yourself), and discord.py does not use it. If that URL is set in the portal, Discord stops sending commands to this bot, and the status page warns you.
python -m venv .venv
. .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
DISCORD_TOKEN=... DISCORD_GUILD_ID=... python bot.pyThen open http://localhost:8080. Without a token, you get the setup page.
If the bot is already live, develop with a second application from the portal: two copies logged in with the same token both hear every command.
velixir deployCommands are plain functions near the top of bot.py. Discord shows the description under the
command, and typed parameters become its options:
@app_commands.command(description="Say hello")
async def hello(interaction: discord.Interaction) -> None:
await interaction.response.send_message(f"Hello, {interaction.user.mention}.")Add hello to the COMMANDS list and it is registered the next time the bot starts. Reply
within three seconds, or call await interaction.response.defer() first and send the answer
with interaction.followup.send(...) when the work takes longer.
- A redeploy overlaps for a moment. velixir starts the new version before it stops the old one, so for a few seconds both are connected and both hear each command. One answers, and the other logs that it was beaten to it. Nobody gets two replies.
- Nothing is stored. The container's disk is wiped on every deploy. For anything the bot should remember (settings, points, reminders), bind a managed Postgres and keep it there.
- One process covers up to 2,500 servers. Past that, Discord requires sharding (discord.py's
AutoShardedClient), and a bot that big will want more memory than Hobby. - A rejected token is not retried. Discord temporarily blocks addresses that make too many unauthorised requests, so the bot stops and says so instead of retrying in a loop. Fix the token and redeploy. Network trouble, on the other hand, is retried on its own.
- No voice. velixir apps can only make outbound connections over TCP on ports 80 and 443, and Discord voice needs UDP. Everything else discord.py does runs over HTTPS and the gateway's WebSocket.
- Mentions are tame by default. Nothing the bot says can ping
@everyoneor a role unless a command asks for it explicitly (theallowed_mentionssetting inBot.__init__). - Keep the token secret. Whoever has it can act as the bot. If it leaks, reset it in the portal and redeploy with the new one.
| Variable | Required | Meaning |
|---|---|---|
DISCORD_TOKEN |
yes | The bot token from the Developer Portal's Bot page. Without it, the app serves its setup page. |
DISCORD_GUILD_ID |
no | A server id. When set, commands are registered to that server only, where changes show up instantly. |
| Method | Path | Does |
|---|---|---|
GET |
/ |
The status page, or the setup steps when no token is set |
GET |
/healthz |
200 while the process runs, with the gateway state in the body |
MIT.