Skip to content
Docs just relaunched - explore the new sidebar, OG images, and AI-ready content.
Operate And Ship

Deployment

Deploy the web and docs apps to your platform of choice.

Last updated on

8 min read

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

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.

PostgreSQL logo

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.

TimeVariablesWhere 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_HOSTVercel 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_KEYVercel 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

TargetMigration handlingMulti-app patternBuilt-in CDNConfig in repo
VercelCI step (pnpm db:migrate:deploy before deploy)Two Vercel projects (one per app)Yesapps/web/vercel.json, apps/docs/vercel.json
Fly.ioRelease hook (release_command in fly.toml)Two Fly apps via two fly.toml filesYes (Fly Edge)fly.toml, apps/docs/fly.toml
RenderPre-deploy hook (preDeployCommand)Two Render services via blueprintYesrender.yaml
Self-hosted DockerManual (docker compose run --rm migrator)Three services in one compose fileNodocker-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:deploy into a GitHub Actions step after merge and before the production push (the shipped .github/workflows/migrate.yml is the canonical pattern).
  • Or run it locally against the production DATABASE_URL before 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-deploy

Run 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-deploy

The 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-web

Deploy

fly deploy

Fly 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 -d

The 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), with pnpm install as 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.sh works on any Linux host with Node and the packages/database workspace 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

Was this page helpful?

On this page