Most "support ticket" setups on Discord are a subscription: a hosted bot, a monthly plan, a dashboard you don't control. I wrote a ticket + moderation bot called ARBITER that goes the other way. You run it, you own it, forever. One npm install, one config file, no database.
This post is the architecture story: the five decisions that make a self-hosted bot pleasant to own instead of a liability.
Decision 1: One dependency, on purpose
The bot's entire dependency list is discord.js. That's not a flex about minimalism. It's a support-load decision. Every dependency is a thing a buyer on a $4 VPS can break: native modules fail to compile, lockfiles fight, Node versions drift. A single, pure-JS, actively maintained dependency is the difference between "runs anywhere" and "runs on my machine".
If you're building a tool people will self-host, count your dependencies as a cost, not a capability.
Decision 2: JSON over SQLite
Ticket counters and warning history live in data/*.json, written atomically: write a temp file, rename() over the real one. On any crash, you either have the old file or the new one, never a half-written one.
I'm not going to pretend JSON beats a real database at scale. It doesn't need to. A community server accumulates maybe a few hundred warnings a year. For that volume, the wins are concrete:
- data is human-readable, so owners can audit their own files with a text editor
- no schema migrations, ever
- no native modules to break on weird architectures
- backups are literally copying a folder
Choosing the storage engine that matches the actual data volume, not the volume you wish you had, keeps the whole program small enough to reason about.
Decision 3: File-per-command
Every slash command is one small file with the same shape: metadata, permission checks, an execute function. Adding a command is adding a file. The README says exactly that, and means it.
The consistency is the feature. When every file has the same shape, onboarding a new maintainer (or your future self, six months later) is pattern-matching, not archaeology. It also keeps diffs honest in code review: a change to /warn is a change to one file.
Decision 4: Custom interaction IDs
Buttons carry IDs like arbiter:ticket:open instead of raw strings like open. The namespace looks cosmetic. It isn't. Panels get cross-posted, reactions get chain-wired, and on busy servers other bots' components end up on the same messages. The prefix guarantees the bot only ever answers its own buttons, and that a collision surfaces as "ignored", never as "misfire".
Small systems touch. Big systems regret skipping them.
Decision 5: Errors that teach
Every failure message names the likely cause: role hierarchy, missing permission, Discord's 14-day purge window. A bot that says "error" teaches nothing; a bot that says "I can't time out a moderator above me" turns every failure into the owner learning the system. Support volume drops when the error messages do the supporting.
The part that sells it to owners
On ticket close, the bot posts a transcript to the log channel: a self-contained dark-theme HTML file with avatars, timestamps, attachments. Inline CSS, no CDN, renders in any browser. The archive outlives the channel it came from, readable in five years without running anything.
Want the whole bot?
Full source, one dependency, zero database at pixelforgehq.sell.app/product/arbiter-discord-bot. It's $12.99 one-time, and the code LAUNCH20 takes 20% off until Nov 5 (the price rises after the first reviews). config.example.json included: paste your token, run it.
The patterns above are free, though. One dependency you can defend, storage that matches your real data volume, same-shaped files, namespaced IDs, errors that teach. Steal them for your next bot; they'll hold up.
Indie dev, building in public. More admin tools at pixelforgehq.sell.app.
Top comments (2)
how do you ensure ticket state stays consistent when the bot reloads modules on restart, especially if multiple users open tickets simultaneously?
Good question, and the honest answer has two parts.
Restarts: there is no in-memory state to lose. Every mutation (open, claim, close) is a read-modify-write of
data/tickets.jsonthat hits the disk before the bot replies to the user, and writes go through a temp file +rename(), so a crash mid-write never leaves a truncated file. The ticket counter is a separate JSON file, incremented synchronously before anything async happens, so numbers never reset or collide across restarts. On boot the bot just re-reads those files. Discord itself stays the source of truth for channel existence: if the JSON points at a channel that was deleted, those fetches fail softly instead of throwing.Concurrent opens: Node being single-threaded means the counter increment cannot interleave, so two users pressing the button in the same millisecond still get sequential numbers. The real race window is inside
createTicket:tickets.jsonis read at the top, then there is anawaitwhile Discord creates the channel, then the push and write. Two opens overlapping that window can let the second write clobber the first ticket entry. I know about that window and left it there on purpose, because the failure mode is benign: the channel exists on Discord's side regardless, the user loses nothing, and the worst case is the "max open tickets per user" check cannot see one entry. No corruption, no lost counters, no half-written files. If you expected hundreds of simultaneous opens, the fix is serializing that critical section with a promise chain, maybe three lines. At community-server volume I would rather not ship code nobody needs yet.