Self-host on Discord
Everything needed to run your own instance: install, configuration, a reverse proxy, upgrades and rollback, and a troubleshooting table. For what each feature does, see the module docs.
Sylo is a single Node 22 process — the Discord bot and the web dashboard in one. No build step. All state is one SQLite file under a mounted data directory by default — that’s the right choice for nearly everyone; see docs/postgres.md if you’re running at hosted scale and want the optional Postgres backend instead.
Quick start
Section titled “Quick start”git clone https://github.com/Ferdinand99/Sylo.gitcd Sylonpm installcp .env.example .env # fill in DISCORD_TOKEN and DISCORD_CLIENT_IDnpm startOr with Docker:
cp .env.example .envdocker compose up -d --builddocker compose logs -f syloThe dashboard is then on http://<host>:${WEB_PORT:-3000} and the database
persists in ./data.
For fast iteration set DISCORD_DEV_GUILD_IDS to a test server’s id — commands
register to it instantly instead of taking up to ~1 hour globally. npm run register re-syncs commands without a restart.
Environment variables
Section titled “Environment variables”Only DISCORD_TOKEN and DISCORD_CLIENT_ID are required.
| Variable | Default | Description |
|---|---|---|
DISCORD_TOKEN |
— | Bot token (required) |
DISCORD_CLIENT_ID |
— | Application (client) id (required) |
DISCORD_DEV_GUILD_IDS |
— | Register commands instantly to one or more servers (comma/space-separated) instead of globally. Old name DISCORD_GUILD_ID still works (warns). |
DISCORD_SHARD_COUNT |
auto |
Internal gateway shards for this one process. auto follows Discord’s recommendation and stays at 1 below ~2,500 servers; pin an integer to override. Multi-process sharding is not supported. |
WEB_PORT |
3000 |
Dashboard HTTP port |
DISCORD_CLIENT_SECRET |
— | Set to require “Log in with Discord” on the dashboard |
SESSION_SECRET |
random | Signs the session cookie; pin it so logins survive restarts |
OWNER_IDS |
— | Your Discord user id(s), comma/space-separated. Gates /health to just these accounts when DISCORD_CLIENT_SECRET is set — everyone else is blocked, not just non-admins |
DEV_LOG_CHANNEL_ID |
— | A channel id Sylo posts its own errors to — a “dev-log”, separate from any per-guild logging/modlog channel. Optional; without it there’s no proactive notification |
DASHBOARD_URL |
derived | Public dashboard URL; needed behind a reverse proxy and for verification-captcha / ban-appeal links |
TURNSTILE_SITE_KEY |
— | Cloudflare Turnstile site key — enables the Verification captcha mode |
TURNSTILE_SECRET_KEY |
— | Cloudflare Turnstile secret key (pair with the site key) |
ITAD_API_KEY |
— | IsThereAnyDeal key — adds non-Epic stores to the Free games module |
INTENT_GUILD_MEMBERS |
true |
Request the Server Members privileged intent |
INTENT_MESSAGE_CONTENT |
true |
Request the Message Content privileged intent |
GAMETOOLS_API_BASE |
https://api.gametools.network |
Stats API base URL |
STATS_CACHE_TTL_MINUTES |
5 |
How long stats lookups are cached |
DATABASE_PATH |
./data/sylo.db |
SQLite file path |
DATABASE_URL |
— | Optional: a postgres:// URL to use Postgres instead of SQLite. Hosted-scale deployments only — see docs/postgres.md. Unset (default) means nothing about the setup below changes. |
BACKUP_INTERVAL_HOURS |
24 |
Scheduled DB snapshot interval; 0 disables it (pre-migration + manual still run) |
BACKUP_RETENTION |
14 |
How many DB snapshots to keep in <data>/backups |
BACKUP_DIR |
<db dir>/backups |
Where DB snapshots are written |
LOG_LEVEL |
info |
debug / info / warn / error |
LOG_FORMAT |
text |
text or json (LOG_JSON=1 = json) |
NODE_ENV |
development |
Set to production in deployment |
Privileged intents
Section titled “Privileged intents”Both privileged intents default on. Several modules need them:
| Intent | Env var | Modules that need it |
|---|---|---|
| Server Members | INTENT_GUILD_MEMBERS |
logging, roles, verification, welcome, leveling, server-stats, invite-tracker |
| Message Content | INTENT_MESSAGE_CONTENT |
logging, autoresponder, counting, automod, starboard |
Enable them on the Discord Developer Portal (Bot → Privileged Gateway
Intents). If you don’t want those modules, set the env var to false and Sylo
starts without requesting that intent. A verified bot (100+ servers) needs
Discord’s approval for Message Content.
Discord application setup
Section titled “Discord application setup”-
https://discord.com/developers/applications → New Application.
-
Bot tab → Reset Token → copy into
DISCORD_TOKEN. Enable the privileged intents you need (see above). -
General Information → copy Application ID into
DISCORD_CLIENT_ID. -
OAuth2 → URL Generator → scopes
bot+applications.commands. Bot permissions:- Send Messages, Embed Links — always
- Attach Files — welcome images, rank cards, leaderboard cards
- Kick Members, Ban Members, Moderate Members, Manage Messages — moderation
- Manage Channels —
/lock,/lockdown,/slowmode, temporary voice - Manage Roles — reaction roles, autoroles, verification, leveling rewards, birthday role
- Move Members — temporary voice channels
- Manage Server — invite tracker (reads the invite list) and the automod
push to native Discord AutoMod (creates/edits
Sylo:-named rules)
Open the generated URL to invite the bot. Tickets (modmail) need no extra permission — just leave the bot able to receive DMs.
-
Drag Sylo’s role above the roles it should manage in Server Settings → Roles. The bot can never kick/ban/timeout someone whose highest role sits above its own, or edit a role above its own.
Dashboard authentication
Section titled “Dashboard authentication”By default the dashboard runs open (no login) — only safe on localhost or a
trusted LAN. Even in open mode a same-origin check blocks cross-site form posts.
To require a login:
- Developer Portal → your app → OAuth2 → copy the Client Secret into
DISCORD_CLIENT_SECRET. - Same page → Redirects → add
<DASHBOARD_URL>/auth/discord/callback(e.g.http://192.168.1.10:3000/auth/discord/callback, or the public HTTPS URL behind a proxy). - Set a long random
SESSION_SECRET. - Set
OWNER_IDSto your own Discord user id — without it,/health(status, error log, database backup/restore) is reachable by no one, not even you.
With DISCORD_CLIENT_SECRET set, every page except the /health JSON and the
/metrics scrape endpoint requires “Log in with Discord”. Per-server pages
require Manage Server (or Administrator / owner) in that server, or one of the
bot-master roles set on that server’s Settings page.
/health (JSON) and /metrics stay unauthenticated so a monitor or Prometheus
can reach them — keep them on your LAN, or restrict them at the reverse proxy if
the dashboard is public.
Point Prometheus at <host>:<WEB_PORT>/metrics and import
docs/grafana-dashboard.json for a ready-made overview (gateway health, guild
count, HTTP and command rates, DB size, module adoption).
Off-site backups
Section titled “Off-site backups”Every local snapshot can also be shipped, gzipped, to a remote target — set any
of BACKUP_WEBDAV_URL (+ BACKUP_WEBDAV_USER / BACKUP_WEBDAV_PASS, e.g. a
Nextcloud folder) or BACKUP_WEBHOOK_URL (a Discord webhook; attachments over
~8 MiB are skipped). Uploads are best-effort and logged; they never hold up the
local backup. The Health page shows which targets are active.
Behind a reverse proxy
Section titled “Behind a reverse proxy”Set DASHBOARD_URL to the public URL and proxy to 127.0.0.1:${WEB_PORT}. Sylo
then trusts one proxy hop (X-Forwarded-*), which it needs for correct client
IPs (rate limiting) and OAuth redirects. Make sure the OAuth redirect in the
Developer Portal matches <DASHBOARD_URL>/auth/discord/callback.
Caddy
sylo.example.com { reverse_proxy 127.0.0.1:3000}nginx
server { listen 443 ssl; server_name sylo.example.com; # ssl_certificate ... ;
location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}DASHBOARD_URL=https://sylo.example.comDocker
Section titled “Docker”The bundled docker-compose.yml builds from source, mounts ./data, and sets a
restart policy (needed so a dashboard Restore can restart the container).
cp .env.example .envdocker compose up -d --buildPrebuilt images
Section titled “Prebuilt images”CI publishes multi-arch (linux/amd64 + linux/arm64) images to two registries:
| Tag | Registry | What it is |
|---|---|---|
iwgamin/sylo:latest, :X.Y.Z, :X.Y |
Docker Hub · GHCR | Stable releases. What the Unraid template pulls. |
ghcr.io/ferdinand99/sylo:main, :sha-<short> |
GHCR only | Rolling build of main — every push. |
docker run -d --name sylo -p 3000:3000 --env-file .env \ -v "$PWD/data:/app/data" --restart unless-stopped iwgamin/sylo:latestIf better-sqlite3 ever fails to build on Alpine for your platform, change the
two FROM node:22-alpine lines in the Dockerfile to node:22-slim.
Unraid
Section titled “Unraid”Sylo is in the Unraid Community Applications store — search “Sylo”. The
template’s source of truth is
Ferdinand99/unraid-templates
(templates/sylo.xml) — edits there propagate to existing installs
automatically via <TemplateURL>, so no re-submission is needed for config
changes. If Community Applications hasn’t picked up that repository yet, add
https://github.com/Ferdinand99/unraid-templates under Docker → Template
repositories to install the current template directly.
Manual container setup (Docker tab → Add Container):
| Field | Value |
|---|---|
| Repository | docker.io/iwgamin/sylo:latest |
| Network | bridge |
| Port | Container 3000 → Host 3000 |
| Path | Container /app/data → a real local path (see the caveat below) |
| Variable | DISCORD_TOKEN, DISCORD_CLIENT_ID, NODE_ENV=production |
The image starts as root only long enough to fix ownership of the data
directory, then runs as an unprivileged user (sylo, uid 100). A fresh
root-owned folder works out of the box. If you still see SQLITE_CANTOPEN, run
once: chown -R 100:101 <data path>.
SQLite on a network mount
Section titled “SQLite on a network mount”Put the data directory on a real local disk, not a network share.
better-sqlite3 uses WAL mode, which needs working file locks and mmap. SMB,
NFS, Unraid’s /mnt/user (shfs / FUSE) and some Docker-Desktop bind mounts don’t
provide them reliably, and you get SQLITE_IOERR, database is locked, or
silent corruption.
- Unraid: use a cache-pool path such as
/mnt/cache/appdata/sylo, or a disk-share path like/mnt/disk1/appdata/sylo— not/mnt/user/.... - NAS / remote: run Sylo on the box that owns the disk, or use a local volume.
Backups
Section titled “Backups”All state is one SQLite file (data/sylo.db + -wal / -shm sidecars) —
this section assumes that default setup. Running with DATABASE_URL set
instead? See docs/postgres.md
— the same buttons and flow, pg_dump/pg_restore under the hood.
Automatic snapshots are written to data/backups/: one before any schema
migration, one shortly after start, and one every BACKUP_INTERVAL_HOURS
(default 24), keeping the newest BACKUP_RETENTION (default 14). Set
BACKUP_INTERVAL_HOURS=0 to keep only the pre-migration and manual ones.
From the dashboard Health page you can create a snapshot now, import a .db
from another machine (validated: SQLite header, integrity_check, schema no
newer than this build), download any snapshot, and Restore — which takes a
prerestore snapshot, swaps the file, and exits so the container restarts on the
restored data (needs a restart policy).
Manual restore: stop the container, copy a snapshot over data/sylo.db
(delete the -wal / -shm sidecars first), start again. Migrations only ever
move the schema forward; Sylo runs a quick_check on boot and logs corruption.
Per-server module config can also be exported as JSON from General → Backup.
Upgrades and rollback
Section titled “Upgrades and rollback”Upgrade (prebuilt image):
docker compose pull # or: docker pull iwgamin/sylo:latestdocker compose up -dUpgrade (from source):
git pullnpm install# restart the process / containerOn start, Sylo applies any new schema migrations inside a transaction, taking a
sylo-premigrate-vN-*.db snapshot first.
Rollback: pull the previous image tag (iwgamin/sylo:3.4.1), or git checkout the previous tag, then restore the matching sylo-premigrate-* (or a
dated) snapshot from data/backups/ over data/sylo.db. A newer database can’t
be opened by an older build — the schema check refuses it — so always roll the
database back together with the code.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause / fix |
|---|---|
Used disallowed intents on start |
A privileged intent is requested but not enabled in the Developer Portal. Enable it, or set INTENT_GUILD_MEMBERS=false / INTENT_MESSAGE_CONTENT=false. |
| Slash commands don’t appear | Global registration takes up to ~1 hour. Set DISCORD_DEV_GUILD_IDS for instant per-server registration, or wait. npm run register re-syncs. |
“Log in with Discord” loops / redirect_uri mismatch |
The Developer Portal Redirect must exactly equal <DASHBOARD_URL>/auth/discord/callback, scheme and port included. |
SQLITE_CANTOPEN |
The data directory isn’t writable by uid 100. chown -R 100:101 <data path>. |
SQLITE_IOERR, database is locked, corruption |
The database is on a network share. Move it to a local disk — see SQLite on a network mount. |
better-sqlite3 fails to build |
Switch the Dockerfile base images to node:22-slim, or install python3 make g++ for a from-source build. |
| Moderation says it can’t act on a member | Sylo’s highest role must sit above the target’s, and it needs the relevant permission (Ban/Kick/Moderate Members). |
| Welcome image / rank card missing | The bot lacks Attach Files in that channel, or @napi-rs/canvas didn’t load on this platform (a warning is logged; the text message still sends). |
| Dashboard shows “open mode — no auth” | DISCORD_CLIENT_SECRET isn’t set. That’s expected for LAN use; set it to require a login. |