Discord.js + Docker + GitHub Actions CI/CD + one-click deploy.
Build your bot. Push to deploy.
English | 한국어
Part of Starter Series — Stop explaining CI/CD to your AI every time. Clone and start.
Docker Deploy · Discord Bot · Telegram Bot · Browser Extension · Electron App · npm Package · React Native · VS Code Extension · MCP Server · Python MCP Server · Cloudflare Pages
Via create-starter (recommended):
gh repo create my-discord-bot --template starter-series/discord-bot-starter --clone
cd my-discord-bot && npm install
npm run preflight
cp .env.example .env # fill in DISCORD_TOKEN + DISCORD_CLIENT_ID
npm run deploy-commands -- --dry-run
npm run deploy-commands
npm run devOr clone directly:
git clone https://github.com/starter-series/discord-bot-starter my-discord-bot
cd my-discord-bot && npm install
npm run preflight
cp .env.example .env
npm run deploy-commands -- --dry-run
npm run deploy-commands
npm run devSee docs/DISCORD_SETUP.md for the Discord Developer Portal walkthrough.
├── src/
│ ├── index.js # Entry point
│ ├── config.js # Environment config loader
│ ├── commands/ # Slash commands (auto-loaded)
│ │ ├── index.js # Loader
│ │ ├── ping.js # /ping — check latency
│ │ ├── help.js # /help — list commands
│ │ └── search.js # /search — autocomplete example
│ ├── events/ # Event handlers (auto-loaded)
│ │ ├── ready.js # Bot ready
│ │ ├── interactionCreate.js # Command router
│ │ ├── error.js # Client error logger
│ │ └── warn.js # Client warn logger
│ └── lib/
│ ├── health.js # HTTP health server
│ ├── logger.js # Structured logger
│ ├── rate-limiter.js # Per-user / per-command token bucket
│ └── safe-interaction.js # safeRespond() — reply/editReply/followUp wrapper that never throws
├── scripts/
│ ├── deploy-commands.js # Register commands with Discord API
│ ├── smoke.js # Local/CI build smoke without Discord network
│ └── bump-version.js # Bump package.json version
├── tests/ # Jest — command/config/runtime tests, coverage gated at 70 % statements
├── Dockerfile # Production container
├── docker-compose.yml # Dev with hot reload
├── .github/
│ ├── workflows/
│ │ ├── ci.yml # Lint, test, Docker build, Trivy scan
│ │ ├── codeql.yml # CodeQL static analysis
│ │ ├── cd-railway.yml # Deploy to Railway (action pinned by SHA)
│ │ ├── cd-fly.yml # Deploy to Fly.io
│ │ ├── maintenance.yml # Weekly CI health check
│ │ ├── stale.yml # Stale issue/PR sweep
│ │ └── setup.yml # First-use setup checklist
│ └── PULL_REQUEST_TEMPLATE.md
├── docs/
│ ├── DISCORD_SETUP.md # Discord Developer Portal guide
│ └── DEPLOY_GUIDE.md # Railway & Fly.io deployment guide
└── package.json
Things that exist in this repo today, backed by code on disk.
- Discord.js v14 — slash commands, embeds, auto-loaded command/event handlers.
- Starter code —
/ping,/help,/search(autocomplete pattern);ready,interactionCreate,error,warnevents. - Runtime safety
src/lib/safe-interaction.js—safeRespond(interaction, payload)picks the right method (reply/editReply/followUp) based on the interaction state, and swallows Discord API errors (expired interactions, double-ack, unknown message) instead of crashing the process.src/lib/rate-limiter.js— fixed-window per-key limiter. Global default is 5 invocations per user per 60 s; individual commands override that by exportingrateLimit: { window, max }.src/lib/health.js— HTTP/healthendpoint atHEALTH_PORT(default3000), wired intoDockerfileHEALTHCHECK.
- Supply-chain hardening
npm ci --ignore-scriptseverywhere — CI workflows, Railway CLI install, and the production Docker build. No install-time arbitrary code from transitive deps.package-lock.jsoncommitted and required bynpm ci.- All third-party GitHub Actions pinned by commit SHA, not a floating tag (
softprops/action-gh-release,superfly/flyctl-actions/setup-flyctl,aquasecurity/trivy-action). Bumps land via Dependabot only. gitleakspinned to8.30.1with sha256 checksum verification.- GitHub Secret Scanning + Push Protection + Dependabot security updates enabled at the repo level (push-time block, not just post-hoc scan).
- Runtime crash handling
process.on('uncaughtException')andprocess.on('unhandledRejection')route through the structured logger before exit — orchestrators get a stack, not silent death.- SIGINT/SIGTERM trigger graceful shutdown of the gateway client and health server.
--enable-source-mapsonnpm start/npm devfor legible stack traces.
- CI pipeline (every PR + push to main) —
npm audit, ESLint, Jest with--coverage(statements / branches / functions / lines all gated), Docker build, Trivy CRITICAL/HIGH scan. - Network-free validation —
npm run deploy-commands -- --dry-runserializes every slash command without Discord credentials;npm run buildruns the local smoke check used by CI. - CodeQL — static analysis on push/PR and weekly.
- CD pipelines — one-shot Railway or Fly.io deploy + auto GitHub Release; manual trigger from the Actions tab.
- Docker — production
Dockerfile+docker-compose.ymlfor hot-reload dev. - Version management —
npm run version:patch/minor/majorwith a git-tag duplicate guard in CD. - Maintenance automation — weekly CI health check, stale issue/PR sweep, first-use setup checklist.
- Bilingual README — English + Korean.
No active roadmap items. The template is in steady-state maintenance; security patches and Discord.js majors are applied via Dependabot. Open an issue if you have a concrete addition in mind.
Why this template is shaped the way it is.
Thin starter, not a framework. Sapphire and Akairo add structure on top of discord.js. This template deliberately does not. The value sits in CI/CD, Docker, the supply-chain posture, and the runtime-safety lib — not in a custom command dispatcher.
| This template | Sapphire / Akairo | |
|---|---|---|
| Philosophy | Thin starter + CI/CD + Docker | Full framework with runtime |
| Abstraction | Vanilla discord.js | Framework-specific patterns |
| CI/CD | Full pipeline included | Not included |
| Docker | Production-ready | Not included |
| Runtime deps | 2 (discord.js, dotenv) | 20+ |
| AI/vibe-coding | LLMs generate clean vanilla JS | LLMs must learn framework conventions |
| Best for | Utility bots, simple commands | Large bots with complex plugin systems |
Safety lives in src/lib/, not in the command files. Rate limiting and Discord-API error swallowing are imported helpers, not framework magic. Each command is one file you can read top-to-bottom in 30 seconds.
Supply-chain posture is the differentiator. --ignore-scripts, pinned actions, lockfile-enforced installs, and gitleaks checksum-verification are the kind of thing every Discord bot template should have — and most don't. That is the reason this starter exists.
JavaScript by default. Vanilla JS produces the cleanest output when an LLM writes the code, and it removes the build step. TypeScript is opt-in (see Non-goals).
Things this template will not become.
- A Discord bot framework. No command groups, no preconditions DSL, no plugin loader. If you need those, use Sapphire.
- TypeScript by default. The build step and type configuration aren't worth it for the most common use case (a few slash commands). Adding TS is a 4-step opt-in documented below, not a default.
- A multi-database starter. No ORM, no migration framework. Bring your own (Prisma, Drizzle, plain
pg) if you need persistence. - A Discord-feature kitchen sink. No voice, no music, no moderation toolkit. Slash commands + interactions only; everything else is yours to add.
- An auto-updating template. Once you clone, it's your code. There is no
starter-series upgradepath — that is by design (no hidden compatibility contract).
| Step | What it does |
|---|---|
| Security audit | npm audit for dependency vulnerabilities |
| Lint | ESLint for code quality |
| Test | Jest with --coverage, thresholds enforced in package.json |
| Docker build | Builds the container image to catch build errors |
| Trivy scan | Scans the container image for CRITICAL CVEs (SHA-pinned action) |
| Workflow | What it does |
|---|---|
CodeQL (codeql.yml) |
Static analysis for security vulnerabilities (push/PR + weekly) |
Maintenance (maintenance.yml) |
Weekly CI health check — auto-creates issue on failure |
Stale (stale.yml) |
Labels inactive issues/PRs after 30 days, auto-closes after 7 more |
| Step | What it does |
|---|---|
| Version guard | Fails if git tag already exists for this version |
| Deploy | Pushes to Railway or Fly.io |
| GitHub Release | Creates a tagged release with auto-generated notes |
How to deploy:
- Set up GitHub Secrets (see below)
- Bump version:
npm run version:patch(orversion:minor/version:major) - Go to Actions tab → Deploy to Railway (or Fly.io) → Run workflow
| Secret | Description |
|---|---|
RAILWAY_TOKEN |
Railway API token |
RAILWAY_SERVICE_ID |
Target service ID |
| Secret | Description |
|---|---|
FLY_API_TOKEN |
Fly.io deploy token |
See docs/DEPLOY_GUIDE.md for setup guide.
# Start with hot reload
npm run dev
# Or use Docker
docker compose up
# Register slash commands with Discord
npm run deploy-commands
# Validate command registration payloads without Discord credentials/network
npm run deploy-commands -- --dry-run
# Token-free preflight: build smoke + Jest
npm run preflight
# Build smoke used by CI (commands, events, .env.example)
npm run build
# Bump version (updates package.json)
npm run version:patch # 1.0.0 → 1.0.1
npm run version:minor # 1.0.0 → 1.1.0
npm run version:major # 1.0.0 → 2.0.0
# Lint & test
npm run lint
npm testCreate a new file in src/commands/:
// src/commands/echo.js
const { SlashCommandBuilder } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('echo')
.setDescription('Repeat your message')
.addStringOption(option =>
option.setName('text').setDescription('Text to repeat').setRequired(true)
),
// Optional: opt into rate limiting
rateLimit: { window: 5000, max: 3 },
async execute(interaction) {
const text = interaction.options.getString('text');
await interaction.reply(text);
},
};Then register: npm run deploy-commands
Before using a real token, validate the registration payload locally:
npm run deploy-commands -- --dry-runCommands are auto-loaded — no need to edit any other file.
See src/commands/search.js for the autocomplete pattern. Add .setAutocomplete(true) on an option and export an autocomplete(interaction) function alongside execute:
async autocomplete(interaction) {
const focused = interaction.options.getFocused().toLowerCase();
const matches = CHOICES
.filter((c) => c.toLowerCase().includes(focused))
.slice(0, 25) // Discord caps responses at 25 choices
.map((c) => ({ name: c, value: c }));
await interaction.respond(matches);
}The dispatcher in src/events/interactionCreate.js routes isAutocomplete() interactions to this handler automatically.
- Add
typescriptand@types/nodetodevDependencies - Add a
tsconfig.json - Update
npm startto build and run fromdist/ - Rename
.jsfiles to.ts
The bot exposes a tiny HTTP health server (src/lib/health.js) on HEALTH_PORT (default 3000). It's used by the Docker HEALTHCHECK and by Fly.io / Railway to detect a crashed or disconnected bot process.
| Path | Status | Body |
|---|---|---|
GET /health (client ready) |
200 |
{ "status": "ok", "uptime": <seconds>, "guilds": <count> } |
GET /health (starting / disconnected) |
503 |
{ "status": "starting", "uptime": <seconds>, "guilds": 0 } |
Configuration
# .env
HEALTH_PORT=3000 # change if 3000 is already taken on your hostFly.io — add an HTTP service check to fly.toml:
[[services]]
internal_port = 3000
protocol = "tcp"
[[services.http_checks]]
interval = "30s"
timeout = "5s"
grace_period = "30s"
method = "get"
path = "/health"Railway — set the service's health-check path to /health and port to 3000 under Settings → Deploy.
Docker — docker ps will show (healthy) / (unhealthy) status automatically; the HEALTHCHECK runs wget --spider http://localhost:${HEALTH_PORT}/health every 30s.
PRs welcome. Please use the PR template.