Deployment (Dokploy)
Set up the bot on Dokploy from scratch, or migrate an existing deployment to the monorepo layout.
FluffBoost deploys as a Docker image on Dokploy. Because Bun runs TypeScript directly, there's no build step — the image just needs the bot's source and its production dependencies.
How it works
apps/discord/Dockerfilebuilds the image. It usesturbo pruneso the bot image only contains the bot and its dependencies, neverapps/docs.- Build context is the repository root (not
apps/discord) — the Bun workspace lockfile lives at the root and Turborepo needs the whole graph to prune. - The container's entrypoint runs database migrations, then starts the bot with
bun run src/app.ts. - The container
HEALTHCHECKhitsGET /api/health/liveonPORT(default3000). That liveness endpoint has no dependencies, so a short Postgres or Redis blip does not get a container with live gateway sessions replaced. GET /api/healthis the readiness endpoint: it returns503while Postgres or Redis is unreachable. Point external uptime monitors at it.- All configuration is injected as environment variables — see Configuration.
Setting up a fresh deployment
Starting from a clean server? Work through these in order.
Provision PostgreSQL and Redis
Create a PostgreSQL 18 and a Redis 8 service in Dokploy (or point at
existing ones). Copy each connection string — they become DATABASE_URL and
REDIS_URL. Each bot process opens its own Postgres pool, so check the
connection budget
against the server's max_connections.
Create the application
Add a new Docker / Dockerfile application pointing at this repository, then set exactly two build fields:
| Field | Value |
|---|---|
| Build context / base directory | . (the repository root) |
| Dockerfile path | apps/discord/Dockerfile |
The context must stay at the root — the Bun workspace lockfile lives there
and turbo prune needs the whole graph.
Add the environment variables
Set the required variables — DATABASE_URL, REDIS_URL,
DISCORD_APPLICATION_BOT_TOKEN, OWNER_ID and MAIN_CHANNEL_ID — plus any
optional ones you want. Leave HOST unset so the health API listens on all
interfaces inside the container. The full table (with
defaults) is in Configuration.
Production does not read a .env file; inject these through Dokploy.
Invalid or missing config exits the process immediately at startup — that's by design. If the container dies instantly, read the logs: the Zod error names the exact variable.
Expose the port, health check and stop grace period
The container listens on PORT (default 3000). Point Dokploy's health check
at GET /api/health/live (liveness). Keep GET /api/health for external
uptime monitors, since it reports 503 when Postgres or Redis is down.
Set Dokploy's Swarm Stop Grace Period to 35 seconds
(stopGracePeriodSwarm: 35000000000, in nanoseconds). Shutdown is budgeted so
each layer finishes before the next one gives up on it:
| Layer | Limit |
|---|---|
| Shard shutdown watchdog | 20 s |
Shard SIGKILL | 22 s |
| Parent (manager) watchdog | 30 s |
| Orchestrator stop grace | 35 s |
With a shorter grace period, in-flight quote deliveries and Redis/Postgres connections are cut off mid-drain.
Restart backoff. If one shard dies 5 times within 15 minutes, the manager exits the container with a non-zero code instead of respawning it again, so Dokploy's own restart backoff applies. Fatal shard errors (login failure, an unrecoverable gateway close) also wait 15 seconds before exiting, so a respawn cannot hot-loop Discord logins. The manager looks up the shard count before spawning anything: it retries network errors, 5xx and 429 answers, and on an invalid token (or once retries run out) it also waits 15 seconds before exiting. A failed shard spawn waits the same 15 seconds.
Use stop-first updates
Dokploy deploys applications as Docker Swarm services, and its default update order is start-first: the new container logs in to Discord while the old one is still connected. For a bot that means two instances on the same token until the old task stops, so commands run twice and both schedule jobs.
Under Advanced → Swarm Settings, set both Update Config and Rollback Config to:
{ "Parallelism": 1, "Order": "stop-first", "FailureAction": "rollback" }With stop-first, each deploy has a short gap (one boot plus shard login) where commands do not answer. That is the intended trade-off for a gateway bot.
Deploy and verify
Trigger the deploy. The container applies the database migrations before the bot
starts, so the schema is created for you on this first run — watch the logs for
Running database migrations..., then a successful Postgres + Redis connection
and the shards logging in. Confirm the health check goes green.
If the deploy stops with Migration aborted: Existing schema without migration history, the database already has tables that were not created by these
migrations. See Baseline an existing database.
Load the starter quotes
The quote library is not seeded automatically, and without it the bot has
nothing to post. Now that the tables exist, run the seed once. Seeding needs
only DATABASE_URL and OWNER_ID, which the bot container already has, so the
simplest option is to run it inside the bot container. This keeps Postgres
private:
# Dokploy: open the application's terminal, then run
bun run db:seed
# Or from the Docker host (the image's working directory is
# /usr/src/app/apps/discord):
docker exec -it <container> bun run db:seedIf you must seed from your own machine instead, run this from the repository root in an interactive Bash shell. Enter the database URL at the hidden prompt so it is not included in the command text or shell history:
(
set -e
read -r -s -p 'Production database URL: ' DATABASE_URL
printf '\n'
read -r -p 'Owner Discord ID: ' OWNER_ID
export DATABASE_URL OWNER_ID
bun run db:seed
)db:seed skips quotes that already exist, so it's safe to re-run. The URL is
still present in the seed process environment while it runs; use a trusted
machine and shell.
Point the bot at a channel
In Discord, run /setup channel in your server to choose where the daily quote
lands, then /quote to confirm the library loaded.
Migrating an existing deployment
This applies to deployments created before the monorepo move, when the
Dockerfile lived at the repository root. The bot's runtime behavior is
unchanged — only the build path moves.
The build change is where the Dockerfile lives; a few Dokploy settings also need updating (step 3). Update your Dokploy application:
Keep the build context at the repo root
Base directory / build context stays . (the repository root). Do not point
it at apps/discord — the workspace lockfile is at the root.
Point the Dockerfile path at the app
Change the Dockerfile path from Dockerfile to:
apps/discord/DockerfileUpdate the runtime settings
Environment variables and ports are unchanged, and no new variables are
required. Do update three settings: switch the health check path to
/api/health/live, set the Stop Grace Period to 35 seconds (see
Expose the port, health check and stop grace period),
and switch to stop-first updates.
DISCORD_APPLICATION_ID, DISCORD_APPLICATION_PUBLIC_KEY and MAIN_GUILD_ID
are no longer read and can be removed.
Redeploy and watch the logs
Trigger a deploy. Confirm the bot boots, connects to Postgres and Redis, and the health check goes green.
Local sanity check
Before touching production, verify the image builds with the new path:
docker build -f apps/discord/Dockerfile -t fluffboost:test .(Note the trailing . — the context is the repo root.)
Rollback
If anything looks wrong after the monorepo move, redeploy the previous commit. The build path change does not touch the environment, so rolling back is a redeploy of the old image (restore the old health check path too).
Rolling forward may touch the database: if the production schema was
created with db:push (or before migrations existed), the first deploy that
runs migrations stops with a baseline error. Follow
Baseline an existing database (schema already
current) or Reconcile a legacy database
(Prisma era or drifted) before or right after that deploy.
Migrations, backups and rollbacks
Every deploy runs pending migrations before the bot starts, and there are no down migrations. Rolling back therefore runs the old image against the new schema. Keep that safe:
-
Expand, then contract. Ship additive changes (new nullable columns, new tables, new enum values) first. Remove or rename things in a later deploy, once no running image reads them.
-
Back up before risky migrations. Take a dump right before deploying a migration that rewrites or drops data:
pg_dump --format=custom --file=fluffboost-$(date +%Y%m%d%H%M).dump "$DATABASE_URL" -
Close long sessions before a migration. The migrator waits at most 60 seconds for a table lock (
lock_timeout). An openBEGINin psql or a longpg_dumpholding a lock on a table the migration alters makes the deploy fail withcanceling statement due to lock timeout (55P03); the migration transaction rolls back cleanly, so finish those sessions and redeploy. If another migrator holds the migration lock, the log showsWaiting for the migration lock held by backend pid N...and the deploy aborts after 2 minutes; check that pid inpg_stat_activity. -
Prefer fixing forward. If a deploy misbehaves, ship a fix. Roll back to the previous image only when its migrations were backward-compatible; restore the dump (
pg_restore --clean) only as a last resort, since it discards everything written after the backup.
Baseline an existing database
The entrypoint refuses to migrate when the app tables exist
(public."Guild") but Drizzle has no migration history
(drizzle.__drizzle_migrations is missing or empty). Applying migration 0000
there would fail on the first CREATE TYPE, and silently recording it could
hide schema drift. The deploy exits with:
Migration aborted: Existing schema without migration history: baseline required (see the deployment docs). ...Baselining only records that 0000 is applied; it changes no tables. It is for
a database whose schema already matches 0000 exactly, such as one built with
db:push from the current code. A database from the Prisma era, or one pushed
from an older Drizzle schema, has drift (missing columns, text ids, different
enum labels). Reconcile it instead; the
reconcile also records the baseline.
Set SKIP_MIGRATIONS=true to keep the bot running while you baseline, then:
Confirm the state
SELECT to_regclass('drizzle.__drizzle_migrations') AS history,
to_regclass('public."Guild"') AS guild;A baseline is needed when guild is set and history is NULL (or the
history table has no rows).
Diff the schema
Create a scratch database, migrate it with the same image
(bun run src/database/migrate.ts with DATABASE_URL pointing at the
scratch database), then compare the public schemas. Run pg_dump from the
postgres image that matches the server's major version (a client older than
the server refuses to dump), and drop the \restrict / \unrestrict lines,
which carry a random key on every dump:
# Match the server's major version: postgres:18-alpine for PostgreSQL 18, postgres:17-alpine for 17, ...
dump() {
docker run --rm --network host postgres:18-alpine \
pg_dump --schema-only --no-owner --no-privileges --schema=public "$1" |
grep -vE '^\\(un)?restrict'
}
dump "$PROD_URL" > prod.sql
dump "$SCRATCH_URL" > expected.sql
diff prod.sql expected.sql && echo "schema matches 0000"An empty diff is a pass. Any output is drift: do not baseline, use Reconcile a legacy database instead.
Record the baseline
Run this in one transaction. The hash is the SHA-256 of
apps/discord/drizzle/0000_confused_eternity.sql and created_at is that
entry's when in drizzle/meta/_journal.json; recompute both with
shasum -a 256 if the file ever changes. The safety check at the top aborts
(and records nothing) when a column, column type, uuid id default, enum
label or index from 0000 is missing, for example the Prisma-era
SuggestionQuote without reviewedBy.
BEGIN;
-- Safety check: abort unless the tables already have migration 0000's shape.
DO $$
DECLARE problems text;
BEGIN
SELECT string_agg(format('%s.%s', e.t, e.c), ', ') INTO problems
FROM (VALUES
('Guild', 'id', 'uuid'), ('Guild', 'guildId', 'text'), ('Guild', 'motivationChannelId', 'text'),
('Guild', 'motivationFrequency', 'MotivationFrequency'), ('Guild', 'motivationTime', 'text'),
('Guild', 'motivationDay', 'int4'), ('Guild', 'timezone', 'text'),
('Guild', 'lastMotivationSentAt', 'timestamp'), ('Guild', 'isPremium', 'bool'),
('Guild', 'joinedAt', 'timestamp'), ('Guild', 'updatedAt', 'timestamp'),
('MotivationQuote', 'id', 'uuid'), ('MotivationQuote', 'quote', 'text'),
('MotivationQuote', 'author', 'text'), ('MotivationQuote', 'addedBy', 'text'),
('MotivationQuote', 'createdAt', 'timestamp'),
('SuggestionQuote', 'id', 'uuid'), ('SuggestionQuote', 'quote', 'text'),
('SuggestionQuote', 'author', 'text'), ('SuggestionQuote', 'addedBy', 'text'),
('SuggestionQuote', 'status', 'SuggestionStatus'), ('SuggestionQuote', 'reviewedBy', 'text'),
('SuggestionQuote', 'reviewedAt', 'timestamp'), ('SuggestionQuote', 'createdAt', 'timestamp'),
('SuggestionQuote', 'updatedAt', 'timestamp'),
('DiscordActivity', 'id', 'uuid'), ('DiscordActivity', 'activity', 'text'),
('DiscordActivity', 'type', 'DiscordActivityType'), ('DiscordActivity', 'url', 'text'),
('DiscordActivity', 'createdAt', 'timestamp')
) AS e(t, c, u)
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.columns k
WHERE k.table_schema = 'public' AND k.table_name = e.t AND k.column_name = e.c AND k.udt_name = e.u
AND (e.c <> 'id' OR k.column_default = 'gen_random_uuid()')
);
IF problems IS NOT NULL THEN
RAISE EXCEPTION 'Not baselining: missing or mismatched columns: %', problems
USING HINT = 'Use the reconcile script (bun run db:reconcile) instead.';
END IF;
IF (SELECT string_agg(enumlabel, ',' ORDER BY enumsortorder) FROM pg_enum
WHERE enumtypid = to_regtype('public."DiscordActivityType"')) IS DISTINCT FROM 'Custom,Listening,Streaming,Playing'
OR (SELECT string_agg(enumlabel, ',' ORDER BY enumsortorder) FROM pg_enum
WHERE enumtypid = to_regtype('public."SuggestionStatus"')) IS DISTINCT FROM 'Pending,Approved,Rejected'
OR (SELECT string_agg(enumlabel, ',' ORDER BY enumsortorder) FROM pg_enum
WHERE enumtypid = to_regtype('public."MotivationFrequency"')) IS DISTINCT FROM 'Daily,Weekly,Monthly'
OR to_regclass('public.suggestion_status_idx') IS NULL
OR to_regclass('public.guild_motivation_channel_idx') IS NULL THEN
RAISE EXCEPTION 'Not baselining: enum labels or indexes differ from migration 0000'
USING HINT = 'Use the reconcile script (bun run db:reconcile) instead.';
END IF;
END $$;
CREATE SCHEMA IF NOT EXISTS drizzle;
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at bigint
);
INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
SELECT '9289c18010701751cde68a6c7bffb56e87090393725f50e8d903467bdce1cb93', 1785275510784
WHERE NOT EXISTS (SELECT 1 FROM drizzle.__drizzle_migrations);
COMMIT;Only record 0000; the WHERE NOT EXISTS makes a second run a no-op. Any
later migrations apply normally on the next deploy. Only baseline after the
schema diff is clean: recording 0000 on a drifted schema makes Drizzle skip
it, and inserts then fail at runtime instead of at deploy time.
Redeploy and confirm
Redeploy and check the logs for Migrations complete. before the bot starts.
Remove the opt-out
Delete SKIP_MIGRATIONS from the environment so future deploys migrate again.
Reconcile a legacy database
For a database from the Prisma era (any of its migrations, or prisma db push),
one pushed from an earlier Drizzle schema (drizzle-kit push), or one where a
drizzle-kit push --force stopped partway. The entrypoint refuses these with
the baseline error above, and baselining them would hide the drift.
apps/discord/scripts/reconcileLegacySchema.sql rebuilds the four tables in the
current shape and keeps every row, in one transaction:
- Renames the legacy tables, constraints, indexes and enum types to
_legacy_*. - Runs migration
0000verbatim. - Copies every column whose name still exists, mapping values: enum labels
case-insensitively (
pendingtoPending,CUSTOMtoCustom), the old cron default0 8 * * *to08:00, text ids touuid, and NULLs to the column default. It checks the row count of every table. - Drops the
_legacy_*objects and_prisma_migrations, then records the0000baseline row with the hash andcreated_atthat Drizzle's migrator computes.
It refuses a database that already has migration history (already baselined
or migrated) or has no public."Guild". Anything it cannot map aborts the
whole transaction with a message naming the table, column and values, and the
database stays unchanged. That covers an enum value with no current
equivalent (the early WATCHING activity type, an unknown suggestion status),
a non-UUID id, a malformed motivationTime and duplicate guildIds. Fix those
rows and run it again.
Columns the current schema no longer has are dropped with their values; a
NOTICE lists them. On early Prisma databases (before September 2025) that
is SuggestionQuote.guildId, which recorded the server a suggestion came
from. Keep the backup if you need it.
Stop the bot and back up
Stop the application so nothing writes during the reconcile, then dump
the database with a pg_dump that matches the server's major version:
pg_dump --format=custom --file=fluffboost-before-reconcile.dump "$DATABASE_URL"Run the reconcile
From the image (Dokploy terminal, working directory
/usr/src/app/apps/discord) or a checkout of the same commit:
bun run db:reconcile --confirmIt reads DATABASE_URL. Without --confirm it prints this checklist and
exits. Each table reports N of N row(s) copied. With psql instead:
psql -X -v ON_ERROR_STOP=1 -d "$DATABASE_URL" -f apps/discord/scripts/reconcileLegacySchema.sqlRedeploy and confirm
Remove SKIP_MIGRATIONS and redeploy. The log shows Migrations complete.
with nothing applied, then the bot starts. The
schema diff against a fresh scratch database is now empty.
If anything went wrong, pg_restore --clean --dbname="$DATABASE_URL" fluffboost-before-reconcile.dump returns to the backup.
Environment files
Local Docker Compose reads apps/discord/.env. Production does not use a
.env file — inject the variables through Dokploy directly.