Skip to content
Meith
Browse documentation
Getting started

Running on Vercel

The board on functions: the driver set, the build command that carries the migration, the cron job that replaces the worker, what previews and rollbacks do to your schema — and how to leave again.

You can run a Meith board on Vercel, and this page is the whole route: the project, the build command, every environment variable and what it is for, the cron job that stands in for the worker, the first-run installer, and — because it is the part that decides whether this is a home or a trap — how to leave.

This is a narrower route than the Quickstart, not a better one. There is no server to SSH into, no worker process, no disk, and no docker compose run to reach for when something needs a command run against the board. What you get in exchange is that none of those are yours to keep alive.

Who this route is for

Take it if:

  • you have no server and no wish to acquire one. Nobody on the committee has to learn ufw, renew a certificate, or notice that a disk filled up.
  • traffic is bursty. A board that is quiet for six days and busy on club night pays for the busy part rather than for a machine sized to it.
  • you are already on Vercel and adding one more project is less work than adding the first server.

Do not take it if:

  • where the data lives is the point. This route spreads a board across a platform, a managed database, a managed cache and an object store — four companies holding your members' posts, none of them you. If that sentence is the reason your community is leaving whatever it is leaving, stop here and read Deploying by hand instead. One machine you rent, one database on it, one pg_dump that is the whole board. That is the honest answer to data sovereignty, and this page is not it.
  • you want the documented default. A server is still what most boards should run, and what most of this documentation assumes.
  • you are importing a large MyBB or phpBB board. The importer is a long-running command against two databases; see the limits.

What it costs

Usage-based, and spread across four bills rather than one:

Service What it is Notes
Vercel Serving the board, and the cron scheduler The per-minute tick needs a paid plan — see the tick
Managed PostgreSQL Everything durable: posts, members, sessions, the queue Needs both a pooled and a direct connection string
Managed Redis The shared cache, and nothing else Losing it costs a warm cache, not data
Object storage Avatars and attachments A Vercel Blob store, which is on the Vercel bill and provisions itself, or any S3-compatible bucket: R2, S3, Spaces, MinIO

Some providers bundle two of these, which makes it three bills rather than four. None of them bundle all of it. A single rented server running the Quickstart is one bill, a fixed one, and usually a smaller one — the case for this route is the operational work it removes, not the money.

What you need

