Site

The boilerplate is optimized for Docker deployment with Coolify, featuring multi-stage builds, health checks, and proper environment variable handling.

Multi-Stage Docker

Optimized 3-stage build for minimal image size.

Health Endpoint

Database readiness at /api/health and dependency-independent process liveness at /api/health/live.

Coolify Optimized

docker-compose.yml configured for Coolify deployment.

Non-Root User

Runs as non-root user for enhanced security.

Docker Architecture

The deployment uses a multi-stage Docker build optimized for production. Here's how each stage works:

Stage Purpose What Happens
1. Dependencies Install pnpm packages Uses Node 24.18.1 LTS (Debian slim), enables Corepack, and runs pnpm install --frozen-lockfile. This layer is cached unless a workspace manifest or the lockfile changes.
2. Builder Build the Next.js app Copies source code, injects NEXT_PUBLIC_* variables as build args, runs pnpm run build to create standalone output.
3. Runner Production image Minimal image with only the built app. Creates non-root user, copies standalone build, exposes port 3777.

Key Features

  • Standalone Output - Next.js creates a self-contained build that doesn't require node_modules at runtime
  • Non-Root User - Application runs as nextjs user for security (not root)
  • Health Endpoint - /api/health is available for the reverse proxy/orchestrator; the Docker/Compose healthcheck is intentionally disabled (use /api/health/live for liveness)
  • Debian Slim Base - Pinned node:24.18.1-slim image keeps build and runtime versions aligned

Docker Compose (Coolify)

The docker-compose.yml file orchestrates the deployment with these key configurations. When init selects postgres_pg_cron, it writes COMPOSE_PROFILES=postgres-pg-cron; Compose then starts isolated critical and background workers automatically alongside the web application.

Configuration Purpose
Build Args NEXT_PUBLIC_* variables are passed at build time and baked into the image. Changing them requires a rebuild.
Environment Server-only secrets (API keys, webhooks) are injected at runtime. Can be changed without rebuilding.
Resource Limits Default: 2 CPU, 2GB RAM limit with 0.5 CPU, 512MB reservation. Prevents runaway processes.
Health Check Docker healthcheck is disabled (healthcheck: disable: true); Coolify/Traefik use /api/health for readiness and /api/health/live for liveness.
Restart Policy unless-stopped - Auto-restart on crash, but not if manually stopped.
Network Joins the coolify external network for reverse proxy integration.
Files Location

The Dockerfile and docker-compose.yml are located at the root of the project. Review them for the complete configuration.

Production Go-Live Checklist

Before publishing a boilerplate release, run pnpm run qa:release locally on the release candidate. It checks repository guardrails and verifies the certification evidence for all four DB/Auth compositions before running the production Phase 6 profile, including Lighthouse and bounded load. An uncertified or blocked composition fails this command even when the normal development audit passes. Do not replace missing evidence by changing status labels. Docker and Coolify builds do not run this local gate automatically.

Certification is checked by the release command, not by the application during prerender or startup. The runtime accepts the implemented Database/Auth compositions and still validates credentials, TLS, connection budgets, canonical identity, SQL roles and Account/RLS permissions. It does not read Git metadata or proof files from a previous .next build. A successful Docker build therefore does not certify a release.

Run through this once before pointing real users at the deployment:

Rate limiting is required when the server runs in production. Docker sets NODE_ENV=production even when NEXT_PUBLIC_INSTANCE_MODE=development selects test payments. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN in Coolify's runtime environment. Alternatively, for exactly one long-lived Node.js process, explicitly set ALLOW_INMEMORY_RATE_LIMIT=true; these counters reset on restart and are not shared across replicas or cluster workers. Compose forwards all three variables. Recreate the app container after changing them; there is no need to run database init again.

