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
nextjsuser for security (not root) - Health Endpoint -
/api/healthis 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-slimimage 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. |
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.
- 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_onlycertification. - Set the Supabase Auth redirect URLs + Site URL to your domain (
https://your-domain.comandhttps://your-domain.com/*/callback), or magic-link / OAuth will break. - 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 version2026-07-29.dahlia. Lemon Squeezy/Paddle acceptance evidence remains sandbox-only; do not issue a live test purchase as part of this workflow. - Set every server-only secret in the platform env (not
.env.local): when the scheduler calls the HTTP jobs endpoint, rotateJOBS_SECRET_KEY; always setLOGS_SALT/REFERRAL_SALT/AFFILIATES_SALTand the selected provider keys. - Set
NEXT_PUBLIC_APP_URLto the real domain andNEXT_PUBLIC_INDEXABLE=true; setNEXT_PUBLIC_PRELAUNCH=falsewhen you go public. - 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, setALLOW_INMEMORY_RATE_LIMIT=trueto state that intent explicitly — do not set it merely to make a deploy pass. KeepTRUST_CLOUDFLARE_IP=falseunless direct origin access is blocked or your trusted proxy strips spoofed client-IP headers. - Register the cron jobs (rows in the
jobstable) and verify the selected execution path.supabase_pg_cronandexternal_runnerneed a reachable public Jobs API URL;postgres_pg_cronneeds the supervisedpnpm jobs:workerprocess and no HTTP URL. - Edit the legal CMS pages (
terms,privacy,legal) before launch. - Smoke test:
/api/health→ a real test checkout → magic-link login → an AI message → confirm credits decremented. EnableLOGS_ENABLEDand watch/admin-dashboard/logs. - Enable Supabase automated backups / PITR and confirm a rollback path before your first real customer.
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.
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:
- 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.
- Configure Build Args - Add all
NEXT_PUBLIC_*variables in the Build Args section. These are embedded at build time. - Configure Environment - Add server-only secrets (API keys, webhook secrets) in the Environment section. These can be changed without rebuilding.
- Set Domain - Add your domain in Coolify. SSL certificates are automatically provisioned via Let's Encrypt.
- Deploy - Click "Deploy" and wait for the build to complete (typically 3-5 minutes for first build, faster for subsequent builds due to caching).
- Verify - Check
https://your-domain.com/api/healthreturns 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).
- Create Webhook - In the Stripe Dashboard, go to Developers → Webhooks → Add endpoint
- Set Endpoint URL - Use
https://your-domain.com/api/billing/webhooks/stripe - Set API Version - Explicitly select
2026-07-29.dahlia; do not leave the endpoint on the account default - 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 failedcheckout.session.expired- Abandoned checkout tracking- Subscription Events:
customer.subscription.created- New subscriptionscustomer.subscription.updated- Plan changes, status updatescustomer.subscription.deleted- Cancellationscustomer.subscription.paused- Subscription pausedcustomer.subscription.resumed- Subscription resumedcustomer.subscription.trial_will_end- Trial ending notification (3 days before)- Invoice Events:
invoice.paid- Successful payments / monthly credit refillsinvoice.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 accountcustomer.updated- Sync customer data changescustomer.deleted- Clean up Stripe references- Payment Method Events:
payment_method.attached- New payment method addedpayment_method.detached- Payment method removed (churn signal)
- Copy Signing Secret - After creation, copy the webhook signing secret (starts with
whsec_) - Add to Environment - Set
STRIPE_WEBHOOK_SECRETin your environment variables
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.
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.
- Create an empty database - Provision a new Supabase or Neon PostgreSQL database and obtain its direct administrator URL.
- Select the composition - Set the database, Auth, scheduler and Storage choices through the initialization wizard. Do not infer one provider from another.
- 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. - Configure native integrations - Enable only the capabilities selected for this composition.
supabase_pg_cronuses:pg_cron- native scheduled job executionpg_net- bounded native HTTP dispatch where required
postgres_pg_cron, the Neon default, installs onlypg_cron. Its fixed command enqueues a pending occurrence without a URL or secret; it does not require Supabase Vault orpg_net, andpnpm jobs:workermust run continuously to execute TypeScript handlers. A self-hosted target must provide the extension binary,shared_preload_libraries=pg_cron,cron.database_nameset to the application database, and a restart before init. Managed Neon owns those server settings; its catalogue must exposepg_cron, or init fails closed. Compositions usingexternal_runnerinstall no fake native scheduler schema and remain the fallback when native prerequisites are unavailable. - 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)
- Configure Storage when selected - Let
pnpm run initcreate 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. - 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
- Project URL →
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.
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.