A board repository A scaffolded board of your own, not a clone of this repository — the same workspace Quickstart § 2 creates. It depends on the published @meith/web and @meith/cli packages, which is what puts the forum-web and community commands in the build.
A managed PostgreSQL With both connection strings: the transaction-mode pooler and the direct one. Both are needed, for the reason under the environment.
A managed Redis Reachable over TLS (rediss://).
Somewhere to put uploads Either a Vercel Blob store, which costs nothing to set up, or an S3-compatible bucket and a key pair for it. The choice has consequences for leaving; read that first.
A mail provider with an HTTP API SMTP is possible and worse here; see mail.
A domain Pointed at Vercel per their instructions.

1. The project and the build command

Import the board repository as a Vercel project. The one setting that is not a default is the build command, which must be:

community migrate && forum-web build

Applying the schema is always a separate step from starting the board. Under Compose a one-shot migrate service does it and web waits for it to exit 0. A platform that only builds and serves has nowhere to run a one-shot job, so the same step goes in the build command, ahead of the build — this is the supported arrangement, described in Operations § Migrations.

The && is the whole mechanism. community migrate exits non-zero on a failed migration, the build never starts, and the deployment fails carrying the migration's own error instead of shipping new code onto an old schema. There is deliberately no forum-web build --migrate: two commands is what makes a failed deploy attributable to the step that actually failed.

community migrate needs the database. forum-web build does not — Next sets NEXT_PHASE during a production build and the board's environment rules exempt that phase — but since they share one command line, the build environment needs the database variables anyway.

Read what build-time migration means before the first preview build. Not after.

2. The environment

This is the supported driver set for a board on functions, and it is the one where no driver keeps anything inside the instance. The reasoning for each is in Scaling out.

Variable Value Why
DATA_SOURCE postgres fixture is a read-only sample board with no write side.
QUEUE_DRIVER postgres memory loses every queued job when the instance goes away, which is after almost every request. The environment refuses it in production.
CACHE_DRIVER redis next and memory cache inside the process. With instances created and destroyed constantly, a per-process cache is close to no cache.
FILESTORE_DRIVER blob or s3 local writes to a disk no other instance can read and that is discarded with the instance. On Vercel the environment refuses local outright rather than losing uploads quietly. blob is a Vercel Blob store and needs no configuration beyond the token the store publishes; s3 is any S3-compatible bucket and is the portable one. See choosing between them.
MAIL_DRIVER http Reaches the provider over ordinary HTTPS on 443, the one outbound path a function can rely on.

And the values those drivers need:

Variable What it is
DATABASE_URL The pooled (transaction-mode) connection string. Every ordinary request goes through it.
DIRECT_DATABASE_URL The direct (session-mode) string. Required here, not optional — see below.
REDIS_URL The cache. rediss:// for TLS, which every managed provider requires.
BLOB_READ_WRITE_TOKEN Under FILESTORE_DRIVER=blob: the Blob store's token. A Blob store attached to the project publishes it under exactly this name, so there is nothing to copy.
S3_BUCKET, S3_REGION Under FILESTORE_DRIVER=s3: the bucket and its region. auto is the region for R2; the real one for AWS.
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY The bucket's credentials.
S3_ENDPOINT The API endpoint for a non-AWS bucket (R2, Spaces, MinIO). Omit for AWS S3.
S3_PUBLIC_BASE_URL Where objects are served from, which is not always where the API lives — R2 serves from an r2.dev address or a custom domain, not from S3_ENDPOINT. Set it to that, or to a CDN in front of the bucket, or public URLs point somewhere a browser cannot fetch.
MAIL_FROM The sender address. It must be at a domain your provider has verified for you; there is no sensible default.
RESEND_API_KEY Set by Resend's Vercel integration when you add it. The board reads this name and needs neither of the next two.
MAIL_HTTP_ENDPOINT, MAIL_HTTP_TOKEN Any other provider of the same shape. Set them as a pair, with MAIL_DRIVER=http: setting either one on its own stands the Resend bridge down entirely, so that a key issued for Resend is never presented to an endpoint someone else chose. Only RESEND_API_KEY turns the driver on by itself.
APP_URL The board's public origin. Every link in every password-reset and confirmation e-mail is built from it, so it must be the real domain and not a preview URL.
AUTH_SECRET Signs unsubscribe links in outgoing mail and seals two-factor secrets. 32 characters minimum; the board refuses to boot on a shorter one.
CRON_SECRET Guards /api/system/tick. Also 32 characters minimum. This is the name Vercel Cron can send — see the tick.
TICK_SECRET The same guard under the other name. Strictly speaking CRON_SECRET alone protects the endpoint, but set this too: the installer's preflight looks only at TICK_SECRET and warns without it, and it is what any other scheduler — or the board you eventually move to — presents. Generate a separate value; it need not match.

Generate each secret with openssl rand -hex 32.

Three of the five drivers derive themselves and only need setting to override the derivation: a DATABASE_URL implies DATA_SOURCE=postgres and QUEUE_DRIVER=postgres, and a RESEND_API_KEY with a MAIL_FROM beside it implies MAIL_DRIVER=http.

CACHE_DRIVER and FILESTORE_DRIVER stay explicit, deliberately. Each turns on an external service the board then depends on for every request, and neither switches on merely because a variable is present: the board caches in Redis and puts uploads in an object store because you said so.

Leaving them unset fails in two different ways, and only one of them is loud. Unset FILESTORE_DRIVER is local, which this platform refuses outright — the board will not boot rather than write uploads to a disk that is about to disappear. Unset CACHE_DRIVER is next, and nothing refuses it: the board caches inside each instance and serves whatever that instance last saw for up to a minute, however many instances are running. A blank field on a deploy form arrives as an empty value and is treated as unset, so the cache is the one to check twice — it will not tell you.

Setting all five by hand, as the table above does, is still the clearest thing to do on a project you configure yourself.

Blob or a bucket

Both work, and the board is the same either way. The difference is what happens on the day you leave.

A Vercel Blob store is the cheapest thing to set up: attach one to the project, and BLOB_READ_WRITE_TOKEN appears by itself. That replaces four values that each had exactly one correct setting and each of which was a typo away from a board that booted and then failed at the first upload. It is what the one-click template in templates/vercel defaults to, and what its Deploy Button provisions for you. Objects are written with private access — an object URL is not a public link — and member content is served by the board, which is where permissions are checked.

What you give up is portability. A Blob store is reachable only through Vercel's API: there is no bucket to point rclone or aws s3 sync at, no credential you can hand a second tool, and deleting the Vercel project deletes the attachments with it. It is the one piece of genuinely Vercel-shaped state this route has, and the whole of Leaving Vercel is written around getting it out.

An S3-compatible bucket is the portable one, and it stays the documented default everywhere else in this project. You hold the bucket, it outlives the Vercel project, and moving the board is a matter of pointing the new deployment at the same credentials. Choose it if you expect to move, or if you already have object storage.

You can change your mind later, in either direction, with a backup and a restore — that is exactly what the exit below does.

Why both database strings

A managed database hands out two connection strings for the same database, and they are not interchangeable. Serving the board is pooler-safe: it never holds a session open across statements, never uses LISTEN/NOTIFY, and asks the driver for no named prepared statements — so DATABASE_URL should be the pooler, because functions multiply connections faster than anything else does.

Two things are not pooler-safe, and they are the two that take a session-level advisory lock: migrations and the first-run installer. A transaction-mode pooler hands the backend back the moment the statement that took the lock commits, which breaks such a lock twice over — two callers on different backends can each be told they hold it, and the lock outlives the caller that took it. Both therefore use DIRECT_DATABASE_URL when it is set and fall back to DATABASE_URL when it is not.

The rule is short: if DATABASE_URL points at a pooler, set DIRECT_DATABASE_URL as well, in every environment that builds. Operations § Connection pooling has the full account.

Mail

MAIL_DRIVER=http posts the message as JSON to MAIL_HTTP_ENDPOINT with MAIL_HTTP_TOKEN as a bearer token, and gives up after ten seconds, which fits inside a function's timeout. Prefer it.

The body it posts is {from, to, subject, text, html, reply_to}, which is Resend's POST /emails contract exactly. So Resend needs no adapter and no configuration: add Resend to the project from Vercel's marketplace, and the integration sets RESEND_API_KEY. The board reads that name, fills in Resend's endpoint for you, and sends — the only thing left to set is MAIL_FROM, at a domain you have verified in Resend, which nothing can guess on your behalf.

That bridge is one injected variable name mapped onto the generic pair, not a provider baked into the board. The driver stays what it was: any provider accepting a bearer token and that JSON shape works, by setting MAIL_HTTP_ENDPOINT and MAIL_HTTP_TOKEN, which override RESEND_API_KEY.

Only RESEND_API_KEY flips MAIL_DRIVER to http on its own, and only with a MAIL_FROM beside it. Configuring the generic pair by hand leaves MAIL_DRIVER alone, so set it to http yourself — a board that had those two variables set and MAIL_DRIVER unset was not sending mail before, and an upgrade is not the moment to start.

Set that pair together. Touching either half stands the bridge down completely: the board will not combine RESEND_API_KEY with an endpoint you supplied, nor point a token you supplied at Resend. That matters most when moving away from Resend, where the integration has already set RESEND_API_KEY and the obvious move is to change the endpoint and forget the key — which would otherwise send a live Resend credential to the new provider as its bearer token. Boot then fails naming MAIL_HTTP_TOKEN rather than leaking it. Remove RESEND_API_KEY once you have moved.

smtp can work and is worse here. It opens a raw TCP connection on a port the platform may not allow out: port 25 is refused outright by the board's own environment rules on Vercel, because serverless egress blocks it and every message would hang until the function timed out; 465 is often blocked; 587 with MAIL_SMTP_SECURITY=starttls is the one that usually survives. A connection is also negotiated from scratch for every message, because there is no long-lived process to pool one in — which makes each send slow in a place where slow costs money.

3. The tick replaces the worker

A Compose deployment has something ticking every 60 seconds without being asked: the compiled apps/worker process in this repository's own image, or — in a scaffolded board's own compose file, since @meith/worker is not published — a small container looping against the tick endpoint. There is no such thing here. The same tick is available over HTTP at /api/system/tick, and Vercel Cron calls it on a schedule instead. Nothing else changes: the HTTP tick runs the identical tick over the identical task list, and tasks claim their work through the database, so an overlapping call cannot double-process anything.

Declare the schedule in vercel.json at the root of the project:

{
"crons": [{ "path": "/api/system/tick", "schedule": "* * * * *" }]
}

Vercel documents that it calls the path with Authorization: Bearer <CRON_SECRET> — exactly that header, under exactly that variable name, and it cannot be told to send another. That is why this route sets CRON_SECRET rather than TICK_SECRET: the endpoint accepts either, and either on its own protects it, but only one of them is a name Vercel will send. Setting both is fine.

The cadence caveat, stated plainly

Per-minute cron is a paid-plan feature. Vercel documents that its Hobby plan allows a couple of cron jobs and runs each of them roughly once a day, at an hour Vercel picks; only paid plans accept an arbitrary cron expression. A board driven by Hobby cron therefore ticks daily.

Nothing is lost at a sparse cadence. Every task carries its own interval and is skipped when it is not due, and tasks are written so that a missed run delays work rather than dropping it. What stretches is latency — and three of the shortest-interval tasks are the chain behind everything a member actually notices:

Task What it drives
outbox.relay Moves committed events onto the queue
queue.drain Executes the queued jobs
subscriptions.instant Tells "as it happens" subscribers about new posts

At a daily tick, "notify me as it happens" becomes a daily digest in all but name, and queued mail waits for the same tick. Say that out loud before promising a community instant notifications on a Hobby plan.

A board that wants a minute-by-minute tick without paying Vercel for it drives the endpoint from anything else that can call a URL on a schedule — a GitHub Actions workflow, a systemd timer on some other machine, an uptime pinger — presenting TICK_SECRET as a bearer token. That works identically; the endpoint does not care who called it.

maxDuration is checked at build time

The tick route declares maxDuration = 300. Vercel documents that this figure is checked when the project builds, not when the function runs — so a plan that does not allow it fails the deployment rather than clamping the request.

300 is a ceiling, not a guarantee that every tick fits inside it. The four per-minute tasks are each aborted at their own budget, and those budgets add up to roughly six minutes if every one of them runs to its limit in the same tick — the scheduler runs them one after another, not in parallel. That is survivable rather than alarming: a tick killed mid-run leaves its claims to expire after 15 minutes, and the next tick picks the work up. Nothing is lost, and in practice the tasks do not all run to their limits at once.

With Fluid Compute, which Vercel makes the default for new projects, Hobby allows up to 300 seconds and the declaration builds as written. With Fluid Compute switched off, Hobby caps a function at 60 seconds and this build fails. Turning Fluid Compute back on is the fix available to you: the constant lives inside the published @meith/web package rather than in your board repository, so it is not a line you can edit in your own checkout.

Monitoring § Driving the tick over HTTP has the request and response contract, including what each status code means to a scheduler.

4. Run the installer

Deploy, then open https://your-domain/install.

Everything about the installer is the same here as on every other route and is written once, in Quickstart § Run the installer: the preflight report that separates blockers from warnings, the three form sections, the five steps, and the sealing that cannot be undone. Read that, then come back for the three things specific to this route:

  • The board's address is not asked for. APP_URL supplies it, and the preflight names the value it is using. Check that line — a preview URL left in APP_URL is a board whose password-reset links point at a deployment that will not exist next week.
  • The installer takes the same session-level advisory lock migrations do, so it needs DIRECT_DATABASE_URL for the same reason. Run against a pooler, it can report itself permanently in flight.
  • A warning about TICK_SECRET means what it says, not that the tick is unprotected. The preflight checks that one variable by name, so a board configured the way Vercel Cron needs — CRON_SECRET and nothing else — is warned that the tick has no secret while the tick is in fact guarded. It is a warning rather than a blocker, so you can install straight past it. Setting TICK_SECRET as well, as the environment recommends, is the tidier answer and clears the check.

Sealing is recorded in the database rather than in the deployment, so it survives every redeploy: /install answers 404 from then on, however many times the project builds afterwards.

What build-time migration means

Welding the migration to the build buys the && guarantee, and it costs three things. All three are properties of the arrangement rather than bugs, and Upgrading § When the build runs the migration is the full treatment.

The deploy window is inverted, not closed

When the deploy and the migration are separate events, new code serves against an old schema until somebody runs the command. Build-time migration does not remove that window — it turns it around. The migration runs during the build, while the previous deployment is still serving, so between the migration and the cutover it is old code against a new schema.

For a release that only adds things, that is safe. For one that removes or renames, the two-step rule still holds but you no longer get to order its steps, which leaves a single invariant:

A release's migration must be tolerated by the release before it, because that is the code serving while this release's build migrates.

So a destructive migration cannot travel in the same release as the code that tolerates it. Those have to be two deploys.

Every build migrates, previews included

The build command is the build command. It runs for every deployment the platform builds: the pull-request preview, the branch deployment, the redeploy of an old commit. Each one runs community migrate against whatever database that deployment's own environment variables name.

This is where the pattern cuts, and Vercel's default is on the wrong side of it. Vercel documents that a new environment variable applies to all environments unless you narrow it, which points preview and branch builds at the production database — and then the first preview build of an unmerged branch migrates production, from a schema nobody has reviewed, with no deploy of that branch ever having happened. Nothing in the build command can detect this: from the migration's point of view it is an ordinary run against an ordinary DATABASE_URL.

Overlapping deploys themselves are safe. Two builds triggered close together queue on the advisory lock, and the second finds the schema current and applies nothing.

Rollback does not un-migrate

Vercel documents its instant rollback as promoting a previous deployment by re-pointing an alias at an artefact that was built already. That runs no build, so it never calls community migrate. There is nothing to undo the schema with. Rolling back the other way, by redeploying an older commit, does build and does run community migrate, which then applies nothing, because migrations are forward-only.

Either route puts the old code back and leaves the schema where it is. A rollback is therefore only safe while the older code tolerates the newer schema.

There is one more shape to know: a successful migrate followed by a failed build. The && guards one direction only. It stops new code reaching an old schema and does nothing about the reverse, so the deployment aborts with the migration already applied and the previous release still serving — and it stays that way until some later build succeeds. The instinct is to roll back, and rolling back does nothing: the old code is already what is serving. Fix the build and deploy forward.

The limits worth knowing first

Large imports stay a CLI job against the database. Moving a MyBB or phpBB board across is one long-running command that reads the old board's MySQL database and copies its uploads directory as files. It is resumable by design — it stops at its row budget and you run it again — but it is a command you run from a machine with a terminal, not something a function does. Run it from a checkout of your board repository, pointed at the same DATABASE_URL, and follow Migrating from MyBB or phpBB.

Uploads and downloads both buffer wholly in function memory. The board uploads each object in a single request rather than a multipart one, holding the whole file in memory while it is processed and sent; reads have the same ceiling, because the download route buffers the whole object before it answers. So the function's memory limit — not the bucket — caps attachment size, in both directions. An attachment uploaded on a larger function will exhaust a smaller one on the way back down. Set the board's own attachment limit below what the function can hold, and remember it applies to serving as well as receiving.

Redis connections scale with concurrent instances, and the platform decides how many of those exist. A traffic spike that creates two hundred instances wants two hundred connections; a managed Redis plan with a connection cap will start refusing them. Pick a plan whose cap is above the concurrency the board is allowed to reach.

Objects are public only if the bucket is. The board sends no ACL on a write, so per-file visibility is accepted and ignored. Serving uploads publicly is a decision made on the bucket, not per file.

There is no docker compose run. Every operator command in Operations still exists, but you run it from a checkout of your board repository with the production environment in front of it, rather than inside a container on a server.

Leaving Vercel

This is the section that makes the rest of the page acceptable. A board is a community's record of itself, and a route that cannot be walked back is not a route this project would document. Nothing above puts your board somewhere you cannot get it out of, and here is the whole exit.

Everything durable is in PostgreSQL, in no proprietary format, with no export request to file with anybody. Neon and Upstash hand out ordinary Postgres and Redis connection strings that any host accepts.

The uploads are the one thing you have to carry out deliberately, and how much care that takes depends on the choice made under blob or a bucket:

  • On s3, the objects are already in a bucket you own. It outlives the Vercel project and you can copy it with any S3 tool you like.
  • On blob, they are in a Vercel Blob store, and that is Vercel-shaped state. There is no bucket to sync, no credential to hand a second tool, and deleting the Vercel project deletes the attachments with it. The backup bundle is the only copy you will ever have.

Either way the command below produces one bundle holding both halves, and the rest of this section is identical.

1. Take a bundle that carries both halves

From a checkout of your board repository, with the production environment in front of it:

community backup --uploads include

That runs pg_dump over DIRECT_DATABASE_URL when it is set, and pulls every object out of the object store into the same bundle.

The --uploads include flag is what forces that, and whether you need to type it depends on the driver:

Driver Default Why
s3 skips the bucket A bucket has its own backup story and is yours already, so the bundle does not duplicate it. --uploads include is not optional here if the bundle is meant to stand alone.
blob includes the store A Blob store has no backup story you can drive yourself, so a bundle that skipped it would be a bundle that silently lost the attachments.
local includes the directory Same reasoning.

Passing --uploads include is correct and harmless under all three, so pass it and stop having to remember which one you are on.

Read the last line the command prints. the database dump and the uploads means the objects are in the bundle. the database dump, no uploads means they are not, and restoring that bundle gives a board whose posts have broken images — which on blob is unrecoverable, because there is nowhere else the objects still exist.

This runs from anywhere; it does not have to run on Vercel. Put the project's variables in front of it and it talks to Neon and to the Blob store over the network:

DATABASE_URL=            # the pooled string
DIRECT_DATABASE_URL= # the direct string, for the dump
FILESTORE_DRIVER=blob
BLOB_READ_WRITE_TOKEN= # copy it out of the project's environment settings
community backup --uploads include

Copy the bundle somewhere that is none of the four vendors.

2. Stand up the destination

Follow Deploying by hand — a server, the compose file, a .env and a proxy — or the Quickstart if you would rather have the panel. Write the .env, and then bring up Postgres alone:

docker compose up -d postgres

Stop there. Do not run docker compose up -d --build yet, and do not open /install. The full up starts the migrate container, which applies the schema and exits 0 — and community restore refuses a target that already holds tables, saying so rather than writing over them. A fresh Postgres container gives you the empty database a restore insists on. This is the same sequence, for the same reason, as Disaster recovery § Restore the board; follow that page if you want the commands spelled out against a running stack.

You are restoring a board, not installing one.

3. Restore into it

RESTORE_DATABASE_URL=postgres://… community restore <bundle.tar.gz>

community restore refuses to run without RESTORE_DATABASE_URL and writes only there, so a restore can never be aimed at a live board by accident. It puts the uploads back where the destination's own FILESTORE_DRIVER says they go, whatever they came from — the local volume on a Compose deployment, a bucket if you set FILESTORE_DRIVER=s3 and the S3_* values, or --uploads-dir to write them to a directory you name. Objects taken out of a Blob store go into a bucket or onto a disk with no conversion step: the keys are the same on either side.

It applies any migrations the bundle predates on its way through, so there is no separate migration step to remember. Bring up the rest of the stack, then verify sign-in, recent threads, uploads, mail and scheduled tasks before you move DNS. Disaster recovery is the complete runbook and applies unchanged; leaving a platform is the same operation as recovering from one, minus the urgency.

4. Turn the tick back into a worker

The destination has a worker container, so drop vercel.json's cron entry and let it do what Vercel's scheduler was standing in for. It ticks every 60 seconds without being asked, which is the cadence the daily-tick caveat above was costing you.

That container presents TICK_SECRET, not CRON_SECRET — so make sure TICK_SECRET is set in the destination's .env, carrying over the value you were already advised to set on Vercel. CRON_SECRET stops being needed the moment the cron job is gone.

That is the whole move: a dump, the objects, and a guide that was already written. The board stays yours — which is the only condition under which running it on somebody else's functions is a reasonable thing to do.

One caveat, stated plainly because it is the only part of this route that does not survive neglect: on blob, that bundle is the sole copy of the attachments. A bucket sits there whether or not you ever think about it again; a Blob store goes when the project goes. If you are on blob, take a backup on a schedule rather than on the day you leave — see Disaster recovery — or move to s3 while the board is still up, which is the same backup and restore run against a destination that keeps the objects.

When it goes wrong

What you see What it is
The build fails on maxDuration Fluid Compute is off and the plan caps functions below 300 seconds — see above.
The build fails inside community migrate The migration itself failed, and the && stopped the build on purpose. Its error is in the build log, and nothing was deployed.
community migrate hangs with no output It is waiting for the advisory lock, or DIRECT_DATABASE_URL names the pooler rather than the direct string. See Operations.
The board refuses to boot with a FILESTORE_DRIVER error local is refused on Vercel outright. Set blob, or s3 and its companions.
The build fails naming BLOB_READ_WRITE_TOKEN FILESTORE_DRIVER=blob is set but no Blob store is attached to the project, or it was attached after this build's environment was read. Attach one under Storage, then redeploy.
The board boots but sends no mail No mail token is set, so MAIL_DRIVER fell back to log and every message goes to the build log. Add Resend to the project, or set MAIL_HTTP_ENDPOINT and MAIL_HTTP_TOKEN. MAIL_FROM must be set too, at a domain the provider has verified.
Mail is rejected with a sender error MAIL_FROM is at a domain the provider has not verified. Verify it in the provider's dashboard; nothing on this side can work around it.
Production migrated and nobody deployed anything A preview or branch build did it, because the database variables reach every environment — scope them to Production. The migration has applied and does not come back off.
A rollback did not fix the schema It never could. Rollback runs no build and so runs no migration — above. Deploy forward.
Nothing happens on a schedule The cron job is not reaching /api/system/tick, or the secret is wrong — a wrong secret gets a plain 404, because the endpoint does not admit it exists. Check /admin/system.
Notifications arrive a day late The tick is running at the Hobby cadence — the cadence caveat.
Uploads 404 from the browser On s3, S3_PUBLIC_BASE_URL is unset or wrong — R2 does not serve objects from the API endpoint S3_ENDPOINT names. On blob this does not arise: the board serves the bytes itself.
Mail is queued and never sent Either the tick is not running — queue.drain is what sends it — or MAIL_DRIVER=smtp is hanging on a blocked port. Use http.
An upload fails on a large file The function's memory limit, not the store. Lower the board's attachment limit.
community restore says the target already holds tables The destination's migrate container has already run, so the database is not empty. Bring up Postgres alone into a fresh volume — leaving, step 2. Nothing was written; it refused before touching anything.

Operations § Troubleshooting covers the failures that are about the board rather than the platform.

Next

You want to Read
Understand the driver set in depth Scaling out
Set up metrics and alerting Monitoring & alerting
Move between versions Upgrading a board
Run the board day to day Operations
Move a MyBB or phpBB forum here Migrating from MyBB or phpBB
Leave for a server Deploying by hand