How to host a Discord bot 24/7
Follow this on your own server. Start free, no card needed
The bot works. node index.js on your laptop logs it into your test server, !ping gets a pong back, everything is fine, right up until you close the terminal or put the laptop to sleep and the bot goes offline with it. What’s missing isn’t more code. It’s a process that keeps running after you walk away, which is a different problem than the one you solved writing the bot itself.
This guide builds a real discord.js bot, packages it in a Dockerfile, and explains the one thing that trips up almost everyone hosting a Discord bot for the first time: why it needs a process that never stops, not a function that starts on demand. Then it covers the restart policy, logging and the one-click path that gets the same thing running on Railyard.
What you’ll need
- Node.js 22 or 24 installed locally (discord.js 14 requires Node 18 or newer).
- A Discord account and a server (guild) you can test in, ideally one you control.
- Docker installed locally, to build and run the image before it goes anywhere else.
- A server to run the container on, or a Railyard account for the shortcut in the last section.
Step 1: Create the bot application and get a token
Go to the Discord Developer Portal and click New Application. Give it a name, then open the Bot tab on the left. Click Reset Token to generate a token, and copy it somewhere safe. This is the credential your code uses to log in as the bot; anyone with it can control your bot, so it goes in an environment variable, never in a file you commit.
On the same Bot tab, under Privileged Gateway Intents, turn on Message Content Intent if your bot needs to read the text of messages (a moderation bot or a command bot that isn’t purely slash-commands needs this; a bot that only responds to slash commands and button clicks doesn’t).
Open OAuth2 → URL Generator, tick the bot scope, tick the permissions your bot actually needs (start with Send Messages and Read Message History for a simple bot), copy the generated URL, and open it in a browser to invite the bot to your test server.
Step 2: Write the bot
discord.js 14.27.0 is the current npm release. Initialize the project and install it:
npm init -y
npm install discord.js@^14.27.0
Add "type": "module" to package.json so you can use import, and a start script:
{
"name": "my-discord-bot",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"discord.js": "^14.27.0"
}
}
index.js:
import { Client, GatewayIntentBits, Events } from "discord.js";
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
client.once(Events.ClientReady, (readyClient) => {
console.log(`Logged in as ${readyClient.user.tag}`);
});
client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
if (message.content === "!ping") {
await message.reply("pong");
}
});
client.on(Events.Error, (error) => {
console.error("Discord client error:", error);
});
client.login(process.env.DISCORD_TOKEN);
Each GatewayIntentBits entry declares a category of events Discord will actually send to your bot; GuildMessages and MessageContent together are what let MessageCreate fire with a readable message.content for a plain text command like !ping. Asking for MessageContent without turning on the Message Content Intent in the Developer Portal in Step 1 gets you a “disallowed intents” error at login, which is the single most common first error with a new bot.
Test it locally:
DISCORD_TOKEN=your-token-here node index.js
You should see Logged in as yourbot#0000 in the terminal, and !ping in your test server should get a pong reply.
Step 3: Write the Dockerfile
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
CMD ["node", "index.js"]
This is the same two-stage shape you’d use for any Node app: dependencies install in their own stage so they’re cached separately from the rest of the source, and the final image runs as the non-root node user rather than root. The one real difference from a web app’s Dockerfile is what’s missing: no EXPOSE, because this process never listens on a port. It opens an outbound connection to Discord’s gateway; nothing connects in.
Step 4: Why this needs to be an always-on process, not a function
A serverless function (a Vercel Function, an AWS Lambda) starts when an HTTP request arrives and shuts down shortly after it responds. That model fits a workload made of discrete, independent requests. A Discord bot isn’t one: after client.login() succeeds, discord.js opens a WebSocket connection to Discord’s gateway and keeps it open indefinitely. Every message, reaction and slash-command interaction your bot sees arrives as an event pushed down that already-open socket, not as an inbound HTTP request your platform routes to a handler.
There’s no request to trigger a cold start in the first place, and even if a platform invoked your code on some other schedule, the gateway connection itself would still need to exist continuously between invocations to receive anything at all; a connection that only exists while a function happens to be running isn’t a connection that can receive an event that arrives between two invocations. That’s the real distinction a “host a Discord bot” search is circling: not performance, but whether anything is listening at the moment Discord decides to send something.
(Discord does also support an HTTP-based interactions endpoint for slash commands specifically, where Discord sends your bot an HTTP POST per interaction instead of a gateway event. That model can run on a serverless function, but it only covers slash commands and components, not plain messages, presence, or anything else the gateway carries, and it’s a different architecture from the Client + gateway approach this guide uses.)
What a bot like this one actually needs is unglamorous: a process that starts, stays running, and reconnects on its own if the connection drops, which discord.js already does internally for ordinary network blips. Your job is making sure the process itself keeps existing, which is a restart policy, not a framework.
Step 5: Restart policy and logging
Run the image locally with a restart policy so Docker brings it back after a crash or a host reboot:
docker build -t my-discord-bot .
docker run -d --name my-discord-bot --restart unless-stopped \
-e DISCORD_TOKEN=your-token-here \
my-discord-bot
--restart unless-stopped tells the Docker daemon to restart the container automatically unless you explicitly stopped it yourself, which covers both an unhandled exception crashing the process and the host machine rebooting.
For logs, don’t add a logging library yet; console.log and console.error to stdout/stderr are enough for a bot this size, and Docker already captures both:
docker logs -f my-discord-bot
You should see the Logged in as... line from Step 2, and any console.error output if the client hits an error. Keep logging the Discord-specific events that matter for debugging a bot that’s gone quiet, in particular reconnects, so a dashboard or terminal you check later tells you something happened instead of just going silent:
client.on(Events.ShardDisconnect, () => console.warn("Shard disconnected, reconnecting..."));
client.on(Events.ShardReconnecting, () => console.log("Shard reconnecting..."));
Step 6: Run it on a real server
Copy the image (or the repository, if you’re building on the server) to a VPS with Docker installed, and run the same docker run command from Step 5 there, with a real .env file instead of an inline token:
docker run -d --name my-discord-bot --restart unless-stopped --env-file .env my-discord-bot
That single flag, --restart unless-stopped, is most of what “hosting a bot 24/7” actually means in practice: a process that comes back on its own after anything short of you explicitly stopping it.
The shortcut: do it with Railyard
Point Railyard at the same repository. Since the repo has a Dockerfile that actually copies the app into the image (not a development image expecting a bind mount, which Railyard’s build detection skips automatically), that Dockerfile builds and runs directly, no Railpack or buildpack guessing involved; see build methods.
This bot has no web process, and that’s fine: Railyard runs worker-only apps the same as any other, as a process type with no port attached, covered in Workers and scheduled jobs. Add DISCORD_TOKEN under the app’s Variables tab (see Variables and secrets); it’s encrypted and applied on the next deploy, which is the Railyard-side equivalent of the --env-file .env from Step 6.
One setting to leave alone: Sleep when idle, under Settings → General, stops a process once its reported CPU has been idle long enough. A Discord bot sitting in a quiet server can look idle by that measure even while its gateway connection is very much alive and about to receive a message; leave that setting off for this process so the connection is never the thing that gets put to sleep.
The restart policy from Step 5 isn’t something you configure here either, it’s just how Railyard runs every app’s containers: a crash restarts the process without you SSHing in to type docker start. Logs are the Logs tab on the app page, or railyard logs my-bot --follow from the CLI, the same docker logs -f habit from Step 5 without needing a terminal open on the server itself.
To deploy without even pasting a repository URL each time, add a deploy button to the bot’s README:
[](https://app.railyard.run/deploy?repo=https://github.com/you/your-discord-bot)
See the deploy button docs for the full link format, and quickstart if you’re starting from nothing: sign-up, a server, and a running app in about ten minutes.
Troubleshooting
The bot shows offline in Discord even though the container is running. Check docker logs (or the Logs tab) for a login error first; an invalid or revoked token fails immediately. If login succeeds but the bot still won’t respond, confirm the invite URL from Step 1 actually included the bot scope and the permissions your commands need.
“Used disallowed intents” at startup. You’re requesting GatewayIntentBits.MessageContent (or another privileged intent) in code without turning it on in the Developer Portal. Go back to the Bot tab in Step 1 and enable the matching Privileged Gateway Intent.
The container keeps restarting in a loop. That’s --restart unless-stopped doing exactly its job against a process that’s crashing immediately, usually a startup error, not a transient one. Check the first few lines of docker logs right after a restart, not the tail; the crash reason is almost always there, commonly a missing DISCORD_TOKEN or a typo in a dependency import.
The bot goes quiet for a while, then comes back. This is usually a gateway reconnect, which discord.js handles on its own; the ShardDisconnect/ShardReconnecting logging from Step 5 turns “the bot seemed down” into a log line you can actually check against Discord’s own status page if it keeps happening.
FAQ
Can I host a Discord bot for free? The bot framework itself is free either way; what costs money is the always-on compute it runs on. A small VPS is a few dollars a month, which is the real floor for “24/7,” since nothing free keeps a process alive indefinitely without you babysitting it.
Do I need a web server for my Discord bot? No, not for the gateway-based approach this guide uses. The bot only makes outbound connections (to Discord’s gateway and REST API); it never needs to accept inbound HTTP requests, so there’s no port to expose and no reverse proxy or TLS certificate to manage for the bot itself.
What happens to messages sent while my bot is offline? They’re gone from the bot’s point of view. Discord’s gateway doesn’t queue events for a disconnected client to replay later; if the bot was offline when a message was sent, it never sees that MessageCreate event, which is the practical reason “24/7” matters for a bot more than it does for most web apps.
discord.js or discord.py: does it matter for hosting? Not for anything in this guide. A discord.py bot (Python) needs the same things: an always-on process, not a function; a restart policy; and intents.message_content = True set in code plus the Message Content Intent enabled in the Developer Portal, the Python equivalent of Step 2’s intent setup. The Dockerfile swaps node:24-alpine for python:3.13-slim and bot.run(TOKEN) for client.login(...), but the architecture is identical.
Why not just leave a terminal open with node index.js running? It works until the terminal closes, the laptop sleeps, or the SSH session drops, at which point the process dies with it and nothing brings it back. --restart unless-stopped (or Railyard running the container the same way by default) is the difference between a bot that’s up because you haven’t closed your laptop yet and one that’s actually up.
A Discord bot is a small piece of code doing a big, continuous job: staying connected. Once the process itself is something that restarts on its own and logs where you can see it, the bot being always online stops being something you have to think about at all.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.