Skip to main content
Teramont Logo
How to host a Discord bot 24/7 on a VPS with Node.js and PM2
Back to Blog

How to host a Discord bot 24/7 on a VPS with Node.js and PM2

Mizael Segovia

8/1/2026 ·Mizael Segovia· 16 min read ·

8 views

To host a Discord bot 24/7, move the working Node.js project to a VPS, run it as an unprivileged user, and put PM2 in charge of the process. Save PM2’s process list and install the startup service it generates. The bot will then stay up when you close SSH, restart after a process crash, and return after the server reboots. This is resilient operation, not a promise of 100% uptime: the VPS, network, Discord, or the application itself can still fail.

This runbook starts with a working discord.js bot and targets Ubuntu 24.04 LTS. If you are still choosing a runtime, start with our discord.js vs discord.py comparison; this guide focuses on operating an existing Node.js bot.

What keeps a Discord bot running

A bot using the Discord Gateway maintains a persistent WebSocket, sends heartbeats, and reconnects or resumes when required. discord.js handles that protocol. PM2 sits at a different layer: it detaches the Node.js process from your terminal, restarts it after an exit, and can restore it during system startup.

The responsibilities are separate: PM2 handles a closed SSH session, a crash, and a reboot; the library handles Gateway recovery. PM2’s “online” state proves only that a process exists, so the runbook later adds a Discord-client readiness check.

Prerequisites and sizing guidance

You need SSH access through a sudo-capable administration account, a registered Discord application, a valid bot token, and either a repository or project copy containing package-lock.json. Use an actual bot user. Discord’s OAuth2 and bot documentation says developers must not automate normal user accounts, commonly called self-bots.

ResourceEvaluation baselineReasons to scale up
CPUOne vCPU may be enough to evaluate a small text bot.CPU-heavy commands, encryption, image work, browsers, and voice change the profile.
MemoryRoughly 1 GB gives the OS and a small bot more breathing room than a minimum-size instance.Large guild counts, caches, a local database, voice, or sidecars require measurement and more headroom.
DiskAllow for the OS, at least two releases, dependencies, and logs.Generated files, SQLite, audio, and long log retention increase the requirement.
NetworkStable outbound HTTPS/WSS access to Discord and any services the bot calls.Inbound webhooks, a dashboard, or OAuth2 callbacks add ports, TLS, and reverse-proxy work.

These figures are starting points, not universal minimums. Measure CPU, memory, storage, and latency under your own workload before committing to a plan.

1. Prepare Ubuntu and isolate the bot user

Connect with the administration account. Install the small set of tools used below and create a dedicated system identity. Neither the bot nor its PM2 daemon should run as root.

sudo apt update
sudo apt install --yes ca-certificates curl git nano rsync ufw xz-utils

sudo adduser --system --group --home /home/discordbot --shell /bin/bash discordbot
sudo install -d -o discordbot -g discordbot -m 0750 /opt/discord-bot
sudo -u discordbot install -d -m 0750 /opt/discord-bot/releases /opt/discord-bot/shared

If discordbot already exists, inspect its home, group, and shell instead of creating it again. A Gateway-only bot opens an outbound connection and does not need a public inbound port. Before enabling UFW, replace the SSH rule if the host uses a custom port and confirm that the provider has a recovery console.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status verbose

A web dashboard, inbound webhook receiver, or OAuth2 callback changes the design. Expose only the required HTTPS reverse-proxy port, never the Node.js process directly.

2. Install a supported Node.js LTS release

At the July 31, 2026 factual cutoff, Node.js 24 is Active LTS and Node.js 22 is Maintenance LTS. The July 29 security release shipped patched builds 24.18.1 and 22.23.2. The Node project recommends Active LTS or Maintenance LTS lines for production on its release schedule.

The commands below download the official 24.18.1 AMD64 or ARM64 archive and verify its checksum before extraction. If a dependency still requires Node 22, set NODE_VERSION to 22.23.2. On a future deployment, select the current patched LTS version after reviewing its security notes.

(
set -euo pipefail

ARCH="$(dpkg --print-architecture)"
case "$ARCH" in
  amd64) NODE_ARCH="x64" ;;
  arm64) NODE_ARCH="arm64" ;;
  *) printf 'Unsupported architecture: %s\n' "$ARCH" >&2; exit 1 ;;
