Self-hosting / Configuration

Self-hosting

Configuration

Every environment variable and what it does.

Cauliflower is configured entirely with environment variables. Three are required; everything else turns on an integration or changes a default. Copy .env.example from the repository as a starting point — it lists the common variables with comments.

Variables are read when the server starts, so restart the app (or redeploy on Vercel) after changing them. Platform admins can check the effective setup under Admin → System.

Minimal production setup

.env
DATABASE_URL=postgres://cauliflower:[email protected]:5432/cauliflower
APP_URL=https://cal.example.com
BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
ENCRYPTION_KEY=generate-with-openssl-rand-hex-32

# Background work: in-process on Docker and VMs, or CRON_SECRET for /api/cron
INTERNAL_CRON=true
RUN_MIGRATIONS=true

# Email (or set the Resend key in Admin → Integrations)
RESEND_API_KEY=re_...
EMAIL_FROM="Acme Scheduling <[email protected]>"

Generate secrets with openssl rand -base64 32 for BETTER_AUTH_SECRET and openssl rand -hex 32 for ENCRYPTION_KEY and CRON_SECRET.

Required

VariableDescription
DATABASE_URLPostgreSQL 14+ connection string. TLS is switched on automatically for URLs containing sslmode=require, neon.tech, supabase.co or vercel-storage (see DATABASE_SSL). Prepared statements are turned off for pooled URLs containing pgbouncer=true or pooler.
APP_URLPublic base URL of the deployment, without a trailing slash, e.g. https://cal.example.com. Used in every link and email, in OAuth redirect URIs, as the trusted origin for sign-in, and to decide whether cookies are Secure. If unset, Cauliflower falls back to BETTER_AUTH_URL, then to Vercel's production domain, then to http://localhost:3000.
BETTER_AUTH_SECRETLong random secret, at least 32 characters. Signs session cookies and other short-lived tokens, and is the source of the encryption key when ENCRYPTION_KEY isn't set. Changing it signs everyone out.

Security

VariableRequiredDescription
ENCRYPTION_KEYRecommended32-byte key, as 64 hex characters or base64, used to encrypt Google Calendar tokens, the Attio access token and the Apollo API key with AES-256-GCM. Defaults to a key derived from BETTER_AUTH_SECRET. To rotate it, move the old key to ENCRYPTION_KEY_PREVIOUS; the derived key is always tried too, so adding this later doesn't lock anything out.
ENCRYPTION_KEY_PREVIOUSOnly when rotatingOlder encryption keys (comma separated) that can still decrypt stored values. New values always use ENCRYPTION_KEY.
CRON_SECRETOn Vercel, or with an external schedulerProtects GET /api/cron. Callers send it as Authorization: Bearer <secret>; Vercel Cron sends the header automatically. Without it, the cron route returns 503.

Google

VariableRequiredDescription
GOOGLE_CLIENT_IDOptionalOAuth client ID of type Web application. Both Google variables are needed to enable Google Calendar sync and Sign in with Google.
GOOGLE_CLIENT_SECRETOptionalThe matching client secret.

Register both redirect URIs on the OAuth client: https://cal.example.com/api/integrations/google/callback and https://cal.example.com/api/auth/callback/google. See Google Calendar.

Email

VariableDefaultDescription
RESEND_API_KEYnoneSends email through Resend. A key saved under Admin → Integrations → Email takes precedence.
SMTP_HOSTnoneSMTP server hostname, used when no Resend key is set. Leave both empty to disable email.
SMTP_PORT587SMTP port.
SMTP_USERnoneSMTP username. Without it, Cauliflower connects without authentication.
SMTP_PASSWORDnoneSMTP password or token.
SMTP_SECUREtrue on port 465Use implicit TLS. Accepts true, 1 or yes. Leave unset on port 587 (STARTTLS).
EMAIL_FROMno-reply@ at the APP_URL hostSender address, e.g. "Acme Scheduling <[email protected]>". The default uses the name Cauliflower.

See Email for what is sent and what happens without email.

Salesforce

VariableRequiredDescription
SALESFORCE_CLIENT_IDOptionalConsumer key of a Salesforce connected app. With the secret, workspace admins can Sign in with Salesforce.
SALESFORCE_CLIENT_SECRETOptionalThe matching consumer secret.
SALESFORCE_LOGIN_URLhttps://login.salesforce.comLogin host for production orgs. Sandboxes always use https://test.salesforce.com.

Set the connected app's callback URL to https://cal.example.com/api/integrations/salesforce/callback with the api and refresh_token scopes. Without these variables, workspaces can still connect Salesforce with their own connected app. HubSpot, Attio and Slack need no environment variables — admins paste tokens in the app. See CRM.

PostHog, the platform CRM, AI providers, feature flags, onboarding and the blog are configured in the admin portal, not with environment variables.

Sign-up and access

