Build & deploy
Introduction
Section titled “Introduction”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.
Development
Section titled “Development”During development, you run TypeScript directly with tsx watch:
pnpm dev# → tsx watch src/app.tsThere’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();Building for production
Section titled “Building for production”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/.
pnpm build# → tsupAfter 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 tsup configuration
Section titled “The tsup configuration”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:
| Option | Value | Why |
|---|---|---|
entry | src/**/*.ts | Compile every TypeScript file individually |
format | esm | ES modules — required for Warden |
bundle | false | Preserve directory structure so .discover() can find compiled handlers |
clean | true | Remove stale files from previous builds |
outDir | dist | Output to dist/ directory |
target | node22 | Match your Node.js version |
Running in production
Section titled “Running in production”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();import {Bot, env} from '@warden/core';
const isDev = env('NODE_ENV', 'development') === 'development';const root = isDev ? './src' : './dist';const ext = isDev ? 'ts' : 'js';
new Bot() .discover(d => d .interactions(`${root}/{commands,buttons,modals,menus}/**/*.${ext}`) .events(`${root}/events/**/*.${ext}`) .jobs(`${root}/jobs/**/*.${ext}`) .hooks(`${root}/{errors,lifecycle}/**/*.${ext}`)) .hmr() // no-op in production .intents(i => i.defaults()) .partials(p => p.defaults()) .registerTo(isDev ? env('DEV_GUILD_ID') : undefined) .start();Start the production bot with:
node dist/app.jsA minimal package.json scripts section:
{ "scripts": { "dev": "tsx watch src/app.ts", "build": "tsup", "start": "node dist/app.js" }}Command registration
Section titled “Command registration”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.
Guild-scoped vs global
Section titled “Guild-scoped vs global”// Development: guild-scoped, updates instantly.registerTo(env('DEV_GUILD_ID'))
// Production: global registration (omit .registerTo())// Takes up to 1 hour to propagate to all guildsSkipping sync on boot
Section titled “Skipping sync on boot”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.
Docker services
Section titled “Docker services”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:
docker-compose up -dpnpm devIn production, you’ll typically point to managed database and cache services via environment variables rather than running Docker Compose alongside the bot.
Graceful shutdown
Section titled “Graceful shutdown”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:
-
Stop accepting — the bot stops processing new interactions
-
Wait for in-flight — any currently executing commands, events, or jobs are allowed to finish
-
Disconnect — the Discord gateway connection is closed cleanly
-
Plugin shutdown — each registered plugin runs its cleanup (closing database connections, flushing Redis, etc.)
-
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.