esac

NODE_VERSION="24.18.1"
NODE_DIST="node-v${NODE_VERSION}-linux-${NODE_ARCH}"
DOWNLOAD_DIR="$(mktemp -d)"
trap 'rm -rf "$DOWNLOAD_DIR"' EXIT
cd "$DOWNLOAD_DIR"

curl --fail --silent --show-error --location --remote-name \
  "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_DIST}.tar.xz"
curl --fail --silent --show-error --location --remote-name \
  "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt"
grep " ${NODE_DIST}.tar.xz$" SHASUMS256.txt | sha256sum --check --strict

sudo install -d -m 0755 /usr/local/lib/nodejs
sudo tar -xJf "${NODE_DIST}.tar.xz" -C /usr/local/lib/nodejs
for binary in node npm npx corepack; do
  sudo ln -sfn "/usr/local/lib/nodejs/${NODE_DIST}/bin/${binary}" "/usr/local/bin/${binary}"
done

node --version
npm --version
)

The last two commands are checks for you to run. Confirm that they report the intended branch; no output is assumed here.

3. Publish a reproducible release

A releases/ directory plus a current symlink lets you switch versions without overwriting the last one. Deploy an immutable tag or reviewed commit rather than the moving tip of main.

Option A: clone with Git

Replace the example URL and tag. For a private repository, use a read-only deploy key. Never put a personal access token in the repository URL.

sudo -iu discordbot
set -euo pipefail
export RELEASE_ID="initial-v1.0.0"
export RELEASE_TAG="v1.0.0"
export REPOSITORY_URL="https://example.invalid/OWNER/REPOSITORY.git"

git clone --branch "$RELEASE_TAG" --depth 1 "$REPOSITORY_URL" "/opt/discord-bot/releases/$RELEASE_ID"
ln -sfnT "/opt/discord-bot/releases/$RELEASE_ID" /opt/discord-bot/current
cd /opt/discord-bot/current
git rev-parse HEAD
exit

Record the hash returned on your server in the deployment log; this guide does not invent that value.

Option B: copy the project from your workstation

Run this on your local machine, excluding secrets, installed dependencies, and logs:

rsync -az \
  --include='.env.example' \
  --exclude='.env*' \
  --exclude='.git' \
  --exclude='node_modules' \
  --exclude='*.log' \
  ./ VPS_ADMIN@VPS_HOST:/tmp/discord-bot-upload/

Then publish the upload on the VPS with the correct ownership:

sudo install -d -o discordbot -g discordbot -m 0750 /opt/discord-bot/releases/initial-copy
sudo rsync -a /tmp/discord-bot-upload/ /opt/discord-bot/releases/initial-copy/
sudo chown -R discordbot:discordbot /opt/discord-bot/releases/initial-copy
sudo -iu discordbot ln -sfnT /opt/discord-bot/releases/initial-copy /opt/discord-bot/current

4. Keep the token out of Git and install dependencies

Store the token at /opt/discord-bot/shared/.env, outside every release. Open it in an interactive editor so the secret never becomes part of shell history.

sudo -iu discordbot
umask 077
nano /opt/discord-bot/shared/.env

The file has this shape. Replace the marker inside the editor, never in a command you paste or share:

dotenv
DISCORD_TOKEN=REPLACE_INSIDE_THE_EDITOR

Lock down the file and inspect its metadata. It should report mode 600 and owner discordbot.

chmod 600 /opt/discord-bot/shared/.env
stat -c '%a %U:%G %n' /opt/discord-bot/shared/.env

Keep the same policy in the project’s .gitignore, and check common variants with git ls-files -- '.env' '.env.*'. Only .env.example may appear, and only when it contains no secrets:

gitignore
.env
.env.*
!.env.example
node_modules/
*.log
cd /opt/discord-bot/current
git ls-files -- '.env' '.env.*'
npm ci --omit=dev

npm ci requires a lockfile and reproduces it. If TypeScript or another asset pipeline needs development packages, build and test in CI, or install all packages temporarily, build, and finish with npm prune --omit=dev. Do not run an ad-hoc npm install in production that rewrites the lockfile.

Before introducing PM2, you can run one short foreground test after replacing src/index.js with the real entry point. Keep the local instance stopped so the same bot does not open two sessions, and press Ctrl+C after you observe Ready.

