How to Self-Host a Discord Bot: Building and Running Your Own Bot 24/7
There's a moment every Discord bot developer remembers, even if nobody makes a big deal of it. You run node index.js, switch over to Discord, type a command, and your own code answers back. It's a small thing to get excited about, and it never gets old.
A day or two later you close your laptop, the bot goes offline with it, and the real question arrives: how do people keep these things running all the time?
I've hosted a handful of Discord bots over the years, and the truth is it stops being complicated the moment you've done it once. It's a stack of small pieces that aren't obvious the first time: a bot token, SSH, a process manager, environment variables, a way to ship updates without breaking anything. This is the walkthrough I wish I'd had, going from a bare "hello world" bot to something running 24/7 on a server and staying up on its own.
We'll build the bot, get it working locally, move it onto a VPS, keep it alive with PM2, and set up a clean way to update it later. Along the way I'll flag the traps that cost me hours the first time, so you can walk straight past them.
Worth saying up front: almost none of this is Discord specific. Swap "Discord bot" for "Telegram bot" or "small Node API" and the process barely changes. Discord bots are just a common first deploy because the feedback loop is so tight. You push a change and a few seconds later you see it working, or not, in a real chat window. That immediacy is a big part of why it's worth learning properly instead of following a hosting tutorial on autopilot and hoping it holds.
What "self-hosting" actually means, and why your laptop isn't it
Self-hosting just means you run the bot on a computer you control, rather than paying a managed platform to run it for you. That computer can be an old laptop in a drawer or a rented server in a data center. The important word is control: you decide the runtime, the updates, the uptime, and the security.
Running your bot locally while you build it is normal. It's how it should work. Fast to edit, fast to restart, no reason to complicate things while you're still figuring out what the bot even does.
The problem appears the second other people start relying on it. Say you build a moderation bot for a community you're in. For that bot to be useful, your computer would have to:
- stay powered on, basically forever
- never lose its internet connection
- keep Node running without interruption
- never crash
- never reboot for a random Windows update at 3am
That's not a fair ask of a personal laptop, and it isn't meant to be. It's the job servers exist for. Moving the bot onto something built to stay online is the moment it stops being "a script I wrote" and becomes an actual service other people can depend on.
A quick mental model of how a bot works
Before we touch servers, it helps to know what actually happens when your bot responds to something. It makes debugging far less mysterious later.
Your bot doesn't talk to Discord directly. It goes through Discord's API, and a library like discord.js handles that conversation for you:
Your application → discord.js → Discord API → Discord's servers
In practice:
- Your bot starts up, and discord.js opens a connection to Discord.
- It authenticates with a bot token.
- Discord checks the token is valid.
- Once accepted, your bot starts receiving events and can respond to them.
So when someone types !hello, Discord fires that to your bot as an event, your code handles it, and a reply goes back, but only if your process is running at that moment. The second your program stops, the whole chain stops. That's the entire reason hosting matters. No running process, no bot.
Two channels: the gateway and the REST API
There are two separate channels your bot uses to talk to Discord, and knowing the difference explains a couple of things you'll bump into later.
The gateway is the persistent connection, a WebSocket that stays open the whole time your bot runs. It's how Discord pushes events to you in real time: messages sent, members joining, reactions added. This is the connection PM2 is really keeping alive for you. If your host has flaky networking, this is the connection that suffers, and discord.js will spend its life reconnecting.
The REST API is what your bot uses to do things: send a message, edit a channel, ban a user, register commands. Those are one-off HTTP requests, separate from the persistent connection. The REST API also has rate limits, so if your bot ever starts hammering it (a loop that edits a message every 100ms, say), you'll start seeing 429 Too Many Requests. discord.js queues around these for you most of the time, but it's good to know the wall is there.
Intents, the thing that silently breaks bots
The other thing to learn early is intents. When your bot connects, it tells Discord which categories of events it wants to receive. Discord doesn't fire every event at every bot by default; you opt in. This keeps the firehose manageable, and it's also a privacy boundary.
Most intents you just list in your client config. Three of them are privileged: Presence, Server Members, and Message Content. Those you have to explicitly enable in the Discord Developer Portal, on your application's Bot page, on top of requesting them in code. And once your bot is in more than 100 servers, using a privileged intent requires going through Discord's verification process.
This trips people up constantly. The bot connects fine, logs in fine, then never responds to messages, because the Message Content intent was never turned on in the portal. There's no error. It just sits there. If your bot goes quiet with no errors at all, this is the very first thing to check.
Step 0: create the bot in the Developer Portal
Before any code runs, your bot needs an identity and a token. This part lives entirely on Discord's side.
- Go to the Discord Developer Portal and create a new application. Give it your bot's name.
- Open the Bot tab. Your application already has a bot user attached. This is where you'll find (and reset) the token, and where you toggle those privileged intents.
- Copy the token once and put it somewhere safe. If you ever lose it or leak it, reset it here, which invalidates the old one immediately.
Treat that token like a password, because it is one. Anyone who has it can run code as your bot, in every server it's in. We'll come back to keeping it out of your source code, but the habit starts now: the token never gets pasted into a file you commit.
Inviting the bot to a server
A freshly created bot isn't in any server yet. Use the OAuth2 URL generator in the portal:
- Under OAuth2 → URL Generator, tick the
botscope, andapplications.commandstoo if you plan to use slash commands (you should). - Pick the permissions the bot actually needs. Resist the urge to check Administrator "to be safe." A moderation bot needs things like Kick, Ban, Manage Messages, and Read Message History, not the keys to the whole server. Least privilege is a real security control, not a formality.
- Copy the generated URL, open it, choose your server, and authorize.
Now the bot exists, has a token, and is sitting in a server waiting for a process to bring it to life.
Step 1: get the bot working locally first
Don't skip ahead to server setup. Get it running on your own machine first, so any problem later is clearly about the server and not the bot.
A simple Node.js bot looks roughly like this:
discord-bot/
├── index.js
├── package.json
├── package-lock.json
├── .env
├── .gitignore
└── node_modules/
What each piece does:
| File | Purpose |
|---|---|
index.js | Your bot's actual logic |
package.json | Project info and dependency list |
package-lock.json | Pins exact dependency versions |
.env | Your token and other secrets, never shared |
.gitignore | Keeps .env and node_modules out of Git |
node_modules/ | Installed packages (don't upload this by hand) |
One prerequisite: current discord.js (v14) needs Node 18 or newer. Check with node -v. If you're on something older, fix that now, because it's the exact trap that bites people again on the server later.
Setting up the project
mkdir discord-bot
cd discord-bot
npm init -y
npm install discord.js dotenvdiscord.js does the heavy lifting of talking to Discord. dotenv lets you pull secrets like your token out of a .env file instead of pasting them into your code (please don't paste them into your code).
Before you write anything else, create a .gitignore so you never commit the two things you must not commit:
node_modules/
.env
The bot itself
Create index.js:
require("dotenv").config();
const { Client, GatewayIntentBits } = require("discord.js");
const client = new Client({
intents: [GatewayIntentBits.Guilds]
});
client.once("ready", () => {
console.log(`${client.user.tag} is online!`);
});
client.login(process.env.TOKEN);Nothing fancy, on purpose. require("dotenv").config() pulls in your .env values, new Client() sets up the connection, ready fires once Discord accepts your login, and client.login() authenticates. Get this bare version online before you add anything else. A working "hello world" is a much better starting point than a half-built feature you can't get to run.
Create your .env alongside it:
TOKEN=your_bot_token_here
Then run it:
node index.jsYou should see your bot's tag printed and the bot show as online in Discord.
Catch errors before they take the bot down
One habit worth building from the very first version: a single unhandled error should not kill your whole bot. Add a safety net near the top of index.js:
process.on("unhandledRejection", (error) => {
console.error("Unhandled promise rejection:", error);
});Later, when you're handling commands, wrap the risky parts in try/catch too. The goal is that one broken command replies with "something went wrong" instead of crashing the process and taking every other server's bot offline with it. PM2 will restart a crashed bot, but a restart still means a few seconds of downtime and a dropped interaction, so it's better not to crash in the first place.
Where to actually run it
You have a few realistic options.
You could run it on hardware you already own: an old laptop, a Raspberry Pi in a drawer, whatever. It's free and you're in full control. But now you own your own uptime. Home internet drops, power goes out, the Pi's SD card dies, and all of that becomes your bot's problem, on top of the networking and security you now have to handle yourself. It's a fine way to learn. It's a rough way to run something people rely on.
Most people land on a VPS, a Virtual Private Server. It's a slice of a much bigger machine in a data center, set up so it feels like your own private Linux box. You SSH in, install what you need, and it stays on. That's the whole selling point: someone else owns the power, the cooling, and the network, and you just rent a reliable place to run a process.
You don't need anything powerful for a Discord bot. Even a cheap, small instance handles most bots without effort. The specs matter less than the fact that the thing doesn't randomly disappear.
So which VPS?
Don't overthink it. A few names come up for a reason: Hetzner, netcup, DigitalOcean, Vultr, and Linode (now Akamai) all offer small instances aimed at exactly this. Hetzner and netcup in particular are popular in the EU and tend to give you noticeably more RAM per euro, which is handy if your bot is going to grow into a database and a dashboard. DigitalOcean and Vultr have a gentler dashboard and lots of tutorials if you're brand new. Any of them will run a Discord bot for somewhere around $4 to $6 a month, on hardware the bot will never come close to maxing out.
A few things worth paying attention to when you pick one:
- Region. Choose something close to you or your bot's main user base. It won't make a huge difference for a text bot, but it's a free win. If your community is European, an EU provider like Hetzner or netcup also keeps you on the right side of data-locality expectations.
- RAM over CPU. A small Node process is rarely CPU-bound, but running out of memory crashes the bot outright. 512MB to 1GB is plenty to start; go up if you're adding a database on the same box.
- Bandwidth caps. A non-issue for most bots, but glance at it if you plan to add a dashboard or serve images.
- Snapshots and backups. Usually a cheap add-on, and worth the extra dollar or two once your bot has data worth losing.
Pick one, spin up the cheapest current Ubuntu LTS instance they offer, and move on. You can resize later if you outgrow it, which takes a few minutes on most providers.
Step 2: getting the server ready
Once the bot logs in locally, move it somewhere that stays up. I'm using a Linux VPS running Ubuntu, since that's what most bot hosting runs on. It's lightweight, extremely well documented, and gives you full control.
Connecting over SSH
SSH is how you drive the server from your own terminal. Providers give you an IP and a login. On first connect you'll use a password (or a key you uploaded at creation):
ssh [email protected]Once you're in, every command runs on the server, not your machine. Sit with that for a second the first time. It looks and feels almost identical to your normal terminal, but you're driving a completely different computer now.
Passwords over SSH are fine for the first login and bad forever after. Set up a key pair instead. On your local machine:
ssh-keygen -t ed25519 -C "[email protected]"
ssh-copy-id [email protected]Now you can log in without typing a password, and later we'll turn password login off entirely so nobody can brute-force their way in.
Make a non-root user first
Providers often drop you in as root. Don't stay there. A stray command as root can wipe the whole box, and you don't want your bot running with that much power either. Create a normal user and switch to it:
adduser bot
usermod -aG sudo botLog out, copy your SSH key to the new user (ssh-copy-id bot@your-ip), and from now on connect as bot@your-ip, reaching for sudo only when you actually need it.
Update packages
Before installing anything, update the server. Easy habit, and it pulls in security patches you'd rather have:
sudo apt update
sudo apt upgrade -ySet up a basic firewall
A fresh server should only accept traffic on the ports you actually use. On Ubuntu, ufw makes this a two-minute job:
sudo ufw allow OpenSSH
sudo ufw enableThat allows SSH and blocks the rest. If you add a dashboard later, you'll open its port (or, better, put it behind a reverse proxy on 80/443). Fewer open doors, fewer things that can go wrong.
Install Node (don't use apt install nodejs here)
This is the one that quietly wrecks people's first deploy. Ubuntu's own repositories ship an old Node, often too old for current discord.js. You install it, everything looks fine, then discord.js throws an unsupported-version error the moment it starts, and you waste an afternoon debugging your code when the real problem is the runtime.
Install a current version instead. A version manager is the friendliest route, and it makes bumping Node later trivial:
# nvm (touches no system packages)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
# reopen your shell, then:
nvm install --ltsPrefer a real system package? Use NodeSource, which serves current releases:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejsEither way, confirm it worked:
node -v
npm -vYou should get real version numbers back, and Node should be 18 or higher.
Step 3: getting your code onto the server
A few ways to do this, and it doesn't matter much which you pick starting out.
Git is what most people settle on, because updating later is just a git pull. Clone your repo:
git clone your-repository-urlIf your repo is private, you'll need to authenticate. The clean way is a read-only deploy key: generate an SSH key on the server (ssh-keygen -t ed25519), add the public half to your repo's deploy keys on GitHub, and clone over SSH. A personal access token works too, but a deploy key scoped to one repo is tidier and safer.
SCP copies files straight over SSH if you don't want a repo yet:
scp -r discord-bot bot@your-ip:/home/bot/SFTP is the drag-and-drop option, something like FileZilla, which is probably the easiest start if the terminal still feels unfamiliar.
Installing dependencies
cd discord-bot
npm installThis reads your package.json and pulls everything listed there, which is exactly why you don't upload node_modules. It rebuilds from scratch on the server in a few seconds, matched to the server's own OS and architecture.
Recreating your .env
Your .env never goes through Git (it's in your .gitignore), so make it directly on the server:
nano .envTOKEN=your_bot_token_here
Save it, and your bot has what it needs to log in.
Test before automating anything
node index.jsYou should see it log in, and the bot should show as online in Discord. Kill it with Ctrl + C and confirm it goes offline. That's expected, and it sets up the next problem nicely: right now the bot only stays alive as long as this terminal session does.
Step 4: keeping it alive with PM2
You could, in theory, run node index.js and leave your SSH session open. Don't. It falls apart fast:
- Close the SSH session and the process usually dies with it.
- Hit an unhandled error and the bot stays down. Nobody restarts it for you.
- Reboot the server and the bot doesn't come back on its own.
This is the gap PM2 fills. It's a process manager for Node apps. Think of it as something sitting behind your bot, watching it, and restarting it the moment something goes wrong instead of leaving it dead until you notice.
Installing it
npm install pm2 -g
pm2 -vStarting the bot
pm2 start index.js --name discord-botCheck it's running:
pm2 status┌────┬───────────────┬────────┬────────┐
│ id │ name │ status │ mode │
├────┼───────────────┼────────┼────────┤
│ 0 │ discord-bot │ online │ fork │
└────┴───────────────┴────────┴────────┘
You can close your SSH session now and the bot keeps running in the background. The first time you see that work, it feels a little like magic.
The commands you'll actually use
| Command | What it's for |
|---|---|
pm2 logs discord-bot | Live logs, the first place you go when something breaks |
pm2 restart discord-bot | Restart after an update |
pm2 stop discord-bot | Stop it without deleting it |
pm2 delete discord-bot | Remove it from PM2 completely |
pm2 monit | Live CPU and memory view per process |
pm2 list | See everything PM2 is managing |
Use a config file instead of long commands
Starting the bot with a flag or two is fine, but the moment you want memory limits, environment settings, or predictable restarts, put it in an ecosystem.config.js at the root of your project:
module.exports = {
apps: [{
name: "discord-bot",
script: "index.js",
instances: 1,
exec_mode: "fork",
max_memory_restart: "300M",
env: {
NODE_ENV: "production"
}
}]
};Then start it with:
pm2 start ecosystem.config.jsTwo things in there matter for bots specifically. Keep instances at 1 and exec_mode as fork. PM2's cluster mode is built for web servers, where load balancing across identical workers makes sense. A Discord bot holds a single gateway connection, and running several copies of it would just log in several times and reply to every command two or three times over. Scaling a bot is Discord's job, through sharding, which is a separate mechanism we'll touch on later. And max_memory_restart is a cheap safety net: if a leak ever pushes the process past 300MB, PM2 restarts it before the server runs out of memory.
Make it survive a reboot
PM2 doesn't know to bring your bot back after the server itself restarts. You have to tell it:
pm2 startupIt prints a command specific to your system. Copy that exactly and run it. Then lock in your current setup:
pm2 saveNow the whole chain looks like this:
Server reboots → PM2 comes back up → your bot starts automatically → bot's online again
This is the point where the bot stops being something you babysit and becomes an always-on service.
One more thing PM2 won't do for you: rotate logs
Left alone, PM2's log files grow forever. On a small VPS they can quietly fill the disk and take the bot down with them, which is a miserable outage to diagnose because nothing in your code is wrong. Install the log rotation module once and forget about it:
pm2 install pm2-logrotateKnowing when your bot is down before your users tell you
PM2 keeps the process alive, but it can't tell you when the bot is up but broken, or when the whole server has gone dark. Two cheap layers cover most of this.
The first is an external uptime check. A free service like UptimeRobot can ping a tiny health endpoint on your bot every few minutes and email or message you when it stops answering. To use one, add a minimal HTTP route to your bot (a few lines with express) that returns 200 OK only while the bot is connected. If the ping fails, you find out in minutes instead of the next morning.
The second is a crash notification straight into Discord. Post to a private webhook when the process is about to die:
process.on("uncaughtException", async (error) => {
console.error(error);
// fire a webhook to a private channel, then let the process exit
// so PM2 restarts it cleanly
});You don't need both on day one. But the first time your bot goes down at 2am and you only hear about it at noon, you'll wire one of these up, so you may as well know they exist now.
Updating the bot without breaking production
Once the bot is stable, you'll start adding things: slash commands, a database, moderation tools. At some point, editing files directly on the server every time starts to feel risky, because it is. One typo and the bot's down until you notice.
A workflow that holds up better:
- Make changes locally.
- Test them locally.
- Push to your repo.
- Pull on the server and restart.
On your own machine:
git add .
git commit -m "Added moderation commands"
git pushOn the server:
git pull
npm install
pm2 restart discord-botThat's the whole update cycle. It's the same pattern real teams use, at a smaller scale. npm install is only strictly needed when you've added or changed a dependency, but running it every time is harmless and saves you the one deploy where you forget.
A small caveat worth knowing: pm2 reload exists and does zero-downtime reloads for clustered web apps, but for a single-process bot in fork mode it behaves much like restart. There's no truly seamless reload for a single gateway connection, so a quick restart is the honest, correct tool here. The blip is a second or two.
As the bot grows
Moving to slash commands
If your bot still runs on !hello-style prefix commands, switch to slash commands sooner rather than later. Discord has been steering developers this way for a while, and slash commands bring real perks: autocomplete, built-in argument validation, and users don't have to guess your prefix or remember which three other bots it clashes with.
Registering one looks like this:
const { REST, Routes, SlashCommandBuilder } = require("discord.js");
const commands = [
new SlashCommandBuilder()
.setName("ping")
.setDescription("Replies with pong")
].map(command => command.toJSON());
const rest = new REST().setToken(process.env.TOKEN);
(async () => {
await rest.put(
Routes.applicationCommands(process.env.CLIENT_ID),
{ body: commands }
);
console.log("Slash commands registered.");
})();Run that once whenever you add or change a command. It just talks to Discord's API, so it doesn't matter whether you run it locally or on the server. Then handle the command with an interactionCreate listener:
client.on("interactionCreate", async interaction => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === "ping") {
await interaction.reply("Pong!");
}
});Small detail that catches people out: global slash commands can take up to an hour to appear everywhere after you register them. While you're actively developing, register to a single test server instead with Routes.applicationGuildCommands. Those update almost instantly and make iterating far less painful. Register globally only once the command is ready for everyone.
More packages, as you need them
npm install mongoose # MongoDB
npm install express # web dashboards / APIs / health checks
npm install axios # calling external APIsEverything lands in package.json automatically, so setting the bot up somewhere new is always one npm install away.
Giving it a memory
At some point you'll want the bot to remember things: warnings a mod bot has issued, XP for a leveling bot, open tickets, per-server settings. That's a database's job, not something to bolt onto your bot's logic directly.
SQLite is plenty for smaller bots. It's a single file on disk, needs no separate server process, and it's genuinely enough for a lot of communities. If you're running across many servers, or you want a proper query layer and concurrent access, PostgreSQL or MongoDB hold up better. Whichever you choose, open the connection once when the bot starts, not per command, and handle the case where the database is briefly unreachable so a hiccup doesn't crash the whole bot.
The real point is separation. Keep the bot's behavior in one place and its data in another. When the project grows past a single file, that boundary is what keeps it possible to reason about.
One hosting note: if your database lives on the same VPS, it's now part of your backup story. A bot with no database can be rebuilt from Git in minutes. A bot with a database holds the only copy of your community's warnings and levels, so back that file up on a schedule and, once in a while, actually test a restore.
When one process isn't enough: sharding
Here's a limit worth knowing before you hit it. Once your bot is in roughly 2,500 servers, Discord requires it to shard: split the gateway connection into multiple connections, each handling a slice of the servers. discord.js has a ShardingManager for this, and it changes how you start and think about the process.
You almost certainly won't need it early, and you shouldn't build for it prematurely. But it's the reason "just run more copies with PM2 cluster mode" is the wrong answer for scaling a bot. Cluster mode would give you duplicate logins; sharding is the sanctioned way to spread load. File it away for the day your bot gets popular.
Logs you'll actually want later
console.log("command received") is fine while you're messing around. Once real people depend on the bot, you want logs that tell you what happened when things went wrong: connection events, command usage, errors with real context, external services timing out. A structured logger like pino or winston gives you levels (info, warn, error) and timestamps, which turns "why is my bot broken" into "here's exactly what happened right before it broke." That's a much better place to start debugging from, and pm2 logs will still show all of it live.
Downtime happens, and that's fine
Even a well-run bot goes down sometimes. The downtime isn't the failure. The failure is not knowing why, or taking forever to notice. It's usually one of:
- A bug in your own code. An unhandled error, something
undefinedthat shouldn't be. - The server itself. Maintenance windows, network hiccups, provider issues.
- Discord's API having a bad day, which happens to everyone occasionally and isn't yours to fix. During a Discord outage your bot will reconnect on its own once the gateway recovers, so the right move is usually to wait, not to keep restarting.
When something's broken: a quick troubleshooting pass
Keep a short mental checklist for "the bot isn't working," because most of the time it's one of a small handful of things.
Bot shows offline, no obvious errors. Check pm2 status first. If it says errored or stopped, the process crashed and PM2 gave up after too many failed restarts. Run pm2 logs discord-bot --lines 50 and read from the top of the crash, not the bottom.
Bot logs in fine but never responds. Almost always intents. Either you didn't request the right GatewayIntentBits in code, or you didn't enable the matching privileged toggle (like Message Content) in the Developer Portal. Both have to match, and it's easy to forget since the bot logs in successfully either way.
"Cannot find module 'discord.js'" on the server. You forgot npm install after pulling new code, or node_modules wasn't rebuilt after an update added a dependency. Cheap fix, easy to miss.
Works locally, breaks on the server. Nine times out of ten it's an environment variable that lives in your local .env but never made it into the server's copy. Check both files actually match.
PM2 says online, but the bot isn't responding. This one's sneaky. The process is alive but stuck, often from an unhandled promise rejection or an infinite loop. pm2 monit gives you a live view of CPU and memory per process, which is a good way to spot something spinning that shouldn't be.
Bot restarts every few minutes. Check memory with pm2 monit or pm2 status. If it climbs steadily over time, you're likely looking at a memory leak. A listener added repeatedly without being removed is a classic cause, and PM2 is just restarting the process when it hits the limit you set.
Commands 429 or feel throttled. You're hitting REST rate limits, usually from editing or sending too fast in a loop. Batch the work or add a small delay. discord.js queues requests, but it can't invent capacity that Discord won't give you.
Getting comfortable reading pm2 logs is most of the battle. The error message is almost always telling you exactly what's wrong. The skill is reading the whole thing instead of skimming for the word "Error" and closing the terminal.
PM2 isn't the only way: systemd and Docker
PM2 is what I'd recommend for most people because it's simple, Node-specific, and fast to get running. It isn't the only way to keep a process alive on Linux, and two alternatives are worth knowing about.
systemd is the init system most modern distros already run, and it can supervise your bot through a service file. You write a small .service file describing how to start the bot and telling systemd to restart it if it exits, drop it in /etc/systemd/system/, then systemctl enable and systemctl start it. It works well and it's one less tool to install. The trade-off is more verbose syntax and no built-in log viewer or dashboard like PM2's. If you're already managing a fleet of services with systemd, it's a natural fit.
Docker goes the other direction: you package the bot and its exact Node version into a container image, so "works on my machine" and "works on the server" become the same environment. It's excellent once you have a database and a dashboard and want them to come up together with one command (docker compose up). For a single bot on a small VPS it's more moving parts than you need on day one, but it's the direction to grow toward if the project gets serious about reproducibility.
For a first bot, reach for PM2. Graduate to systemd or Docker when you have a concrete reason, not because a blog told you to.
A few security basics worth not skipping
It's easy to get the bot online and never think about security again. A handful of habits go a long way.
Lock down SSH. You already set up keys and a non-root user. Now finish the job: in /etc/ssh/sshd_config, set PasswordAuthentication no and PermitRootLogin no, then reload SSH. Confirm you can still log in with your key before you close your current session, so you don't lock yourself out.
Keep secrets out of your code, always.
// don't do this
client.login("my-secret-token");
// do this instead
client.login(process.env.TOKEN);And never let a .env file end up in a public repo. It happens more than you'd think, and a leaked token means someone else can run your bot until you reset it. If it ever does leak, reset the token in the Developer Portal immediately; that invalidates the old one on the spot.
Least privilege on the invite. Give the bot only the Discord permissions it needs. An "Administrator to be safe" bot is one bug or one compromise away from being a disaster in every server it's in.
Keep the firewall tight. Open only the ports you use. If you add a dashboard, put it behind a reverse proxy with HTTPS rather than exposing a raw Node port.
Actually back things up. Source code lives in Git, but your database and your .env do not. Back those up on a schedule, and every so often test that a restore works. A backup you've never restored is just a hope.
Mistakes worth avoiding (I've made most of these)
- Running everything as root. Set up a normal user for the bot. Five minutes now, a headache saved later.
- Uploading
node_modules. It's huge and pointless. Uploadpackage.jsonandpackage-lock.json, runnpm installon the server, done. - Installing Node from Ubuntu's default repo. You get an old version that current discord.js rejects. Use nvm or NodeSource.
- Forgetting the
.envon the server. Works locally, then silently fails because the token isn't there. - Running the bot in PM2 cluster mode. You'll get duplicate replies. A bot is one process; scaling is sharding's job.
- Restarting the bot over and over without reading logs.
pm2 logs discord-botusually tells you exactly what's wrong in the first few lines. Read it before you start guessing. - Committing the token, once, "just to test." Reset it and move on. Git remembers everything.
The setup that actually works, in one place
After all the options and caveats, here's the honest version of what holds up in the real world, the stack I'd set up today without a second thought:
- A cheap VPS from Hetzner, netcup, DigitalOcean, or Vultr, running the current Ubuntu LTS. 1GB of RAM is comfortable.
- A non-root user, SSH keys only, password login off, and
ufwallowing just SSH. - Node installed through nvm, not the Ubuntu package, so you're on a version discord.js actually supports and can bump later.
- The code deployed with Git, secrets in a
.envthat never leaves the server,node_modulesrebuilt on the box withnpm install. - PM2 running the bot from an
ecosystem.config.js, one process in fork mode,max_memory_restartset,pm2 startupandpm2 savedone, andpm2-logrotateinstalled. - Updates as
git pull && npm install && pm2 restart. - A database file (SQLite to start) backed up on a schedule, with a restore you've tested at least once.
- One uptime ping so you hear about downtime before your users do.
None of that is exotic. It's cheap, it's boring, and it stays up. Boring is the whole goal. Fancier setups exist, and you'll reach for them when you have a concrete reason, but this is the baseline that runs real bots in real communities without drama.
Where this eventually goes
Bots have a way of growing past "responds to a few commands." Give it enough time and you end up with a database, a web dashboard, background jobs, maybe a small API of its own. At that point it isn't a script anymore. It's a full application that happens to talk to Discord.
But the fundamentals stay the same the whole way: code, dependencies, config, a place to run, a way to keep it alive, and a way to update it safely. Once those click, they carry over to basically any project you build after this one. The VPS you rented for a Discord bot is the same VPS that can run a website, an API, or a game server. The muscle memory transfers completely.
A Discord bot is a genuinely good way to learn all of it, mostly because the feedback loop is so immediate. You push a change, and thirty seconds later you see it working, or not, in a real server. Not many first projects give you that.
The code is the idea. Getting it hosted properly is what turns it into something people can actually use.