Skip to content

Repository files navigation

meith-board

A forum, built on Meith, running as Vercel functions.

Deploy with Vercel

What the button provisions

  • A copy of this repository under your own GitHub account. Vercel builds from it, and every later push to main redeploys.
  • A Neon Postgres database, attached to the project. Neon publishes the pooled connection string as DATABASE_URL and the direct one as DATABASE_URL_UNPOOLED.
  • An Upstash Redis store, attached the same way, for the shared cache. It publishes KV_URL, which the board reads as REDIS_URLKV_REST_API_URL beside it is an HTTPS endpoint and is not used for this.
  • A Vercel Blob store for uploads, which publishes BLOB_STORE_ID into 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-typed S3_* secrets.
  • A Vercel project carrying vercel.json — the build command community 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.

What to type into the deploy form

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_SECRET

CRON_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=http

DIRECT_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.

Mail, in one click

A board that cannot send mail cannot reset a password, so do this before you invite anybody.

  1. 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.
  2. 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.
  3. Add MAIL_FROM to 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.
  4. 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.

First run: /install

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.

The tick

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/tick from something else that can call a URL on a schedule — a GitHub Actions workflow, a systemd timer, an uptime pinger — presenting TICK_SECRET instead.
  • maxDuration = 300 is 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.

Upgrading

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 push

Vercel 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.

Leaving Vercel

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 -- backup

Run 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 ./uploads

Take 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.

Somewhere other than Vercel

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages