A Discord bot only works while something is running it. Your laptop sleeps, loses Wi-Fi and gets closed at the end of class, so the bot goes offline with it. A small Linux server fixes that, and the free VPS is exactly the right size for one bot.
This guide takes you from a fresh server to a bot that answers a /ping slash command and survives crashes and reboots.
What you need
- A free VPS running Ubuntu 24.04, with the SSH address and port from your welcome email
- A Discord account and a server where you can add bots
- About 20 minutes
Create the bot in Discord
- Open the Discord Developer Portal and choose New Application. Give it a name.
- Under Bot, press Reset Token and copy the token. Treat it like a password: anyone with it controls your bot.
- Copy the Application ID from General Information. You’ll need it to register the slash command.
- Under OAuth2 → URL Generator, tick
botandapplications.commands, open the generated URL, and add the bot to your server.
Log in and create a user for the bot
Connect with the address and port from your welcome email:
ssh -p YOUR_SSH_PORT root@YOUR_SERVER_ADDRESS
Running services as root is a bad habit, so create a user just for the bot:
adduser --disabled-password --gecos "" bot
Install Node.js LTS
Ubuntu’s own Node.js package is older than discord.js needs, so install the current LTS from NodeSource:
apt update
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
apt install -y nodejs
node --version
Write the bot
Switch to the bot user and create a project:
su - bot
mkdir mybot && cd mybot
npm init -y
npm pkg set type=module
npm install discord.js
Create index.js:
import { Client, Events, GatewayIntentBits } from 'discord.js';
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, (c) => {
console.log(`Ready as ${c.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === 'ping') {
await interaction.reply('Pong from a free VPS');
}
});
client.login(process.env.DISCORD_TOKEN);
Slash commands are registered once, separately from the bot. Create deploy-commands.js:
import { REST, Routes, SlashCommandBuilder } from 'discord.js';
const commands = [
new SlashCommandBuilder().setName('ping').setDescription('Check the bot is alive'),
].map((command) => command.toJSON());
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
await rest.put(Routes.applicationCommands(process.env.CLIENT_ID), { body: commands });
console.log('Registered /ping');
Keep the token out of your code
Put secrets in a .env file that only the bot user can read:
cat > .env <<'EOF'
DISCORD_TOKEN=paste-your-token-here
CLIENT_ID=paste-your-application-id-here
EOF
chmod 600 .env
Register the command, then start the bot once by hand to check it works:
set -a; . ./.env; set +a
node deploy-commands.js
node index.js
You should see Ready as YourBot#1234. Type /ping in your Discord server. Global commands can take a little while to appear the first time. Press Ctrl+C to stop the bot, then type exit to go back to root.
Run it 24/7 with systemd
systemd starts the bot at boot and restarts it when it crashes. As root, create /etc/systemd/system/mybot.service:
[Unit]
Description=Discord bot
After=network-online.target
Wants=network-online.target
[Service]
User=bot
WorkingDirectory=/home/bot/mybot
EnvironmentFile=/home/bot/mybot/.env
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Enable and start it:
systemctl daemon-reload
systemctl enable --now mybot
systemctl status mybot
Follow its logs whenever you need to:
journalctl -u mybot -f
Keep the free VPS awake
The free VPS needs one click on Keep alive in the client area every 30 days. We email a reminder, and the bot keeps running while you do. If a project grows past 1 GB of memory or needs a dedicated IPv4 address, VPS Start is the next step up.
If something goes wrong
Error: An invalid token was provided: the token in.envis wrong or was reset. Copy a fresh one from the Developer Portal./pingdoesn’t appear: rerunnode deploy-commands.jsand checkCLIENT_IDis the Application ID, not the bot’s user ID.- The service keeps restarting:
journalctl -u mybot -n 50shows the last error.
How we made this: drafted with AI assistance, then edited. Technical testing is pending.