Discord Bots

Deploying a discord.js v14 Bot Step by Step

Build a minimal discord.js v14 bot with slash commands, then deploy it to an always-on server with environment variables, git and automatic restarts.

On this page
  1. Create the application and bot
  2. Set up the project
  3. Write a command
  4. Write the bot
  5. Register the command
  6. Add scripts
  7. Deploy to the server
  8. Keep it healthy
  9. Load environment variables locally
  10. Structure for growth
  11. Updating and rolling back
  12. Troubleshooting the first deploy
  13. Summary

This guide takes a discord.js v14 bot from an empty folder to a running production deployment. You’ll set up the project, add a slash command, register it, and deploy it so it stays online after you close your laptop.

You’ll need Node.js 18 or newer locally, a Discord application with a bot user, and a hosting server. Kerit Cloud supports Node.js 16 through 22; discord.js v14 needs Node.js 16.11 or newer, and we recommend 20 or 22.

Create the application and bot

  1. Open the Discord Developer Portal and create a new application.
  2. Under Bot, reset the token and copy it somewhere safe. You’ll only see it once.
  3. Under OAuth2 → URL Generator, select the bot and applications.commands scopes, choose the permissions your bot needs, and open the generated URL to invite it to a test server.

Also note the Application ID from the General Information page — you’ll need it to register commands.

Set up the project

mkdir my-bot && cd my-bot
npm init -y
npm install discord.js

Create the folder layout:

my-bot/
├── commands/
│   └── ping.js
├── deploy-commands.js
├── index.js
├── package.json
└── .gitignore

Add a .gitignore straight away so secrets and dependencies never reach your repository:

node_modules/
.env

Write a command

Each command lives in its own file and exports its definition and handler. commands/ping.js:

const { SlashCommandBuilder } = require('discord.js');

module.exports = {
  data: new SlashCommandBuilder()
    .setName('ping')
    .setDescription('Replies with the gateway latency.'),
  async execute(interaction) {
    await interaction.reply(`Pong! ${interaction.client.ws.ping} ms`);
  },
};

Write the bot

index.js loads every command file, then routes interactions to the right handler:

const fs = require('node:fs');
const path = require('node:path');
const { Client, Collection, Events, GatewayIntentBits } = require('discord.js');

const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.commands = new Collection();

const dir = path.join(__dirname, 'commands');
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.js'))) {
  const command = require(path.join(dir, file));
  client.commands.set(command.data.name, command);
}

client.once(Events.ClientReady, (c) => {
  console.log(`Ready as ${c.user.tag} in ${c.guilds.cache.size} servers`);
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand()) return;
  const command = client.commands.get(interaction.commandName);
  if (!command) return;
  try {
    await command.execute(interaction);
  } catch (err) {
    console.error(`/${interaction.commandName} failed:`, err);
    const reply = { content: 'Something went wrong.', ephemeral: true };
    if (interaction.replied || interaction.deferred) await interaction.followUp(reply);
    else await interaction.reply(reply);
  }
});

process.on('unhandledRejection', (err) => console.error('Unhandled rejection:', err));

client.login(process.env.DISCORD_TOKEN);

Notice the bot only requests the Guilds intent. Slash commands don’t need message content, so you avoid a privileged intent entirely. See gateway intents explained for when you do need more.

Register the command

Slash commands are registered with Discord’s REST API, separately from logging in. deploy-commands.js:

const fs = require('node:fs');
const path = require('node:path');
const { REST, Routes } = require('discord.js');

const commands = fs.readdirSync(path.join(__dirname, 'commands'))
  .filter((f) => f.endsWith('.js'))
  .map((f) => require(`./commands/${f}`).data.toJSON());

const rest = new REST().setToken(process.env.DISCORD_TOKEN);

(async () => {
  await rest.put(Routes.applicationCommands(process.env.CLIENT_ID), { body: commands });
  console.log(`Registered ${commands.length} global commands.`);
})();

