A forum, built on Meith, running as Vercel functions.
- A copy of this repository under your own GitHub account. Vercel builds
from it, and every later push to
mainredeploys. - A Neon Postgres database, attached to the project. Neon publishes the
pooled connection string as
DATABASE_URLand the direct one asDATABASE_URL_UNPOOLED. - An Upstash Redis store, attached the same way, for the shared cache. It
publishes
KV_URL, which the board reads asREDIS_URL—KV_REST_API_URLbeside it is an HTTPS endpoint and is not used for this. - A Vercel Blob store for uploads, which publishes
BLOB_STORE_IDinto the project by itself. That is the whole credential: the board hands the id to Vercel's SDK, which authenticates with the deployment's own OIDC identity, so there is no token to copy. This is what used to be four hand-typedS3_*secrets. - A Vercel project carrying
vercel.json— the build commandcommunity migrate && forum-web build --at-root, which applies the schema before it builds, materializes the board's app at the project root so the artefact lands where Vercel reads it, and the cron entry that drives the tick.
Mail is the one thing the button does not set up, and it takes one click after the deploy — see Mail, in one click below. That is also where the address the board sends from goes: it has to be at a domain your provider has verified, which cannot be true of any domain before a provider exists. The board boots and runs without mail, and delivers nothing, silently, until it is done.
Two secrets, generated rather than chosen. Thirty-two characters is a floor the board enforces at boot, not a suggestion:
openssl rand -hex 32 # AUTH_SECRET
openssl rand -hex 32 # CRON_SECRETCRON_SECRET is the name Vercel Cron sends, as Authorization: Bearer, and it
cannot be told to send another — the caller is the platform, so this one has to
be an environment variable both ends can read, and cannot be something the
board makes up for itself. Note that this floor is stricter than the 16
characters Vercel's own cron documentation suggests — a value generated by
following those instructions is refused here, and the fix is a longer secret.
AUTH_SECRET seals members' two-factor secrets and signs the unsubscribe links
in outgoing mail. It stays in the environment deliberately: a copy of the
database is then not enough to forge either.
That is the whole form. Everything else the board works out from the stores this button just linked to the project:
DATA_SOURCE=postgres
QUEUE_DRIVER=postgres
CACHE_DRIVER=redis
FILESTORE_DRIVER=blob
MAIL_DRIVER=httpDIRECT_DATABASE_URL comes from Neon's own DATABASE_URL_UNPOOLED, or
POSTGRES_URL_NON_POOLING if that one is absent — migrations and the first-run
installer each hold a session-level advisory lock, which the pooled
DATABASE_URL cannot hold. REDIS_URL comes from Upstash's KV_URL, the one
variable it publishes that speaks the Redis protocol.
Every one of those derivations is scoped to this platform, fires only where you
have not set the variable yourself, and refuses to boot rather than guess.
If a store is missing, or publishes a name this board does not know, the deploy
stops with a message naming every variable it looked at — it will not fall back
to caching inside each instance, or to uploads on a disk that is discarded with
the instance. When the name is one we do not know, set REDIS_URL or
DIRECT_DATABASE_URL in the project's environment settings and the derivation
stands aside.
If you would rather keep uploads somewhere you hold yourself — see Leaving
Vercel below for why that matters — set FILESTORE_DRIVER=s3 and add
S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY in the
project's environment settings, with S3_ENDPOINT for a bucket that is not AWS
(S3_REGION=auto for R2). The same board runs either way.
A board that cannot send mail cannot reset a password, so do this before you invite anybody.
- Open your project on Vercel, go to Storage → Marketplace (or Integrations), and add Resend. It creates a Resend account linked to the project and connects your sending domain.
- Verify that domain in the Resend dashboard if you have not already. Resend refuses to send from an address at a domain it has not verified.
- Add
MAIL_FROMto the project's environment settings — an address at that verified domain, and the only mail value you ever type. The deploy form does not ask for it, because a sender address is not something you can know before there is a provider to verify it. - Redeploy, or let the next push redeploy.
That is all. The integration publishes its key into the project as
RESEND_API_KEY, and the board reads that name: with it set, and MAIL_FROM
beside it, mail sends over Resend's HTTPS API with nothing further to
configure.
The board is not tied to Resend. Its mail driver is a plain JSON-over-HTTPS
sender that posts {from, to, subject, text, html, reply_to} with a bearer
token — Resend's POST /emails happens to be exactly that shape, which is why
it needs no adapter. Any provider with the same shape works: set
MAIL_HTTP_ENDPOINT, MAIL_HTTP_TOKEN and MAIL_DRIVER=http in the
project's environment settings, and set the first two together — either
one on its own stands the Resend bridge down, so a key issued for Resend is
never presented to an endpoint you chose. Only RESEND_API_KEY turns the
driver on by itself. Delete it once you have moved off Resend.
Check it worked: sign in as the administrator and use the test button on /admin → Settings → Mail.
The build applies migrations, but an empty schema is not yet a board. Open
https://<your-deployment>/install once the first deploy is green. It asks for
the board's name and address and for the first administrator's username, email
and password, creates the board and that account, and then seals itself:
/install answers 404 from then on. Run it against the database you intend to
keep — the screens are the ones
docs/quickstart.md
walks through.
vercel.json asks Vercel to call /api/system/tick on * * * * *. That
route is how bans expire, digests send, mail leaves the outbox and the queue
drains; nothing here runs it on its own, because there is no worker process on
a function platform. Two things about it are worth knowing before you
deploy rather than after:
- A per-minute schedule needs a paid plan. Hobby allows a couple of cron
jobs and runs each of them roughly once a day, at an hour Vercel chooses;
only paid plans accept an arbitrary cron expression. A board ticking daily
still loses nothing — tasks are written so a missed run delays work rather
than dropping it — but "as it happens" notifications become a daily digest in
all but name. To keep a minute-by-minute tick on Hobby, drive
/api/system/tickfrom something else that can call a URL on a schedule — a GitHub Actions workflow, a systemd timer, an uptime pinger — presentingTICK_SECRETinstead. maxDuration = 300is validated when the project builds, not when the function runs. A plan that does not allow 300 seconds therefore fails the deployment rather than clamping the request. With Fluid Compute — the default for new projects — Hobby allows 300 and this builds as written. With Fluid Compute switched off, Hobby caps a function at 60 seconds and the build fails. Turn Fluid Compute back on.
A tick that reaches the tasks and runs them answers 200 even when one of them
threw, with ok: false and the failure named in ran. That is deliberate:
schedulers retry non-2xx answers, and a task that fails every time would turn
each retry into another attempt against whatever it is failing against.
npm install --save-exact @meith/web@latest @meith/cli@latest @meith/theme-default@latest
npm install --save-exact next@$(node -p "require('./node_modules/@meith/web/package.json').dependencies.next")
git commit -am "Upgrade Meith and the Next.js version it builds with"
git pushVercel rebuilds on the push, and the build command applies the new migrations
before it builds. --save-exact matters and .npmrc already sets it for
everything else installed here.
The second command is not optional. This board pins next itself — Vercel
reads that pin to pick its Next.js builder — and nothing bumps it for you.
Upgrading only the @meith/* packages leaves two versions of Next
installed, the board built with one and the platform configured for the
other. Reading the version out of the freshly installed @meith/web keeps
them the same without anybody having to know the number.
Migrations are forward-only. Recovery is by restore, so take a backup first — there is no down migration to undo a destructive one.
A board must stay movable, and the Blob store is the one part of this shape that
is not portable: Neon and Upstash hand out ordinary Postgres and Redis strings
that any host accepts, but a Vercel Blob store is reachable only through Vercel's
own API and there is no bucket to sync out of it. The uploads are the thing you
have to carry out deliberately, and community backup is how.
Under FILESTORE_DRIVER=blob, community backup includes the uploads by
default — it walks the Blob store, pulls every object, and puts them in the
bundle beside the database dump. This is the opposite of the s3 default, which
skips them, because a bucket has its own backup story you can drive yourself and
a Blob store does not:
DATABASE_URL=… # Neon's pooled string
DIRECT_DATABASE_URL=… # Neon's DATABASE_URL_UNPOOLED
FILESTORE_DRIVER=blob
BLOB_READ_WRITE_TOKEN=… # create one on the store; see below
npm run community -- backupRun that from a checkout of this repository, with those four values in the environment — the CLI talks to Neon and to the Blob store over the network, so it does not have to run on Vercel.
That last one is the one value this route asks you to make by hand, and only
here. On the deployment the board reaches the store with BLOB_STORE_ID and
the deployment's OIDC identity, which a command on your own machine does not
have. Open the store under Storage, create a read-write token, and use it
for the backup; the board itself never needs it. The bundle it writes holds the dump and
every object. Check the last line it prints: if it says no uploads, the
uploads are not in the bundle and restoring it gives a board whose posts have
broken images.
Restoring puts them wherever the restoring board's FILESTORE_DRIVER points,
so the same bundle moves the board either onward or away:
# onto a self-hosted board with a bucket
FILESTORE_DRIVER=s3 S3_BUCKET=… RESTORE_DATABASE_URL=… npm run community -- restore bundle.tar.gz
# onto a board that keeps uploads on its own disk
RESTORE_DATABASE_URL=… npm run community -- restore bundle.tar.gz --uploads-dir ./uploadsTake one before you need it. A Blob store deleted with the Vercel project takes the attachments with it, and there is no second copy anywhere unless you made one.
Everything above is one deployment shape.
docs/self-hosting.md is the
same board as containers you run yourself, and npx create-meith <name>
scaffolds that shape instead — a Dockerfile, a compose file and a workflow that
builds the image. docs/scaling.md
explains why the drivers above are what they are, and why an S3-compatible
bucket is the portable choice for uploads everywhere but here.