Skip to content
Margin

Deploy

Publish on Vercel with Neon or run a Node server with Postgres.

The repository README includes a Vercel Deploy Button. It clones the source, creates a project, and requests a Neon Postgres store through the Marketplace. The store supplies pooled DATABASE_URL and direct DATABASE_URL_UNPOOLED connections. Keep its environment-variable prefix unchanged.

Vercel setup

  1. Use the Deploy Button or import your fork into Vercel.
  2. Connect Neon to the project for production and preview environments.
  3. Set SITE_NAME if you want to override the content’s display name. It is read at build time, so redeploy after changing it.
  4. Keep the repository’s install and build commands: bun install and bun run vercel-build.
  5. Edit the docs folder and push.

The button follows Vercel’s current Deploy Button source reference. The storage parameter is stores, a URI-encoded JSON array:

[
  {
    "type": "integration",
    "integrationSlug": "neon",
    "productSlug": "neon",
    "protocol": "storage"
  }
]

Vercel derives SITE_ORIGIN from the production project URL or deployment URL. Set it explicitly for a custom canonical origin. Production requests redirect to its host unless DOCS_CANONICAL_REDIRECT=0; previews do not redirect.

Build-time ingest

bun run vercel-build resolves a content key once, builds icons, runs database migrations, builds the server, checks the bundle budget, and then ingests content. Validation failures stop deployment. The server bundle contains the same DOCS_CONTENT_SHA that ingest used. Ingest runs last so a build that fails never advances the production pointer: retention protects the version that pointer named, which is therefore always one a successful build published.

The key resolves from DOCS_CONTENT_SHA, then VERCEL_GIT_COMMIT_SHA, then local git HEAD, then a generated development key. Each site, SHA, and kind identifies a content version. A ready version is reused unless DOCS_INGEST=force or the ingest CLI’s --force is set. Force replaces content under the same key; use a new key for a normal immutable publish.

Production ingest advances the site’s current pointer. Preview ingest writes preview versions without changing that pointer. A deployed server selects its baked commit, so a shared database does not make a preview replace the production content. Local development serves the current pointer.

Retention and rollback

After ingest, collection protects the new version, the prior production pointer (the last successful deploy build), and the current pointer. It also keeps the newest DOCS_KEEP_VERSIONS other production versions (default 20) and production versions younger than 30 days. Production versions beyond both limits and previews or failed attempts older than 30 days may be deleted. Watcher-created development versions can be collected sooner beyond the count. Versions with Blob references stay when their blobs cannot be deleted safely.

Age is measured from the content row’s created_at, not from the deployment’s creation time. Redeploying a ready SHA returns “already ingested”; its creation time and sequence do not change. A brand-new deployment reusing an old row can lose that content during a later ingest if the row falls outside the keep count and is not one of the protected pointers. DOCS_KEEP_VERSIONS=1 makes this easy to encounter; a recent deployment is not a new 30-day guarantee.

To refresh an existing content key, check out its commit and run:

bun scripts/ingest.ts --dir docs --slug docs --sha <commit-sha> --kind production --force

Alternatively, use DOCS_INGEST=force for that commit’s deployment build. Forced ingest atomically replaces the row with a new creation time and sequence. It changes what deployments pinned to that key read; production ingest also advances the current pointer. If the row was already collected, an ordinary redeploy ingests it again. Count retention can preserve rows longer than the age window, but it does not extend preview retention. Back up Postgres and any Blob store.

Self-hosting

Use Node.js 24 or newer and a reachable Postgres database. Set DATABASE_URL, SITE_ORIGIN, and NITRO_PRESET=node-server, then build through the same migration and ingest sequence:

NITRO_PRESET=node-server bun run vercel-build
node .output/server/index.mjs

Export the variables to both the build and the running server. Supply DATABASE_URL_UNPOOLED when the main connection uses a transaction-mode pooler. If Postgres uses a private CA, export NODE_EXTRA_CA_CERTS=/path/to/ca.pem before process startup for the build, migrations, ingest, and server. This adds the trusted CA while preserving certificate verification.

Optional integrations

Sentry is off without SENTRY_DSN; source-map uploads also require org, project, and auth token. Local development stays silent even with a DSN unless SENTRY_ENVIRONMENT is set (for example SENTRY_ENVIRONMENT=development). Agent-journey analytics are off without POSTHOG_KEY. The assistant needs AI Gateway access: use AI_GATEWAY_API_KEY outside Vercel or enable Gateway access with the deployment’s OIDC identity on Vercel. x-margin.search.mode: "static" explicitly selects static search.

No Blob store is needed for ordinary docs. Without BLOB_READ_WRITE_TOKEN, assets and the gzipped search corpus stay in Postgres within ingest size caps. With it, large assets and the corpus use Vercel Blob. One Blob store can serve production, every preview branch database and local development: each database writes under its own namespace (minted per database address, so a branched copy gets a new one) and deletes only its own blobs. Pointing a database at a new address starts a new namespace and leaves the old one’s blobs in the store. Hugeicons Pro is a build-time opt-in through HUGEICONS_PRO_TOKEN; Free icons are the default.