Deployment
Deploy the web and docs apps to your platform of choice.
SyntaxKit is framework-agnostic: the product app and the docs site both build with Next.js's standalone output, so anywhere that runs Node or Docker can host them. The kit ships configs for four targets (Vercel, Fly.io, Render, and self-hosted Docker Compose), and any generic Next.js host works using the same shape as Vercel.
Migrations stay separate from app boot: every deploy must run prisma migrate deploy before the new version takes traffic. The kit wires this in per target: a release hook on Fly and Render, an explicit step on Vercel CI and Docker Compose.
What you deploy
The four moving parts: web, docs, migrator, and your Postgres.
Build-time vs runtime env
Which vars bake into the bundle and which inject at runtime.
Picking a target
Compare migration handling, multi-app, CDN, and config at a glance.
Deploy to your target
Per-platform runbooks for Vercel, Fly, Render, and Docker.
What You Deploy
apps/web
Product app on port 3000. Needs DATABASE_URL and BETTER_AUTH_SECRET; runs every product feature.
apps/docs
Fumadocs site on port 3001. No database. Optional but bundled, since most buyers ship docs alongside the app.
Migrator
A one-shot prisma migrate deploy, run before each release via a release hook or an explicit step.
Postgres
Your responsibility. Bring Neon, Supabase, RDS, or any Postgres; the kit ships none in compose.
Build-Time vs Runtime Env
Every Next.js host draws a line between vars baked into the JS bundle at build time and secrets injected at runtime. Knowing which is which prevents the confusing class of deploy where a value "won't update" until you rebuild.
| Time | Variables | Where they go |
|---|---|---|
| Build-time (inlined into the JS bundle) | NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_DOCS_URL, NEXT_PUBLIC_S3_PUBLIC_URL, NEXT_PUBLIC_S3_BUCKET_NAME_IMAGES, NEXT_PUBLIC_TURNSTILE_SITE_KEY, NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST, NEXT_PUBLIC_POSTHOG_UI_HOST | Vercel build env, Docker --build-arg, Fly [build.args], Render build args |
| Runtime (server only) | DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_ID_PRO_MONTHLY, STRIPE_PRICE_ID_PRO_YEARLY, EMAIL_DELIVERY_MODE, provider credentials (one of PLUNK_API_KEY / RESEND_API_KEY / POSTMARK_SERVER_TOKEN / BREVO_API_KEY / SENDGRID_API_KEY, or SMTP_HOST + SMTP_USER + SMTP_PASS + optional SMTP_PORT / SMTP_SECURE), EMAIL_FROM, CONTACT_FORM_TO_EMAIL, TURNSTILE_SECRET_KEY, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_ENDPOINT_URL_S3, UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, NEXT_SERVER_ACTIONS_ENCRYPTION_KEY | Vercel runtime env, Docker environment:, Fly secrets, Render envVars |
Required in production. The boot doctor validates these runtime vars on every host. When one is missing in production the app does not crash — it renders a branded "Configuration error" page (via the ConfigErrorGuard in the root layout) that names the offending vars, and /api/health returns HTTP 503. On a health-gated platform (Fly, Render) that 503 fails the release's health check, so the misconfigured deploy is rolled back instead of taking traffic. Set all of these:
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKEN— Upstash Redis powers abuse protection. TheDISABLE_ABUSE_PROTECTIONopt-out is rejected in production, so both values are mandatory.- A real
EMAIL_DELIVERY_MODE(plunk,resend,postmark,brevo,sendgrid, orsmtp) plus that provider's credentials andEMAIL_FROM— production rejects thelogandnoopdelivery modes to avoid silently dropping outgoing mail.
NEXT_PUBLIC_* values are inlined at build time, so changing one requires a rebuild on every host. Plan to redeploy when you rotate a Stripe price ID or move S3 buckets, even if the secret values are unchanged.
Picking A Target
| Target | Migration handling | Multi-app pattern | Built-in CDN | Config in repo |
|---|---|---|---|---|
| Vercel | CI step (pnpm db:migrate:deploy before deploy) | Two Vercel projects (one per app) | Yes | apps/web/vercel.json, apps/docs/vercel.json |
| Fly.io | Release hook (release_command in fly.toml) | Two Fly apps via two fly.toml files | Yes (Fly Edge) | fly.toml, apps/docs/fly.toml |
| Render | Pre-deploy hook (preDeployCommand) | Two Render services via blueprint | Yes | render.yaml |
| Self-hosted Docker | Manual (docker compose run --rm migrator) | Three services in one compose file | No | docker-compose.yml, .env.docker.example |
Netlify, Railway, Heroku, Coolify, and other generic Next.js hosts follow the same shape as Vercel; Cloudflare Workers and Pages are on the roadmap. The per-target runbooks below cover all of them.
Deploy To Your Target
Pick your platform and expand its runbook. Vercel is the fastest path; the others trade convenience for more control.
Vercel: the fastest path
Vercel auto-detects Next.js, and the shipped vercel.json files declare the workspace-aware build commands.
Create two Vercel projects
One pointing at apps/web (root directory apps/web), one pointing at apps/docs (root directory apps/docs). Both shipped vercel.json files set:
{
"buildCommand": "cd ../.. && pnpm build --filter=@syntaxkit/web",
"installCommand": "pnpm install",
"framework": "nextjs"
}so Vercel installs the workspace once and builds only the targeted app.
Add env vars in the dashboard
Both NEXT_PUBLIC_* and server secrets go into the same Environment Variables panel on each project. Vercel handles the build-vs-runtime split automatically.
Run migrations before each deploy
Vercel has no release hooks, so migrations run outside the platform:
The kit ships .github/workflows/migrate.yml as a manual workflow. It is triggered by workflow_dispatch only (never automatically by a push or merge) and is gated by GitHub Environment protection (staging / production). To run it: open the repo's Actions tab → select Migrate Database → Run workflow → choose the target environment. It checks out the repo, installs dependencies, and executes prisma migrate deploy via scripts/deploy/migrate-database.sh against that environment's DATABASE_URL secret.
Pick whichever pattern fits your team, but keep the ordering — migrate first, then let the new deploy take traffic:
- Run the manual
.github/workflows/migrate.ymlworkflow (or your own CI step) for the target environment before promoting the build. - Run
pnpm db:migrate:deploylocally against the productionDATABASE_URLbefore pushing, for small teams that don't yet need CI automation.
Whichever pattern you pick, write backward-compatible (expand/contract) migrations: the old app version must keep working against the new schema. On a serverless platform you can't perfectly gate ordering, so a brief window where the new deploy serves traffic before (or while) migrations apply must stay safe. This isn't Vercel-specific — Fly and Render release hooks migrate while the old version is still serving, and a rollback restores old code against the already-migrated schema — so it has its own target-agnostic home: Rollback And Recovery → Migrations, rollback, and expand/contract.
The repo ships the manual workflow by default for production safety — auto-migrating on every push happens without review and assumes a DATABASE_URL wired into CI. Teams that want full automation can add a CI job that runs pnpm db:migrate:deploy on push to main before/at deploy time.
Push to your default branch
Vercel builds and promotes both projects automatically. The first deploy takes 4-6 minutes; subsequent deploys hit the build cache and finish in 1-2.
Both vercel.json files declare "framework": "nextjs", so Vercel auto-detects the runtime, output mode, and routing once the projects are created. No manual project-settings tweaks needed.
OAuth on Vercel previews
Every Vercel preview deployment gets a unique URL (https://your-app-<hash>-<scope>.vercel.app), but an OAuth provider (GitHub, Google) only accepts the one callback URL registered in its console. So social sign-in works on production but fails on previews (and on any origin whose callback isn't registered).
The kit solves this with Better Auth's OAuth proxy (opt-in). When enabled, preview/dev deployments run their OAuth flow through your production origin — which owns the single registered callback URL — and production hands the authenticated profile back to the preview (encrypted, never touching production's database). One callback registration then works across unlimited previews.
Better Auth infers the current (preview) URL on its own from the request and VERCEL_URL, so you don't configure that. The only thing it can't infer is which origin is production — and in this kit that's simply your app base URL. So set NEXT_PUBLIC_APP_URL (or BETTER_AUTH_URL) to your production URL on every environment, including Preview — then add two vars on both the Production and Preview environments (Vercel scopes env vars per-environment):
| Variable | Value |
|---|---|
OAUTH_PROXY_SECRET | The same value everywhere (production, preview, and localhost if you proxy locally). Generate with openssl rand -base64 32. A dedicated key — not BETTER_AUTH_SECRET — so it can be shared safely; a leaked proxy secret can't forge sessions or decrypt other data. Must be ≥ 32 chars. |
AUTH_TRUSTED_ORIGINS | The preview origins to trust, wildcards allowed — e.g. https://*.vercel.app. Lets production trust the previews it redirects back to. |
Then register only the production callback with each provider, e.g. https://your-app.com/api/auth/callback/github. No per-preview registration is needed.
The proxy is inert on production (it skips when the current origin already matches the production origin), and it stays disabled entirely until OAUTH_PROXY_SECRET is set — so leaving these blank changes nothing. Run pnpm setup:doctor to confirm the "OAuth proxy" line and catch a missing AUTH_TRUSTED_ORIGINS. If previews fail with state_mismatch, the secret isn't identical across environments; if the proxy never kicks in, your base URL is resolving to the preview URL instead of production (set NEXT_PUBLIC_APP_URL/BETTER_AUTH_URL to production on the Preview environment too).
Email links follow the preview too
AUTH_TRUSTED_ORIGINS does double duty. Transactional auth emails (verification, password reset, email-change confirmation) also build their links from the app base URL — which you've pinned to production — so on a preview they would otherwise point at production (and the link's token, signed with that environment's BETTER_AUTH_SECRET, would fail to verify there). When the incoming request's origin is covered by AUTH_TRUSTED_ORIGINS, the kit builds these links against that origin instead, so the whole verify/reset flow stays on the deployment the user is actually using. A request origin that isn't in the allowlist falls back to the static base URL, so this can't be used to send a link to an untrusted host. No extra configuration — it reuses the same AUTH_TRUSTED_ORIGINS the proxy already needs.
First deploy with Stripe billing
Billing in this kit is fail-closed: once STRIPE_SECRET_KEY is set, a missing STRIPE_WEBHOOK_SECRET is a fatal config error (a partial billing config). That's the right behavior in steady state — it stops you from running live billing with unverifiable webhooks — but it creates a bootstrap problem on the very first deploy. You can't create the Stripe webhook endpoint (and get its whsec_… signing secret) until the app has a live URL, and a misconfigured deploy won't serve normal traffic to give you that URL. The naive first deploy therefore comes up serving the "Configuration error" page (listing Billing is partially configured. Missing: STRIPE_WEBHOOK_SECRET.) with /api/health returning 503 — so on Fly/Render the release is marked unhealthy and never goes live.
The official happy path is a two-phase first deploy: boot once with a placeholder secret to get the URL, then swap in the real secret.
While the placeholder is in place, the app boots but webhook signature verification rejects every incoming Stripe event (400) — processWebhookEvent can't verify a signature against a fake secret. That's expected and harmless during this short window: you haven't registered the endpoint with Stripe yet, so no real events are being sent. Phase 4 closes the gap. Do not leave the placeholder in place once the app is live.
Deploy with a placeholder webhook secret
Set STRIPE_SECRET_KEY to your real key and STRIPE_WEBHOOK_SECRET to a placeholder so the boot doctor is satisfied and the app starts:
STRIPE_WEBHOOK_SECRET="whsec_placeholder"Set it in your host's runtime env (Vercel project settings, Fly secrets, Render env, Docker env) — it's a runtime secret, not a build arg.
Deploy and note the live URL
Trigger the deploy. Once it's live, note the public URL the host assigned, e.g. https://your-app.vercel.app.
Create the Stripe webhook endpoint
In the Stripe Dashboard, add a webhook endpoint pointing at https://<your-app>/api/webhooks/stripe (the kit's webhook route is apps/web/app/api/webhooks/stripe/route.ts). Subscribe to the nine events the kit handles (see Going To Production: Register the production webhook). Stripe shows the endpoint's signing secret — copy the real whsec_….
Swap in the real secret and redeploy
Replace the placeholder with the real value in the host's runtime env and redeploy:
STRIPE_WEBHOOK_SECRET="whsec_…" # the real value from the Stripe DashboardAfter the redeploy, signature verification passes and the webhook loop is live. Confirm by checking the Stripe Dashboard's webhook delivery log for a 200.
This two-phase flow only applies the first time, before the Stripe endpoint exists. On every subsequent deploy the real STRIPE_WEBHOOK_SECRET is already in the host env, so the app boots normally. The same sequence works on any host (Fly, Render, Docker) — only the place you set the runtime secret differs.
Fly.io
Two Fly apps, one per Next.js app, both built from the kit's Dockerfiles. The web app's release hook handles migrations automatically.
Both shipped fly.toml files use placeholder values (your-app-web, your-app-docs, empty NEXT_PUBLIC_*). Replace them with your own app name, URLs, Stripe price IDs, Turnstile site key, S3 bucket, and PostHog key before running fly launch. The placeholders exist so a fresh deploy never inherits another tenant's project IDs.
Launch the web app
fly launch --copy-config --no-deployRun this from the repo root. Fly picks up the shipped fly.toml, which already declares the Dockerfile path, build args, primary region (iad), VM size, and the release command:
[deploy]
release_command = '/bin/sh /release/scripts/deploy/migrate-database.sh'Launch the docs app
Run this from the repo root too — not from apps/docs/ — and point Fly at the docs config with -c:
fly launch --copy-config --no-deploy -c apps/docs/fly.tomlThe docs fly.toml is simpler (no migrator, no Postgres) and runs on port 3001, but it shares apps/Dockerfile with the web app. That Dockerfile's first COPY pulls pnpm-lock.yaml / pnpm-workspace.yaml from the repo root, so the build context must be the repo root. Fly uses the working directory you run the command from as the build context, and resolves -c/build.dockerfile relative to it — so both apps deploy from the repo root, and apps/docs/fly.toml sets dockerfile = 'apps/Dockerfile' (root-relative), exactly like the web fly.toml. Running cd apps/docs && fly launch would make apps/docs the build context and the COPY would fail.
Set runtime secrets
Build args live in fly.toml [build.args] for NEXT_PUBLIC_*. Runtime secrets go via fly secrets:
fly secrets set \
DATABASE_URL="postgresql://..." \
BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \
STRIPE_SECRET_KEY="sk_live_..." \
STRIPE_WEBHOOK_SECRET="whsec_..." \
GITHUB_CLIENT_ID="..." \
GITHUB_CLIENT_SECRET="..." \
--app syntaxkit-webDeploy
Both apps deploy from the repo root so the build context is the repo root. Deploy the web app with the default config, and the docs app with -c:
fly deploy # web app (uses ./fly.toml)
fly deploy -c apps/docs/fly.toml # docs appFor the web app, Fly builds the image, pushes to its registry, runs the release_command (which calls migrate-database.sh), and starts the new machine. Roll forward only happens after the release command exits 0, so a failed migration aborts the deploy cleanly. The docs app has no release_command (no database), so it just builds and rolls forward.
The web app reaches docs via Fly's internal .internal DNS. The shipped web fly.toml sets DOCS_URL = 'http://syntaxkit-docs.internal:3001' so the marketing site can server-render docs links without a public-internet round-trip. Health checks are pre-configured against /api/health.
Render
Render reads the kit's render.yaml blueprint at the repo root and provisions both services in one click. Migrations run as a preDeployCommand on every release.
Push the repo to GitHub
The shipped render.yaml defines both services (syntaxkit-web and syntaxkit-docs), their Dockerfiles, build filters, and env-var declarations.
Deploy the blueprint
Visit https://dashboard.render.com/select-repo?type=blueprint, connect your repo, and Render auto-detects render.yaml.
Fill in runtime secrets when prompted
The blueprint declares secrets with sync: false so they don't sync to source control. BETTER_AUTH_SECRET uses generateValue: true, so Render generates one for you on first deploy.
Push to deploy
Render runs preDeployCommand: /bin/sh /release/scripts/deploy/migrate-database.sh before each release, then deploys both services. Build filters mean only the relevant service rebuilds when apps/web/** or apps/docs/** changes.
Proxy headers (TRUST_PROXY_HEADERS=true, TRUSTED_PROXY_IP_HEADERS=x-forwarded-for) are already set in render.yaml. Better Auth needs these to see real client IPs through Render's load balancer for rate-limit and audit purposes.
Self-hosted Docker
The docker-compose.yml at the repo root defines three services: a one-shot migrator (in the tools profile), web, and docs. Buyers bring their own Postgres.
cp .env.docker.example .env
docker compose build
docker compose run --rm migrator
docker compose up -dThe migrator service uses the tools profile so it doesn't run with up; invoke it explicitly before each release. Web and docs both ship healthchecks (/api/healthz for web, / for docs), and web is configured with a 30-second stop_grace_period so Next.js after() callbacks can finish before SIGTERM.
Pre-built images on GHCR. .github/workflows/docker.yml publishes the same images to GHCR on every push to main and every semver tag:
ghcr.io/<owner>/<repo>/web:latest(and:<sha>,:<branch>,:<version>)ghcr.io/<owner>/<repo>/docs:latest
Useful for Kubernetes, Coolify, Dokku, Nomad, or any other Docker host. Pull the image, set runtime env, and run migrations either by building the migrator stage (docker build --target migrator ...) or by invoking scripts/deploy/migrate-database.sh from a job container that has Prisma available.
The published images bake NEXT_PUBLIC_* at build time from your CI repo variables. These values are inlined into the client bundle during next build in the GHCR workflow (see Build-Time vs Runtime Env) — setting them as runtime env on the host does nothing. On a fresh fork the repo's Actions variables are unset, so the workflow builds with empty NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_S3_*, NEXT_PUBLIC_TURNSTILE_SITE_KEY, NEXT_PUBLIC_POSTHOG_*, etc., and the published image points at nothing usable. Before relying on the GHCR images, set the repo's Actions variables (Settings → Secrets and variables → Actions → Variables) to your production values. And because they're baked in, any change to a NEXT_PUBLIC_* value requires republishing the image — a redeploy of the same tag won't pick it up.
Netlify and other Next.js hosts
The kit ships no netlify.toml, but Netlify, Railway, Heroku, Coolify, and other generic Next.js hosts run the kit cleanly using the same shape as Vercel:
- Build the workspace via
pnpm build --filter=@syntaxkit/web(or--filter=@syntaxkit/docs), withpnpm installas the install command. - Set the same build-time vs runtime env split documented above.
- Run migrations from CI before the deploy step.
scripts/deploy/migrate-database.shworks on any Linux host with Node and thepackages/databaseworkspace available.
Each platform declares those settings its own way (Netlify uses netlify.toml, Railway uses dashboard config); refer to your host's Next.js setup guide for the exact syntax.
Cloudflare (roadmap)
Cloudflare Workers and Pages support is on the roadmap, not shipped today. The Edge runtime requires changes the kit hasn't made yet (Prisma adapter swap to @prisma/adapter-d1 or similar, edge-compatible auth session handling, removed output: "standalone"). Until the kit ships first-class Cloudflare support, deploy to Vercel, Fly.io, Render, or Docker.
Operational Notes
Three production details worth knowing, whichever target you pick.
Multi-instance deploys need NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
Generate with openssl rand -base64 32 and set the same value on every replica. Without a shared key, a server-action signature created by replica A can't be decrypted by replica B, and you'll see intermittent failures on form submissions that span multiple requests.
Behind a proxy that forwards the client IP
Set TRUST_PROXY_HEADERS=true and TRUSTED_PROXY_IP_HEADERS to the header your proxy sets. Better Auth uses these to read the real client IP so per-IP auth rate limits (brute-force/credential-stuffing defenses) key off the actual visitor, not the proxy. Fly does forward the client IP (via X-Forwarded-For / Fly-Client-IP), so proxy headers are needed there — the kit's fly.toml therefore defaults TRUST_PROXY_HEADERS=true. Render's blueprint sets it too. On self-hosted Docker Compose (or any other host) set both vars only when the app sits behind a trusted proxy you control. Tradeoff: these headers are client-spoofable, so trust them only behind a proxy that overwrites them — trusting them on a directly-exposed app lets a caller forge its IP and slip past the rate limits.
When BETTER_AUTH_URL is required
Only when Better Auth needs a different origin from NEXT_PUBLIC_APP_URL. Most deployments leave it unset and let Better Auth derive its base URL from the public app URL.
Where To Go Next
Going To Production
The pre-launch checklist: hardening, smoke tests, observability, and rollout strategy.
Rollback And Recovery
Rolling back a bad deploy per platform, how rollback interacts with an already-applied migration, backups/DR, and secret rotation.
Database
How prisma migrate deploy works and what the migrator script actually runs.
Webhooks And Async Workflows
The Stripe webhook URL to register on each host and the idempotency story behind it.
Setup
The full env-var matrix referenced throughout this page.
Environment Variables
Per-package toggles useful for tightening a production deploy.