Current payment launch boundary: Stripe, Lemon Squeezy, and Paddle implement member checkout and portal locally. Lemon Squeezy and Paddle must be validated through their sandbox/test catalogue, signed webhook, purchase, convergence, and portal smokes; no live purchase is required or allowed by this workflow. Lemon readiness additionally requires the exact server-only LEMON_SQUEEZY_CUSTOMER_PORTAL_ACK after the paid-customer portal opens successfully. Production configuration remains fail-closed until the selected provider has complete environment-owned credentials, catalogue bindings, and required operator attestations. Paddle guest checkout remains intentionally unavailable.

  1. For a new empty Supabase or Neon database, follow SQL Sources & Installation: verify the sources, then use init to install the selected assembled composition in memory. Optional manual exports must be checked before use and never become upgrade artifacts. Complete schema, runtime-credential and selected-integration checks before launch. A bootstrap alone does not satisfy the final composition-specific local_only certification.
  2. Set the Supabase Auth redirect URLs + Site URL to your domain (https://your-domain.com and https://your-domain.com/*/callback), or magic-link / OAuth will break.
  3. Keep the provider chosen during initialization. Set only that provider's production credentials and production catalogue bindings, configure its exact query-free /api/billing/webhooks/* route and required events, and never switch an existing installation in place. When Stripe is selected, pin API version 2026-07-29.dahlia. Lemon Squeezy/Paddle acceptance evidence remains sandbox-only; do not issue a live test purchase as part of this workflow.
  4. Set every server-only secret in the platform env (not .env.local): when the scheduler calls the HTTP jobs endpoint, rotate JOBS_SECRET_KEY; always set LOGS_SALT / REFERRAL_SALT / AFFILIATES_SALT and the selected provider keys.
  5. Set NEXT_PUBLIC_APP_URL to the real domain and NEXT_PUBLIC_INDEXABLE=true; set NEXT_PUBLIC_PRELAUNCH=false when you go public.
  6. Required in production: configure UPSTASH_REDIS_REST_URL/TOKEN. The in-memory rate-limiter is per-instance, so without a shared store a caller load-balanced across replicas is not merely limited more loosely — they are not limited at all, and that covers auth brute-force, the per-email OTP and magic-link budgets, the AI quota, and the contact form. The app refuses to boot in production without them. On a deliberately single-instance deployment, where per-instance counting is correct, set ALLOW_INMEMORY_RATE_LIMIT=true to state that intent explicitly — do not set it merely to make a deploy pass. Keep TRUST_CLOUDFLARE_IP=false unless direct origin access is blocked or your trusted proxy strips spoofed client-IP headers.
  7. Register the cron jobs (rows in the jobs table) and verify the selected execution path. supabase_pg_cron and external_runner need a reachable public Jobs API URL; postgres_pg_cron needs the supervised pnpm jobs:worker process and no HTTP URL.
  8. Edit the legal CMS pages (terms, privacy, legal) before launch.
  9. Smoke test: /api/health → a real test checkout → magic-link login → an AI message → confirm credits decremented. Enable LOGS_ENABLED and watch /admin-dashboard/logs.
  10. Enable Supabase automated backups / PITR and confirm a rollback path before your first real customer.
Vercel instead of Docker/Coolify

The app is a standard Next.js standalone build, so it deploys to Vercel without the Dockerfile — set the same env vars in the project settings. With supabase_pg_cron, point the Jobs API URL at the Vercel domain. With postgres_pg_cron, the database only enqueues durable occurrences; run pnpm jobs:worker on a separate supervised host because the Vercel request runtime is not a persistent worker.

Environment Variables

In production, set all server-only secrets at the platform level (Coolify/Vercel project env), never in a committed .env.local: SUPABASE_SECRET_KEY, PAYMENTS_PROVIDER, only the selected payment provider's API and webhook secrets, JOBS_SECRET_KEY when the scheduler or an operator calls the HTTP jobs endpoint, and the hash salts (LOGS_SALT, REFERRAL_SALT, AFFILIATES_SALT). postgres_pg_cron itself does not use JOBS_SECRET_KEY. The selected provider's previous webhook secret is also server-only and should exist only while an older endpoint remains enabled during a bounded cutover. Build-time NEXT_PUBLIC_* values must be present at build, not just runtime. TRUST_CLOUDFLARE_IP defaults to false; set it to true only when the origin is protected by Cloudflare or a trusted proxy that strips spoofed headers. The full env contract lives in .env.example; the ordered Production Go-Live Checklist is above.

Build-time vs Runtime Variables

NEXT_PUBLIC_* variables are embedded at build time and require a rebuild to change. Server-only variables can be changed at runtime.

Required Variables

These variables must be set for the application to function. Copy .env.example to .env.local and fill in: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY; PAYMENTS_PROVIDER, BILLING_CONTEXT_SIGNING_SECRET, and only the selected provider's API, webhook, mode, browser-safe token when required, portal, and attestation variables; LEGAL_COMPANY_NAME, LEGAL_ADDRESS, LEGAL_REGISTRATION_NUMBER; and AI_LLM_TRANSPORT with its matching credential. The complete provider blocks and rotation variables are documented in Environment Setup.

Email Configuration

Set EMAIL_PROVIDER to brevo, mailjet, or noop. Provide the matching credentials (BREVO_API_KEY for Brevo, or MAILJET_API_KEY_PUBLIC + MAILJET_API_KEY_PRIVATE for Mailjet) plus EMAIL_FROM_NAME and EMAIL_FROM_ADDRESS (the verified sender email). The active provider handles transactional emails (magic links, invitations, deletion notifications, license expiration warnings) and newsletter subscriptions.

Optional Integrations

Optional variables enable additional features: NEXT_PUBLIC_GTM_ID for Google Tag Manager analytics, NEXT_PUBLIC_META_PIXEL_ID for Facebook/Meta pixel tracking, NEXT_PUBLIC_CRISP_WEBSITE_ID for live chat support, and VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY (server-only) for PWA push notifications. Analytics/chat scripts remain consent-gated even when configured.

UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN are no longer optional in production. They used to appear on this list with "falls back to in-memory"; that fallback is per-instance and therefore disables every rate limit behind a load balancer, so production now refuses to boot without them. See the go-live checklist above.

Health Check Endpoint

The application includes a health check endpoint at /api/health used by Docker and monitoring tools.

Field Description
status "ok" (HTTP 200) or "degraded" (HTTP 503 when the DB probe fails)

The public response exposes only status; provider names, capabilities, timing and failure details are kept server-side. Synthetic timeout/failure events remain available in the protected error logs. The Docker/Compose healthcheck is disabled by design; use /api/health/live for platform restart decisions and /api/health for routing readiness (503 when the database probe fails).

Coolify Deployment Steps

Follow these steps to deploy on Coolify:

  1. Create Application - In Coolify, create a new application from your Git repository. Select "Docker Compose" as the build pack. This is required for the init-generated Neon/PostgreSQL worker profile to be supervised automatically.
  2. Configure Build Args - Add all NEXT_PUBLIC_* variables in the Build Args section. These are embedded at build time.
  3. Configure Environment - Add server-only secrets (API keys, webhook secrets) in the Environment section. These can be changed without rebuilding.
  4. Set Domain - Add your domain in Coolify. SSL certificates are automatically provisioned via Let's Encrypt.
  5. Deploy - Click "Deploy" and wait for the build to complete (typically 3-5 minutes for first build, faster for subsequent builds due to caching).
  6. Verify - Check https://your-domain.com/api/health returns a healthy status.

Resource Recommendations

Tier CPU Memory Use Case
Development 1 CPU 1 GB Local testing, staging
Production (Small) 2 CPU 2 GB Low traffic, startup
Production (Medium) 4 CPU 4 GB Medium traffic, growing
Production (Large) 8 CPU 8 GB High traffic, enterprise

Note: Build requires more resources than runtime. Allow 4 GB memory during build process.

The TypeScript pass is the peak-memory phase of next build, and Node sizes its default heap from the host’s total RAM — it does not read the container’s cgroup limit. On a constrained or busy builder V8 therefore keeps growing until the kernel kills it, and because that is a SIGKILL there is no exit code and no error text: the log stops immediately after Running TypeScript ... with no type error printed. That is not a compile failure, and debugging it as one wastes a lot of time.

The builder stage caps the heap with NODE_BUILD_MEMORY_MB (default 4096) so V8 collects instead of being killed — and if the budget really is too small you get a legible JavaScript heap out of memory stack instead of silence. Size it below the memory the build container may use, which is not the host total if you have set a per-resource memory limit in Coolify. Override it as a build argument, or set it as a build-time environment variable:

NODE_BUILD_MEMORY_MB=2048

Stripe webhook setup (when Stripe is selected)

Stripe webhooks notify your application when payment events occur (subscriptions, payments, cancellations).

  1. Create Webhook - In the Stripe Dashboard, go to Developers → Webhooks → Add endpoint
  2. Set Endpoint URL - Use https://your-domain.com/api/billing/webhooks/stripe
  3. Set API Version - Explicitly select 2026-07-29.dahlia; do not leave the endpoint on the account default
  4. Select Events - Enable these events:
    • Checkout Events:
    • checkout.session.completed - Purchases (subscriptions, credit packs, licenses)
    • checkout.session.async_payment_succeeded - Async payment completed (SEPA, bank transfer)
    • checkout.session.async_payment_failed - Async payment failed
    • checkout.session.expired - Abandoned checkout tracking
    • Subscription Events:
    • customer.subscription.created - New subscriptions
    • customer.subscription.updated - Plan changes, status updates
    • customer.subscription.deleted - Cancellations
    • customer.subscription.paused - Subscription paused
    • customer.subscription.resumed - Subscription resumed
    • customer.subscription.trial_will_end - Trial ending notification (3 days before)
    • Invoice Events:
    • invoice.paid - Successful payments / monthly credit refills
    • invoice.payment_failed - Failed payments (grace period)
    • invoice.payment_action_required - SCA/3D Secure authentication required
    • Charge & Dispute Events:
    • charge.refunded - Refunds (deducts credits, revokes licenses)
    • charge.dispute.created - Chargeback created (requires immediate action)
    • charge.dispute.closed - Dispute resolved (won/lost)
    • Customer Events:
    • customer.created - Link Stripe customer to account
    • customer.updated - Sync customer data changes
    • customer.deleted - Clean up Stripe references
    • Payment Method Events:
    • payment_method.attached - New payment method added
    • payment_method.detached - Payment method removed (churn signal)
  5. Copy Signing Secret - After creation, copy the webhook signing secret (starts with whsec_)
  6. Add to Environment - Set STRIPE_WEBHOOK_SECRET in your environment variables
Upgrading an existing endpoint

Create a second Dahlia endpoint with the same exact query-free /api/billing/webhooks/stripe URL and keep the previous endpoint active during validation. Set the new secret as STRIPE_WEBHOOK_SECRET and the old secret as STRIPE_WEBHOOK_SECRET_PREVIOUS, deploy, then run pnpm run test:staging. The readiness probe rejects query strings and fragments. After successful Dahlia deliveries, disable the old endpoint, remove STRIPE_WEBHOOK_SECRET_PREVIOUS, and restart the application.

Local Development

Use the Stripe CLI to forward webhooks locally: stripe listen --forward-to localhost:3777/api/billing/webhooks/stripe

Database composition setup

Select the PostgreSQL provider and Auth provider independently. Supabase and Neon use the same assembled business schema and shared Prisma runtime; Auth, Storage and scheduler integrations are installed only when selected.

  1. Create an empty database - Provision a new Supabase or Neon PostgreSQL database and obtain its direct administrator URL.
  2. Select the composition - Set the database, Auth, scheduler and Storage choices through the initialization wizard. Do not infer one provider from another.
  3. Initialize once - Run pnpm run init. It assembles the selected sources in memory, installs the complete fresh composition, attests isolated roles and emits operational credentials. Source fragments and optional exports are not standalone scripts or an upgrade procedure.
  4. Configure native integrations - Enable only the capabilities selected for this composition. supabase_pg_cron uses:
    • pg_cron - native scheduled job execution
    • pg_net - bounded native HTTP dispatch where required
    postgres_pg_cron, the Neon default, installs only pg_cron. Its fixed command enqueues a pending occurrence without a URL or secret; it does not require Supabase Vault or pg_net, and pnpm jobs:worker must run continuously to execute TypeScript handlers. A self-hosted target must provide the extension binary, shared_preload_libraries=pg_cron, cron.database_name set to the application database, and a restart before init. Managed Neon owns those server settings; its catalogue must expose pg_cron, or init fails closed. Compositions using external_runner install no fake native scheduler schema and remain the fallback when native prerequisites are unavailable.
  5. Configure Authentication - In the selected Auth provider:
    • Enable Email provider for magic links
    • Add redirect URLs for your domain
    • Optionally configure OAuth providers (Google, GitHub)
  6. Configure Storage when selected - Let pnpm run init create or attest configured Supabase Storage buckets. For Neon Object Storage or AWS S3, provision a private documents bucket and public media bucket outside the application, apply least-privilege server credentials, configure the public media base URL or CDN, then provide the values collected by init. Runtime startup never creates buckets or changes public-access policy. A disabled Storage provider requires no Storage secret or bucket.
  7. Copy Supabase Auth/Storage keys only when selected - From the Supabase project settings:
    • Project URL → NEXT_PUBLIC_SUPABASE_URL
    • Publishable key → NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
    • Secret key → SUPABASE_SECRET_KEY

Financial worker credential

Fresh provisioning emits DATABASE_FINANCIAL_WORKER_URL alongside the application, maintenance, and platform-admin credentials. It must target the same database and TLS mode with the exact app_financial_worker login and a distinct password. Keep it server-only, budget one extra pool connection per application instance, and include it in rotation and revocation. The role has no table access, ownership, or membership in another role; it can invoke only the private credit-ledger functions.

Use the canonical fresh-install workflow to provision these capabilities. Never execute individual schema fragments against an existing deployment. Updated application code requires this credential before activation.

## Isolated job workers and operational recovery With `postgres_pg_cron`, supervise two workers from an installed source checkout: ```sh pnpm jobs:worker -- --lane critical pnpm jobs:worker -- --lane background ``` The critical lane owns billing, transactional email, AI usage settlement and MFA recovery; the background lane owns document processing, deletion and other work. Classification uses handler identifiers from `config/job-worker.mjs`, so renaming a job does not move it between lanes. Run both lanes. The default `all` is suitable for a single worker when isolation is unnecessary. Supabase HTTP scheduling and external scheduling keep their existing execution paths. `ops/systemd/[email protected]` supplies a restartable service template. Set its checkout path, service user and pinned Node/pnpm executable paths for the host, then enable the `critical` and `background` instances. Keep the credential environment file readable only by the service administrator. These workers need the source checkout and installed dependencies; the web standalone image does not include their entrypoints. Include every web and worker process in `DATABASE_APPLICATION_INSTANCES`; retain the existing aggregate connection gate. Collect structured stderr alongside the web and worker process logs. Error labels and correlation IDs remain available during a database outage; raw errors, conversation content and arbitrary metadata are excluded. HTTP responses expose a server-generated `X-Request-Id`, and queued jobs retain that correlation context. `job_claimed` reports queue age and attempts; `ai_usage_settlement` reports settled, retried and exhausted counts. Alert on exhausted settlement and sustained queue age. The seeded **Settle AI Usage** job recovers committed receipts every minute. Usage and messages commit before stream success, and credit changes use one stable operation key. If PostgreSQL fails before that receipt commits, the stream reports failure; usage is not guessed. Insufficient balance can exhaust retries. After resolving the cause, an authorized operator can run the job with a payload containing `operation_id` and `retry_failed: true` to retry exactly that failed receipt. Completed receipts cannot be requeued, and lifetime attempt/recovery counts remain available. Usage-log retention detaches receipts; Account erasure deletes them. A database administrator can locate failed receipts with this bounded read; the application and financial-worker logins intentionally cannot read the private table: ```sql select operation_id, account_id, tokens, last_error, lifetime_attempts, recovery_count from private.ai_usage_settlements where status = 'failed' order by created_at, operation_id limit 50; ``` Retry only after correcting the reported cause. Permission and integrity errors become terminal immediately; balance shortages and transient lock failures use bounded backoff. Correlation metadata is excluded from queued-command idempotency comparisons, and the original queued correlation is retained on replay. Schema changes follow the repository's fresh-install contract. This change does not apply SQL to an existing deployment. Verify disposable installations with `node scripts/qa/prove-ai-usage-settlement.mjs` before publishing a new baseline.

With PUBLIC_CONTENT_OFFLINE_BUILD=true during a production build, localized public routes are generated on their first runtime request against the real CMS. The flag has no runtime fallback effect. Publishing CMS blocks/pages invalidates the associated route paths and tags, including pricing blocks.