Gateway bot or interactions endpoint
Discord can reach your bot in two ways:
- Gateway. The bot opens a WebSocket to Discord and keeps it open. It receives slash commands, messages, reactions, member joins and every other event its intents allow. This is what discord.js and discord.py do by default. Deploy it as a Background Worker.
- Interactions Endpoint URL. Discord sends each slash command, button click or modal as an HTTPS POST to your app. Nothing else arrives that way: no messages and no member events. Deploy it as a web service.
Discord treats the two as mutually exclusive for interactions: once an Interactions Endpoint URL is set, slash commands go to that URL, not to the gateway. Most bots use the gateway. Use the endpoint only for a bot that does nothing but answer commands.
Create the bot in the Developer Portal
- Create an application at discord.com/developers/applications. Its Application ID on the General Information page is your client ID.
- On the Bot page, press Reset Token and copy the token. It is shown once, and it is the bot's password: anyone with it can log in as your bot. Resetting it again invalidates the old one.
- Still on the Bot page, turn on any privileged intents your code asks for (see below).
- Invite the bot with a link like
https://discord.com/oauth2/authorize?client_id=<client-id>&scope=bot&permissions=<number>. Thebotscope already includes slash commands. Build the permissions number with the Discord permissions calculator.
A discord.js bot
discord.js 14 needs Node.js 18 or newer; use the current LTS. Install it with npm install discord.js and add "start": "node index.js" to your package.json scripts:
// index.js
const { Client, Events, GatewayIntentBits } = require("discord.js");
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, (c) => {
console.log(`Logged in as ${c.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === "ping") {
await interaction.reply("Pong!");
}
});
client.login(process.env.DISCORD_TOKEN);Use Events.ClientReady rather than the string "ready". Recent 14.x releases renamed the event to clientReady and print a deprecation warning for the old name.
A discord.py bot
discord.py 2.x needs Python 3.8 or newer. Put discord.py in requirements.txt. With no start command set, we run python main.py:
# main.py
import os
import discord
from discord import app_commands
class Bot(discord.Client):
def __init__(self):
super().__init__(intents=discord.Intents.default())
self.tree = app_commands.CommandTree(self)
async def setup_hook(self):
# Register commands only when asked, not on every restart.
if os.environ.get("SYNC_COMMANDS") == "1":
await self.tree.sync()
bot = Bot()
@bot.tree.command(description="Replies with Pong!")
async def ping(interaction: discord.Interaction):
await interaction.response.send_message("Pong!")
bot.run(os.environ["DISCORD_TOKEN"])Set SYNC_COMMANDS=1 for one deploy after you add or change a command, then remove it. The next section explains why.
Register slash commands once, not on every start
Slash commands have to be registered with Discord before they show up. Discord limits command creation to 200 per day per server, and a worker restarts on every deploy and after every crash, so registering from the bot's startup code can hit that limit. discord.js recommends a separate script you run when commands change:
// deploy-commands.js
const { REST, Routes, SlashCommandBuilder } = require("discord.js");
const commands = [
new SlashCommandBuilder().setName("ping").setDescription("Replies with Pong!"),
].map((c) => c.toJSON());
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
rest
.put(Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), { body: commands })
.then(() => console.log(`Registered ${commands.length} commands`));Run it from your machine with the token in a local .env file that is in .gitignore:
node --env-file=.env deploy-commands.js
Routes.applicationCommands registers global commands for every server. While testing, use Routes.applicationGuildCommands(clientId, guildId) instead: Discord applies server commands at once.
Privileged intents
Three intents are privileged: Message Content, Server Members and Presence. Slash commands need none of them. A bot that reads message text, such as one with ! prefix commands, needs Message Content, both in code (GatewayIntentBits.MessageContent or intents.message_content = True) and as a toggle on the Developer Portal's Bot page.
If the code asks for a privileged intent the portal hasn't enabled, Discord closes the connection with code 4014, “Disallowed intent(s)”. The bot exits, and the deploy fails because the process doesn't stay up.
A bot with fewer than 10,000 users can turn these on itself. Once a bot is in 100 or more servers it has to be verified, and a verified bot needs Discord's approval for each privileged intent.
Deploying as a Background Worker
A worker gets no domain and no HTTP health check, so you don't need a PORT or a web route. The deploy passes once the process is still running after a short startup grace period.
A worker deploy stops the old instance before it starts the new one. Two copies logged in with the same token would each receive every command and answer it twice, so this matters for a Discord bot. The bot is offline for a few seconds per deploy, and the new process starts a fresh gateway session, so events from those seconds aren't replayed.
If you run the bot on your laptop while the deployed copy is up, the same thing happens: both reply. Stop one of them, or use a second application with its own token for development.
Crash loops and the 1,000-login limit
Each time the bot starts it sends an Identify to Discord, and Discord allows 1,000 of them per 24 hours per bot. Reconnects that resume a session don't count. When a bot hits the limit, Discord ends all its sessions, resets its token and emails the owner.
A bot that crashes on startup is restarted with a growing delay, up to about a minute between tries. EthioDeploy stops a container that has been crash-looping for about 15 minutes, which keeps it far below the limit. Read the runtime logs, push a fix, and the next deploy starts it again.
Interactions endpoint as a web service
An endpoint bot is a normal web app that listens on 0.0.0.0 and the PORT env var. Discord holds it to three rules:
- Answer each interaction within 3 seconds, or send a deferred response (type 5) and follow up within 15 minutes. After 3 seconds the interaction token is invalidated.
- Verify the
X-Signature-Ed25519andX-Signature-Timestampheaders with your app's public key and answer 401 when they don't match. Answer a PING (type 1) with a PONG. - Keep passing Discord's routine checks, which include requests with deliberately invalid signatures. If the endpoint fails them, Discord removes the URL and tells you by email.
The 3-second rule rules out the Free plan for this kind of bot. A Free web service sleeps after 30 minutes without traffic, and the first request after that waits for the app to boot, which usually takes longer than 3 seconds. On Pro it stays awake. Add a GET / route that returns 200 for the health check, since Discord only POSTs.
Environment variables
Add variables in the project's settings. They're available both while the app builds and while it runs, so build-time values (like framework-public keys) work without extra setup. See Environment variables. Put the bot token in DISCORD_TOKEN. The bot exits at login without it, so add it right after you create the project and redeploy. Keep it out of the repo.
Host a Discord bot for free
The Free plan costs nothing and needs no card. It includes:
- One project, live over HTTPS at
<project-slug>.ethiodeploy.app. - 256 MB of memory and half a CPU core.
- A managed Postgres, MySQL or Redis addon with 512 MB of storage. It doesn't expire.
- A redeploy on every push, the same as the paid plans.
The catch is sleep. After 30 minutes without traffic the app stops, and the next request starts it again. We hold that request for up to 30 seconds while the app boots, so the first visitor after a quiet spell waits a little.
Sleep only applies to web services. A gateway bot deployed as a Background Worker stays online around the clock, but it counts against the Free plan's monthly usage quota: 50 GB-hours of memory and 20 CPU-hours, measured from what the bot actually uses. A bot averaging about 65 MB of memory uses close to 50 GB-hours in a 31-day month. If you go over and stay over for 14 days, the project stops until the next month or until you upgrade.
discord.js and discord.py keep the servers, channels and members they see in memory, so a bot uses more as it joins more servers. A small bot in a few servers is a fair fit for Free. A bot that has to stay online all month in many servers, or an interactions endpoint bot, belongs on Pro.
When the app has to stay awake or needs your own domain, Pro is $10 or 1,399 ETB a month: always on, 1 GB of memory and a 5 GB database.
Deploy
- Sign in with GitHub and install the EthioDeploy app on the repo.
- Click “New Project”, pick the repo and set Service Type to Background Worker.
- Add
DISCORD_TOKENunder Environment Variables and redeploy. When the process stays up past the grace period, the bot shows as online in Discord. Every push to the default branch redeploys it.
Troubleshooting
Deploy fails: the app never answered on port $PORT
Cause: The bot is deployed as a web service. A gateway bot never listens on a port, so the health check can't pass.
Fix: Set Service Type to Background Worker in Settings → Build and redeploy.
Error: Used disallowed intents, or close code 4014
Cause: The code asks for a privileged intent (Message Content, Server Members or Presence) that isn't turned on for the bot.
Fix: Turn the intent on under Bot → Privileged Gateway Intents in the Developer Portal, or remove it from the code.
The bot is online but slash commands don't appear
Cause: The commands were never registered, or were registered to a different server or application.
Fix: Run your registration script with the right DISCORD_CLIENT_ID. Register them for one test server with applicationGuildCommands, which applies at once.
Every command gets two replies
Cause: Two copies of the bot are logged in with the same token, such as one on your laptop and the deployed one.
Fix: Stop the other copy, or give development its own application and token.
The bot reacts to commands but message content is empty
Cause: Message Content is a privileged intent. Without it, message text arrives empty except in DMs and messages that mention the bot.
Fix: Enable Message Content in the code and in the Developer Portal, or switch the command to a slash command.
An interactions endpoint bot shows "The application did not respond"
Cause: The reply took longer than 3 seconds, often because the app was asleep or the handler waited on a slow API.
Fix: Run it on an always-on plan, and send a deferred response first when the work takes time.
Still stuck? Check the deployment's build log in the dashboard, or see Troubleshooting.
Questions
A small one, yes. Deployed as a Background Worker, a gateway bot never sleeps on the Free plan, but its memory and CPU count against the monthly quota of 50 GB-hours and 20 CPU-hours. A bot averaging about 65 MB uses close to that in a 31-day month. One that stays over the quota for 14 days is stopped until the next month. Pro ($10/month) has room for a bigger bot.
A bot built with discord.js or discord.py that calls login() or run() uses the gateway and must be a Background Worker. Only a bot that receives slash commands through an Interactions Endpoint URL is a web service.
Two processes are logged in with the same token. Each one receives every event and answers it. Stop the extra copy, often one still running on your own machine.
For a few seconds. A worker deploy stops the old process before starting the new one, so the bot never runs twice. Events sent in that gap aren't replayed to the new session.
A gateway bot can't: it has to hold its WebSocket open to receive anything. An interactions endpoint bot could, but Discord expects a reply within 3 seconds, which is shorter than a cold start, so it needs a plan that keeps it awake.
Ready to deploy?
Sign in with GitHub and pick your repo. No Dockerfile, no YAML.
More guides
All guides on the deploy guides page. Custom domains, env vars, addons and billing are in the docs.