cd /opt/discord-bot/current
NODE_ENV=production /usr/local/bin/node --env-file=/opt/discord-bot/shared/.env ./src/index.js

If the token has ever appeared in Git, a log, a screenshot, or a visible command, treat it as compromised. Regenerate it in the Developer Portal, update the file, and restart the bot. Removing it from the latest commit does not invalidate leaked copies.

Declare only the Gateway intents and permissions each feature needs. Discord’s Developer Policy limits API data use to what is necessary for the stated functionality; extra events also expand cache use, failure surface, and data exposure.

5. Run PM2 as the bot user

Install PM2 into discordbot’s own prefix. Run every later PM2 command as that same identity. PM2 keeps per-user state, so root and discordbot would see different process lists and daemons.

npm config set prefix "$HOME/.local"
npm install --global pm2
grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.profile" || printf '%s\n' 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.profile"
export PATH="$HOME/.local/bin:$PATH"
pm2 --version

Create /opt/discord-bot/ecosystem.config.cjs. The .cjs suffix keeps module.exports in CommonJS mode even when the application sets "type": "module". Replace script with the bot’s actual entry point.

nano /opt/discord-bot/ecosystem.config.cjs

Keep your Discord bot online without relying on your PC

Run your bot on infrastructure designed for persistent processes, restarts, and ongoing operations.

Premium Character
View Discord bot hosting
module.exports = {
  apps: [
    {
      name: 'discord-bot',
      cwd: '/opt/discord-bot/current',
      script: './src/index.js',
      interpreter: '/usr/local/bin/node',
      node_args: '--env-file=/opt/discord-bot/shared/.env',
      exec_mode: 'fork',
      instances: 1,
      autorestart: true,
      watch: false,
      min_uptime: '10s',
      max_restarts: 10,
      restart_delay: 5000,
      max_memory_restart: '512M',
      kill_timeout: 5000,
      time: true,
      env: {
        NODE_ENV: 'production',
        HEALTH_PORT: '3001'
      }
    }
  ]
};

PM2’s ecosystem file documentation covers the working directory, script, and restart controls used here. The bot token stays out of this file. One small bot runs as a single forked instance: adding generic cluster workers without deliberate sharding, locks, and idempotent jobs can duplicate replies and scheduled work.

restart_delay, min_uptime, and max_restarts contain a crash loop; they do not fix it. The max_memory_restart value is only a sample. Tune it below the VPS limit while leaving room for the OS. A memory restart may contain the immediate blast radius, but it is not a substitute for profiling and fixing a leak.

6. Add a Gateway readiness check

PM2 can see a process, not whether the Discord client is ready. Add a local endpoint in the module where client already exists. This discord.js 14.x example returns 200 only when client.isReady() is true and listens on loopback, so it does not create a public endpoint. Check the API against the installed version’s Client documentation.

import http from 'node:http';

const healthPort = Number(process.env.HEALTH_PORT || 3001);

const healthServer = http.createServer((request, response) => {
  if (request.url !== '/healthz') {
    response.writeHead(404);
    response.end();
    return;
  }

  const ready = client.isReady();
  const body = JSON.stringify({
    ready,
    gatewayPingMs: ready ? client.ws.ping : null
  });

  response.writeHead(ready ? 200 : 503, {
    'content-type': 'application/json',
    'cache-control': 'no-store'
  });
  response.end(body);
});

healthServer.listen(healthPort, '127.0.0.1');

Adapt the import if the project uses CommonJS, confirm that DISCORD_TOKEN matches the variable your application reads, and close the server from your signal handler if the bot already implements graceful shutdown. Next, check the PM2 file, start it, and inspect process state and logs:

node --check /opt/discord-bot/ecosystem.config.cjs
pm2 start /opt/discord-bot/ecosystem.config.cjs
pm2 status
pm2 describe discord-bot
pm2 logs discord-bot --lines 100

Ctrl+C exits the log stream; it does not stop the bot. After the application logs Ready, run the local readiness probe:

curl --fail --silent --show-error http://127.0.0.1:3001/healthz

Production validation has three layers: PM2 reports the process online, /healthz reports a ready client, and a safe slash command in a private test server receives a reply. Do not automate the last check with a normal user account. For external monitoring, emit an outbound heartbeat or place the route behind authenticated HTTPS.