VariableDefaultDescription
SIGNUP_MODEinvite onlyBy default the first person can sign up; after that, people need an invitation (or an allowed domain). Set to open to let anyone sign up and create their own workspace.
ALLOWED_SIGNUP_DOMAINSnoneComma-separated email domains, e.g. acme.com,acme.io. People with these addresses can sign up without an invitation and join the oldest workspace as members.
ALLOW_WORKSPACE_CREATIONoffSet to true to let signed-in people who aren't in any workspace create one. Implied by SIGNUP_MODE=open. Accepts true, 1, yes or on.
PLATFORM_ADMIN_EMAILSnoneComma-separated email addresses that are always platform admins, e.g. [email protected],[email protected]. Matching is case-insensitive. Listed accounts get the admin role stored on the account automatically, at sign-up, on their next sign-in and on their next page load while signed in. Because sessions are cached for up to five minutes, impersonation, suspensions and role changes can take up to five minutes to start working for an account that was already signed in; signing out and back in makes them work right away. A listed address keeps portal access even if its admin role is removed.

Webhooks

VariableDefaultDescription
TRUSTED_PROXYvercel on Vercel, otherwise emptyWhich header holds the visitor's IP for rate limits: vercel uses x-real-ip, cloudflare uses cf-connecting-ip (only behind Cloudflare's proxy), and empty uses the last X-Forwarded-For entry, as set by nginx, Caddy or Traefik. Never trust a header your platform doesn't overwrite.
ALLOW_PRIVATE_WEBHOOKSoffSet to true to allow webhook URLs that point to private or internal addresses such as localhost or 10.0.0.5. For local testing only — it disables SSRF protection. Accepts true, 1 or yes.
ALLOW_INSECURE_WEBHOOKSoffSet to true to allow plain http webhook URLs in production. Accepts true, 1, yes or on; false or an empty value leaves it off.

See Webhooks for the full rules.

Runtime

VariableDefaultDescription
RUN_MIGRATIONSoff (true in the Docker image)Apply pending database migrations on start. Safe with several replicas: migrations take a Postgres advisory lock. Accepts true, 1, yes or on.
MIGRATION_DATABASE_URLDATABASE_URL_UNPOOLED, then DATABASE_URLA direct (non-pooled) connection for migrations; the advisory lock needs one. Neon's Vercel integration sets DATABASE_URL_UNPOOLED for you.
MIGRATE_PREVIEWSoffVercel preview deployments skip migrations unless this is true, so an unmerged branch can't change the production schema. Turn it on once previews use their own database.
INTERNAL_CRONoff (true in the Docker image and in next dev)Run background work inside the server process: emails, reminders, CRM sync, webhook retries, Google Calendar change detection, drop-off detection, meeting auto-completion and cleanup. Ignored on Vercel, which uses /api/cron. Accepts true, 1, yes or on. In development it runs unless set to false.
INTERNAL_CRON_INTERVAL_MS30000How often the in-process scheduler ticks, in milliseconds.
DATABASE_POOL_MAX10 (3 on Vercel)Maximum Postgres connections per server instance.
DATABASE_SSLauto-detecttrue (or 1, yes, on) forces TLS for the database connection; any other non-empty value, such as false, disables it. Leave empty to auto-detect from DATABASE_URL.
LOG_LEVELinfo in production, debug otherwiseOne of debug, info, warn, error. Logs are JSON lines in production and readable text in development.
SALES_CONTACT_URLnoneAdds a "Larger teams — talk to us" card to the pricing page, linking here (https://…, mailto:… or a path such as a hosted router page).
DISABLE_MARKETINGoffSet to true to skip the public landing page: / redirects straight to the dashboard, or to sign-in for visitors who aren't signed in. The other public pages (/pricing, /use-cases and these docs at /docs) stay available. Useful for internal installs. Accepts true, 1, yes or on.
NODE_ENVproduction in the Docker image and production buildsIn production, webhook URLs must use https, sign-in endpoints are rate limited, logs are JSON and the demo seed refuses to run.
PORT3000Port the server listens on. With Docker Compose it only picks the host port (see Docker Compose).
HOSTNAME0.0.0.0 in the Docker imageInterface the standalone server binds to. Use 127.0.0.1 behind a local reverse proxy.

Set by your platform

You normally don't set these yourself.

VariableDescription
BETTER_AUTH_URLUsed as the public URL when APP_URL isn't set.
VERCELSet by Vercel. Disables INTERNAL_CRON and lowers the default pool size to 3.
VERCEL_PROJECT_PRODUCTION_URL, VERCEL_URLSet by Vercel. Used as the public URL when neither APP_URL nor BETTER_AUTH_URL is set.

Docker Compose

docker-compose.yml reads two extra variables from .env to set up the bundled database and the published port:

VariableDefaultDescription
POSTGRES_PASSWORDcauliflowerPassword of the bundled Postgres. Compose builds DATABASE_URL from it, so you don't set DATABASE_URL yourself. Change it before the first start.
PORT3000Host port the app is published on, through the "${PORT:-3000}:3000" mapping. Compose always runs the server on port 3000 inside the container, so changing PORT in .env only changes the host port and doesn't affect the container's health check.

APP_URL and BETTER_AUTH_SECRET are also required by Compose; it refuses to start without BETTER_AUTH_SECRET.

Boolean values

ALLOW_INSECURE_WEBHOOKS, ALLOW_WORKSPACE_CREATION, DISABLE_MARKETING, INTERNAL_CRON and RUN_MIGRATIONS all read their value the same way: 1, true, yes and on, in any case, turn the setting on, and anything else, including false or an empty value, turns it off. ALLOW_PRIVATE_WEBHOOKS accepts 1, true or yes. SMTP_SECURE and DATABASE_SSL fall back to automatic detection when they're empty, as described in their rows above.