Skip to main content
How to run a bot in production and keep it connected. For the full list of things to check before going live, see Going to production.
ESM-only, Node >= 22@botmesh/core and the transport packages are pure ESM, with no CommonJS build. Your production package.json needs "type": "module" (or .mjs entry files), and the runtime must be Node.js 22 or later.
The examples below assume the layout create-botmesh generates: the entry file is src/bot.js, started with npm start (node --env-file-if-exists=.env src/bot.js). Adjust the paths if yours differ. If you write TypeScript, compile it first (tsc) and start the compiled file.

Option 1: a VPS with PM2

1. Prepare the server

2. First connection (WhatsApp on Baileys)

The session is saved under ./sessions/<session>. As long as that folder survives, the bot reconnects without a new scan. Telegram, Cloud API and the other token-based channels skip this step.

3. Keep it running with PM2

pm2 restart and pm2 stop only shut the bot down cleanly if it handles the signal: add the handler from Graceful shutdown (neither the core nor the generated bot.js installs one). PM2 kills the process kill_timeout ms after SIGINT (1600 ms by default), so keep --kill-timeout longer than the drain timeout.

Option 2: Docker

Dockerfile

With pnpm, replace the install lines with RUN corepack enable && pnpm install --prod --frozen-lockfile and copy pnpm-lock.yaml instead of package-lock.json. pnpm 10 and later skip the build script of better-sqlite3 until you approve it in pnpm-workspace.yaml (allowBuilds: with better-sqlite3: true on pnpm 11, onlyBuiltDependencies on pnpm 10; see SQLiteStore). Add a .dockerignore with node_modules, sessions, data, .env and *.qr.png, so credentials never end up in an image layer.

docker-compose.yml

Docker sends SIGTERM on docker compose stop and waits stop_grace_period before it kills the container: keep it longer than the drain timeout.

Environment variables

The framework reads no environment variable by itself: every credential and setting is a constructor option. Map your variables explicitly, for example:
Load a local .env with node --env-file-if-exists=.env, and in production take the values from your orchestrator or secret manager. Checking the required ones at startup (like env() above) makes a missing secret fail with one clear line instead of a provider error later.

Persistence

Sessions

The Baileys provider saves the WhatsApp credentials under sessionsDir/<session> (default ./sessions/<session>). Do not delete that folder, or you will have to scan the QR again:
The folder holds the account’s keys: treat it like a password (restricted permissions, encrypted backups, never in git or in an image).

Store

The default MemoryStore lives in RAM. In production use SQLiteStore for one process (or RedisStore for several replicas), so messages, chats, contacts, flow checkpoints and AI history survive restarts:
The folder of file must exist (SQLite creates the file, not its folders). With a persistent store, flow(...).persistent() checkpoints survive restarts. See Persistent store.

Backups

Back up the session and the data together: they are what lets a redeployed bot resume without a new QR scan or lost conversations.
Copy SQLite files while the bot is stopped, or use sqlite3 data/bot.db ".backup backup.db" while it runs: with WAL on, a plain copy of a live database can miss the latest writes. Take a backup before you upgrade @botmesh/sqlite-store, because a file migrated to a newer schema cannot be opened by an older release.

Graceful shutdown

bot.stop() disconnects the provider and ends active conversations. With { drain: true } it first waits for sends in flight, so a deploy does not cut a reply in half:
Replies still being sent when stop() begins, and those the provider already accepted, are recorded in the message log (after-send hooks and the store write, up to 5 s) before the store is closed, with or without drain. After the drain nothing keeps running: messages still queued for a chat are dropped, no new conversation starts until start(), paced replies still waiting resolve with { skipped: true } and running ctx.stream()s are cancelled without touching the transport again (they resolve with cancelled: true). No timer of the bot keeps the process alive afterwards. With durability, a message dropped this way is still in the journal and is processed on the next start.

Health checks

A small HTTP endpoint tells Docker, Kubernetes or your load balancer whether the bot is alive and connected. bot.getConnectionState() is 'connecting', 'authenticating', 'ready' or 'closed':
  • Use /healthz for liveness (restart the container when it fails) and /readyz for readiness (send traffic only while it passes).
  • With replicas and an exclusive transport (Baileys), the standby replicas are not connected on purpose, so their /readyz answers 503. Do not restart them for it.
  • Bind the port to a private interface or protect it: bot.metrics() holds no personal data, but it describes your traffic.
  • @botmesh/server serves /healthz and /readyz next to the webhook routes: see Webhook server.

Logging

Without logger, the bot logs through a built-in pino logger at warn, so warnings from plugins and handlers (for example a failed AI reply) are visible, and debug: true lowers it to debug. In production, pass your own logger to choose the level and the destination:
Chat and sender ids (phone numbers, emails) are hashed in the bot’s log lines by default. Use logIds: 'raw' for local debugging only.

Next steps