7. Restart the bot after a reboot

PM2’s startup documentation separates two jobs: registering a startup service and saving the process list that it will restore. As discordbot, run:

pm2 startup
exit

PM2 prints a command tailored to the service manager, user, home directory, and PATH. The block then uses exit to return to the sudo-enabled account; from there, run that exact generated command. Do not assemble one by hand or reuse one from another server. Then switch back to the bot user and save the current list:

sudo -iu discordbot
export PATH="$HOME/.local/bin:$PATH"
pm2 save

Schedule a maintenance window, reboot, and reconnect:

exit
sudo reboot

Once SSH is available again, verify the service, the per-user PM2 state, and actual readiness:

sudo systemctl status pm2-discordbot --no-pager
sudo -iu discordbot
export PATH="$HOME/.local/bin:$PATH"
pm2 status
pm2 describe discord-bot
curl --fail --silent --show-error http://127.0.0.1:3001/healthz

If the generated command created a different service name, use that instead of pm2-discordbot. The deployment is not complete until this reboot test passes. After changing the Node.js installation, PM2 recommends regenerating its startup script so the service uses the current runtime. Follow the host-specific output from pm2 unstartup and pm2 startup, then save the list again.

8. Keep logs useful and bounded

PM2 writes logs under $HOME/.pm2/logs by default and can filter them by process, as described in its log management guide. Use a finite window for routine diagnosis and watch disk space as a separate metric.

pm2 logs discord-bot --lines 200 --nostream
pm2 monit
df -h

Install rotation as discordbot. The following settings are an initial policy, not a universal retention requirement; tune them for log volume, available storage, and compliance needs.

pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true
pm2 save

Never log the token, authorization headers, private data, or complete payloads you do not need. Useful operational events include startup, Ready, disconnect with its code, resume, contextual errors, and shutdown.

9. Update by tag and keep a rollback path

Test each release tag in CI and, where practical, staging. Build a new server-side release without touching the active one, install its production dependencies, and record the previous target before the atomic switch:

export PATH="$HOME/.local/bin:$PATH"
set -euo pipefail

export RELEASE_TAG="v1.0.1"
export RELEASE_ID="$(date -u +%Y%m%dT%H%M%SZ)-${RELEASE_TAG}"
export REPOSITORY_URL="https://example.invalid/OWNER/REPOSITORY.git"

git clone --branch "$RELEASE_TAG" --depth 1 "$REPOSITORY_URL" "/opt/discord-bot/releases/$RELEASE_ID"
cd "/opt/discord-bot/releases/$RELEASE_ID"
npm ci --omit=dev
node --check ./src/index.js

CURRENT_RELEASE="$(readlink -f /opt/discord-bot/current)"
printf '%s\n' "$CURRENT_RELEASE" > /opt/discord-bot/shared/previous-release.tmp
mv -f /opt/discord-bot/shared/previous-release.tmp /opt/discord-bot/shared/previous-release

NEXT_LINK="/opt/discord-bot/.current-next"
ln -sfnT "/opt/discord-bot/releases/$RELEASE_ID" "$NEXT_LINK"
mv -Tf "$NEXT_LINK" /opt/discord-bot/current
pm2 restart /opt/discord-bot/ecosystem.config.cjs --only discord-bot --update-env

Replace the syntax check with the project’s own validated pipeline where appropriate; it is not a functional test. Check the new release before saving PM2 state:

pm2 status
pm2 logs discord-bot --lines 100 --nostream
curl --fail --silent --show-error http://127.0.0.1:3001/healthz
pm2 save

If validation fails, point current back to the recorded release. This rolls back code and dependencies, not data or database migrations:

set -euo pipefail
PREVIOUS_RELEASE="$(cat /opt/discord-bot/shared/previous-release)"
case "$PREVIOUS_RELEASE" in
  /opt/discord-bot/releases/*) ;;
  *) printf 'Invalid previous release path\n' >&2; exit 1 ;;
esac
test -d "$PREVIOUS_RELEASE"
NEXT_LINK="/opt/discord-bot/.current-next"
ln -sfnT "$PREVIOUS_RELEASE" "$NEXT_LINK"
mv -Tf "$NEXT_LINK" /opt/discord-bot/current
pm2 restart /opt/discord-bot/ecosystem.config.cjs --only discord-bot --update-env
pm2 logs discord-bot --lines 100 --nostream
curl --fail --silent --show-error http://127.0.0.1:3001/healthz
pm2 save

For SQLite or any other mutable state, use backward-compatible migrations or a tested restore plan. Keep at least one known-good release until the new version is proven.

10. Backups and ongoing maintenance

  • Code: retain immutable tags/commits and package-lock.json. A remote repository does not back up production-generated data.
  • Secrets: keep the token in an approved secret manager or encrypted backup with restricted access, never in Git or an unencrypted archive.
  • Data: back up SQLite, uploaded files, and persistent configuration with an application-consistent method. Copying a live database can produce an unusable backup if its own backup mechanism is ignored.
  • Configuration: retain a reviewed, secret-free copy of ecosystem.config.cjs and record the deployed Node, PM2, and commit versions.
  • Recovery: regularly prove that you can restore data, publish a release, and run pm2 resurrect from the saved process list.

If the bot uses SQLite, its official CLI provides the .backup command for copying the open database. Adjust both paths, move the result off the VPS over an encrypted channel, and test restoration. This example does not include .env:

exit
sudo apt install --yes sqlite3
sudo -iu discordbot
BACKUP_ID="$(date -u +%Y%m%dT%H%M%SZ)"
install -d -m 0700 /opt/discord-bot/backups
sqlite3 /opt/discord-bot/shared/data/bot.sqlite ".backup '/opt/discord-bot/backups/bot-${BACKUP_ID}.sqlite'"
sha256sum "/opt/discord-bot/backups/bot-${BACKUP_ID}.sqlite" > "/opt/discord-bot/backups/bot-${BACKUP_ID}.sqlite.sha256"

As routine maintenance, review Node and dependency advisories, patch Ubuntu during a window, inspect PM2 restart counts, Gateway latency, memory growth, and disk usage, and repeat the reboot test after Node, PM2, or systemd changes.

Troubleshooting common symptoms

SymptomLikely causeWhat to inspectCorrective action
Online locally, offline after closing SSHThe bot was launched with node in the shell, or PM2 ran as another user.Run sudo -iu discordbot and pm2 status.Start the ecosystem as discordbot. Leaving pm2 logs does not stop it.
Online until rebootThe startup service is missing or the process list was not saved.Check systemctl status pm2-discordbot and pm2 status.Run pm2 startup, execute its exact generated command, then run pm2 save.
Invalid tokenThe token was revoked, .env contains an edit error, permissions block access, or the secret leaked.Inspect file metadata and logs without printing the token.Regenerate when in doubt, edit the file, apply mode 600, and restart.
Missing events or message contentAn intent is missing in code or, for a privileged intent, in the Developer Portal.Compare declared intents with the bot’s actual feature set.Enable only what is required in both places and avoid unnecessary data collection.
Repeated reconnects or HTTP 429sUnstable networking, a login loop, fast crash restarts, or REST calls without backoff.Look for disconnect codes, Ready/Resume events, and 429s in logs.Let the library manage Gateway recovery and honor Retry-After/retry_after from Discord’s official rate-limit documentation.
Crash loopStartup exception, missing package, bad configuration, or incompatible runtime.Use pm2 describe discord-bot and finite log output.Stop the cascade, fix the release, or roll back to the known-good version.
Memory keeps growingUnbounded cache, duplicate listeners, collectors, or a leak.Use pm2 monit, host metrics, and a profiler.Reduce state and intents, remove duplicate listeners, and profile. Treat max_memory_restart only as containment.

At that point, “24/7” no longer means leaving a terminal open. It means a supervised process, repeatable startup, a meaningful health signal, recoverable data, and a known-good release ready to restore.

How to host a Discord bot 24/7 on a VPS with Node.js and PM2
GeneralDiscordDiscord botsdiscord.jsNode.jsJavaScriptBot hostingServer Administration
Did you like this article?Share it:

About the Author

Mizael Segovia

Mizael Segovia

CEO & Desarrollador Full Stack y DevOps en Teramont Host

Keep exploring related guides, news, and analysis.

CTA Pattern

Need Help with Your Server?

Our team is ready to help with any questions or issues you may have.

Contact Us