Skip to content

Build & deploy

Warden is designed to make the path from development to production as smooth as possible. In development, there’s no build step — you run TypeScript directly. In production, tsup compiles your files while preserving the directory structure that .discover() relies on. The same patterns work in both environments.

During development, you run TypeScript directly with tsx watch:

Terminal window
pnpm dev
# → tsx watch src/app.ts

There’s no compile step, no watch-then-build pipeline. tsx executes your TypeScript files on the fly, and watch restarts the process when files change. Combined with HMR, this gives you a fast, seamless development loop.

Your bootstrap file in development:

import {Bot, env} from '@warden/core';
new Bot()
.discover(d => d
.interactions('./src/{commands,buttons,modals,menus}/**/*.ts')
.events('./src/events/**/*.ts')
.jobs('./src/jobs/**/*.ts')
.hooks('./src/{errors,lifecycle}/**/*.ts'))
.hmr()
.intents(i => i.defaults())
.partials(p => p.defaults())
.registerTo(env('DEV_GUILD_ID'))
.start();

Warden uses tsup to compile your TypeScript for production. The key detail is bundle: false — tsup compiles each file individually rather than bundling everything into a single output. This preserves your directory structure, which is essential for .discover() to find the same files in dist/ that it found in src/.

Terminal window
pnpm build
# → tsup

After building, your dist/ directory mirrors your src/ directory:

  • Directorysrc/
    • Directorycommands/
      • Directorymod/
        • ModCommand.ts
        • ModWarnCommand.ts
        • ModBanCommand.ts
    • Directoryevents/
      • ReadyEvent.ts
    • Directorymiddleware/
      • IsModerator.ts
    • Directoryconfig/
      • moderation.ts
    • app.ts
  • Directorydist/
    • Directorycommands/
      • Directorymod/
        • ModCommand.js
        • ModWarnCommand.js
        • ModBanCommand.js
    • Directoryevents/
      • ReadyEvent.js
    • Directorymiddleware/
      • IsModerator.js
    • Directoryconfig/
      • moderation.js
    • app.js

This means a discovery rooted at ./dist/...js finds the exact same handlers as one rooted at ./src/...ts — just the compiled versions.

The scaffolder generates a tsup.config.ts that works out of the box:

import {defineConfig} from 'tsup';
export default defineConfig({
entry: ['src/**/*.ts'],
format: ['esm'],
bundle: false,
clean: true,
outDir: 'dist',
target: 'node22',
});

Let’s break down why each option matters:

OptionValueWhy
entrysrc/**/*.tsCompile every TypeScript file individually
formatesmES modules — required for Warden
bundlefalsePreserve directory structure so .discover() can find compiled handlers
cleantrueRemove stale files from previous builds
outDirdistOutput to dist/ directory
targetnode22Match your Node.js version

Your production bootstrap file points discovery at the compiled output and drops development-only features:

import {Bot} from '@warden/core';
new Bot()
.discover(d => d
.interactions('./dist/{commands,buttons,modals,menus}/**/*.js')
.events('./dist/events/**/*.js')
.jobs('./dist/jobs/**/*.js')
.hooks('./dist/{errors,lifecycle}/**/*.js'))
.intents(i => i.defaults())
.partials(p => p.defaults())
.start();

Start the production bot with:

Terminal window
node dist/app.js

A minimal package.json scripts section:

{
"scripts": {
"dev": "tsx watch src/app.ts",
"build": "tsup",
"start": "node dist/app.js"
}
}

By default, .start() syncs your slash commands with Discord’s REST API on every boot. In development, this is exactly what you want — commands are always up to date. In production, you may want more control.

// Development: guild-scoped, updates instantly
.registerTo(env('DEV_GUILD_ID'))
// Production: global registration (omit .registerTo())
// Takes up to 1 hour to propagate to all guilds

If you sync commands as part of your CI/CD pipeline (using warden sync), you can skip the sync at startup:

new Bot()
.discover(d => d
.interactions('./dist/{commands,buttons,modals,menus}/**/*.js')
.events('./dist/events/**/*.js'))
.intents(i => i.defaults())
.partials(p => p.defaults())
.start({sync: false});

This is useful in production where you want deterministic deploys — commands are synced once during deploy, not on every process start. See the CLI sync guide for more details.

If your bot uses a database or Redis cache, the scaffolder generates a docker-compose.yml to run these services locally:

services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: modbot
POSTGRES_PASSWORD: secret
POSTGRES_DB: modbot
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- '6379:6379'
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:

Start services before running the bot:

Terminal window
docker-compose up -d
pnpm dev

In production, you’ll typically point to managed database and cache services via environment variables rather than running Docker Compose alongside the bot.

When Warden receives a SIGTERM or SIGINT signal (from Docker stopping the container, a process manager, or Ctrl+C), it performs a graceful shutdown sequence:

  1. Stop accepting — the bot stops processing new interactions

  2. Wait for in-flight — any currently executing commands, events, or jobs are allowed to finish

  3. Disconnect — the Discord gateway connection is closed cleanly

  4. Plugin shutdown — each registered plugin runs its cleanup (closing database connections, flushing Redis, etc.)

  5. Exit — the process exits with code 0

This means in-flight handlers won’t be interrupted mid-execution. If a command is in progress when the shutdown signal arrives, it finishes before the process exits.