Run it once whenever you add or change a command — not on every start. Registering on every boot wastes API calls and can hit rate limits. Global commands are available in every server the bot is in; if one doesn’t appear, check our slash command troubleshooting guide.

Add scripts

In package.json:

"scripts": {
  "start": "node index.js",
  "deploy": "node deploy-commands.js"
}

Test locally by exporting the variables and running npm run deploy then npm start. On macOS or Linux:

export DISCORD_TOKEN=your-token CLIENT_ID=your-application-id
npm run deploy && npm start

Deploy to the server

Push the project to a GitHub repository (the .gitignore keeps node_modules and .env out). Then on Kerit Cloud:

  1. Create a server on the free tier or a Discord bot plan and select a Node.js 20 or 22 runtime.
  2. Connect the repository for git deploys, or upload the files through the file manager or SFTP.
  3. Add environment variables: DISCORD_TOKEN and CLIENT_ID. They’re stored encrypted and kept out of your logs.
  4. Set the start command to npm start if it isn’t detected automatically.
  5. Start the server and watch the console for your “Ready as…” line.

Dependencies install automatically from package.json. From now on, every git push pulls the new commit, reinstalls if needed and restarts the bot — the workflow is covered in git deploy for Discord bots.

Note: Run npm run deploy from the server console (or locally) after adding commands. Deploying code doesn’t register commands by itself.

Keep it healthy

  • Crashes restart automatically. A watchdog brings the process back within seconds, and the error stays visible in the console log.
  • Log with context. Include the command name and guild ID in error logs so you can reproduce problems.
  • Watch memory. discord.js caches aggressively. If usage creeps up, limit caches as shown in reducing memory usage in discord.js bots.
  • Pin major versions. Keep discord.js on ^14 so an unexpected major release can’t break your deploy.

Load environment variables locally

Typing export every time gets old. For local development, install dotenv and keep variables in a .env file that git ignores:

npm install dotenv
# .env — never commit this file
DISCORD_TOKEN=your-token
CLIENT_ID=your-application-id

Add one line at the very top of index.js and deploy-commands.js:

require('dotenv').config();

On the server, variables come from the panel instead, and dotenv simply finds no file and does nothing. The same code works in both places. There’s more on this pattern in using .env files in Node.js and Python.

Structure for growth

Once you have more than a couple of event handlers, give events the same treatment as commands: one file per event in an events/ folder, each exporting a name, an optional once flag and an execute function. Your index.js then loops over the folder and calls client.once or client.on for each. The main file stays short, and adding a feature means adding a file rather than editing a growing switch statement.

Keep configuration — guild IDs, colours, role names — in environment variables or a small config module rather than scattered through command files. When you later run a staging copy of the bot, you’ll only need to change variables.

Updating and rolling back

With git deploys, updating is a commit and a push. Rolling back is just as simple: revert the bad commit and push again, and the server redeploys the previous working code.

git revert HEAD
git push

Because dependencies install from package.json, commit your package-lock.json too. It pins exact versions, so the server installs the same dependency tree you tested locally rather than whatever the newest compatible release happens to be that day.

Troubleshooting the first deploy

Error [TokenInvalid]: An invalid token was provided. The DISCORD_TOKEN variable is missing, empty or has stray spaces or quotes. Re-paste it in the panel.

Cannot find module 'discord.js'. Dependencies didn’t install — usually because package.json wasn’t uploaded, or discord.js is missing from dependencies. Check the install output at the top of the console.

The bot is online but /ping isn’t listed. Commands weren’t registered, or you registered them under a different application ID. Run npm run deploy again and confirm CLIENT_ID matches the application.

“The application did not respond.” Your handler took longer than three seconds to reply. For slow work, call await interaction.deferReply() first, then editReply() when you’re done.

Summary

A clean discord.js v14 bot has one file per command, a separate script to register commands, and a token read from the environment. Push it to git, connect the repository, set DISCORD_TOKEN and CLIENT_ID, and start it. After that, a push is a deploy, and crashes recover on their own.