Deployment
Deploy the web and docs apps to your platform of choice.
Last updated on
8 min readSyntaxKit 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, PLUNK_API_KEY, CONTACT_FORM_TO_EMAIL, EMAIL_DELIVERY_MODE, TURNSTILE_SECRET_KEY, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_ENDPOINT_URL_S3, NEXT_SERVER_ACTIONS_ENCRYPTION_KEY | Vercel runtime env, Docker environment:, Fly secrets, Render envVars |
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:
- Wire
pnpm db:migrate:deployinto a GitHub Actions step after merge and before the production push (the shipped.github/workflows/migrate.ymlis the canonical pattern). - Or run it locally against the production
DATABASE_URLbefore pushing, for small teams without CI automation yet.
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.
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
Repeat from apps/docs/:
cd apps/docs && fly launch --copy-config --no-deployThe docs fly.toml is simpler (no migrator, no Postgres) and runs on port 3001.
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
fly deployFly builds the image, runs the release_command (which calls migrate-database.sh), then starts the new machine. Roll-forward only happens after the release command exits 0, so a failed migration aborts the deploy cleanly.
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.
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 load balancer, trust the forwarded headers
Set TRUST_PROXY_HEADERS=true and TRUSTED_PROXY_IP_HEADERS=x-forwarded-for. Better Auth uses these to see the real client IP through the proxy. Render's blueprint sets them by default; on Fly's edge-only setup they aren't needed.
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.
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.
