Self-hosting / Deploying

Self-hosting

Deploying

Run Cauliflower with Docker, on a VM or on Vercel.

Cauliflower is a single Next.js application on Node.js with PostgreSQL as its only dependency. Everything — workspaces, meetings, the job queue, webhook deliveries, sessions — lives in Postgres, so a deployment is one app process (or several identical ones) and a database.

This page covers Docker, Vercel and a plain VM. Whichever you choose, you need:

  • PostgreSQL 14 or newer.
  • A public HTTPS URL for booking pages, OAuth redirects and email links.
  • Node.js 20.9 or newer if you build outside Docker. The Docker image uses Node.js 22.
  • Optionally a Resend API key (or an SMTP server) for email and a Google OAuth client for Google Calendar.

Docker Compose

The repository ships a docker-compose.yml that runs Postgres 16 and Cauliflower together. It is the fastest way to a production-style install on a single server.

Configure

Terminal
git clone <your-cauliflower-repo-url> cauliflower && cd cauliflower
cp .env.example .env

Edit .env and set at least:

.env
APP_URL=https://cal.example.com
BETTER_AUTH_SECRET=   # openssl rand -base64 32
ENCRYPTION_KEY=       # openssl rand -hex 32
POSTGRES_PASSWORD=    # password for the bundled database

You don't need DATABASE_URL: Compose builds it from POSTGRES_PASSWORD and points it at the bundled database. Add email (Resend or SMTP) and Google settings now or later — see Configuration.

Start

Terminal
docker compose up -d --build

The app is published on port 3000. To use another host port, set PORT in .env, for example PORT=8080. It only changes the host side of the ports mapping: inside the container the server always listens on port 3000, so the health check keeps working. Migrations run automatically on first boot.

Create the first account

Open your APP_URL and sign up. The first account on an installation becomes a platform admin and creates the first workspace during onboarding. After that, sign-up is invite-only unless you change SIGNUP_MODE.

Database files live in the pgdata volume, so docker compose down and rebuilds keep your data. docker compose down -v deletes it.

Using your own database

To run only the app container against a managed Postgres, build the image and pass your environment:

Terminal
docker build -t cauliflower .
docker run -d --name cauliflower -p 3000:3000 --env-file .env --restart unless-stopped cauliflower

Set DATABASE_URL in .env. TLS is enabled automatically for Neon, Supabase and URLs containing sslmode=require; set DATABASE_SSL=true to force it.

What the image does

The Dockerfile is a multi-stage build that produces a slim Next.js standalone server running as a non-root user. On boot it:

  • Applies database migrations (RUN_MIGRATIONS=true). Migrations take a Postgres advisory lock, so several replicas starting at once apply them exactly once while the others wait. A failed migration is logged as boot.migrations_failed and stops the boot sequence instead of letting the app run against a half-migrated database.
  • Runs background work in-process (INTERNAL_CRON=true). Every 30 seconds it sends queued emails and reminders, runs CRM sync, delivers and retries webhooks, picks up changes made in Google Calendar, marks past meetings as completed and prunes old job and delivery records. Change the interval with INTERNAL_CRON_INTERVAL_MS.
  • Exposes a health check at GET /api/health. It runs a trivial database query and answers 200 with {"status":"ok","db":"ok","latencyMs":3}, or 503 with "status":"degraded" when the database is unreachable. The image's HEALTHCHECK calls it every 30 seconds after a 40 second start period.

Both RUN_MIGRATIONS and INTERNAL_CRON default to true in the image. Set either to false if you'd rather run migrations as a separate deploy step or drive background work from an external scheduler (see External scheduler).

Reverse proxy and TLS

Run Cauliflower behind any TLS-terminating proxy: Caddy, Traefik, nginx or a cloud load balancer. Two things matter:

  • APP_URL must be the public HTTPS URL, exactly as people reach it. It is used in every link and email, in OAuth redirect URIs, as the trusted origin for sign-in, and to decide whether cookies are marked Secure.
  • Pass the client IP. Public booking and routing endpoints are rate limited per IP, read from the last X-Forwarded-For entry (the address your proxy saw), or X-Real-IP when there's no X-Forwarded-For. Behind Cloudflare's proxy, set TRUSTED_PROXY=cloudflare to use CF-Connecting-IP instead. Headers a visitor sends are never trusted on their own.

With Caddy, certificates and headers are handled for you:

Text
cal.example.com {
  reverse_proxy localhost:3000
}

With nginx:

Text
server {
  listen 443 ssl;
  server_name cal.example.com;
  # ssl_certificate ... ;

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto https;
    proxy_read_timeout 75s;
  }
}

API and MCP requests can run for up to 60 seconds, so keep proxy timeouts above that.

Multiple replicas

You can run as many identical app containers as you like behind a load balancer:

  • Migrations are serialized by the advisory lock.
  • Jobs and webhook deliveries are claimed with FOR UPDATE SKIP LOCKED, so keep INTERNAL_CRON=true on every replica — work is spread across them and never processed twice.
  • Sessions live in the database, so no sticky sessions are needed.
  • Rate limits are counted in each process's memory, so the effective limit grows with the number of replicas.
  • Each replica opens up to DATABASE_POOL_MAX connections (10 by default). Keep replicas × pool size below your Postgres max_connections, or put PgBouncer in front.

Vercel

Create a database

Any hosted Postgres works: Neon, Supabase, Vercel Postgres, RDS. Use the pooled connection string if your provider has one — prepared statements are turned off automatically for URLs containing pgbouncer=true or pooler.

Import the repository

Import the repository in Vercel and set the environment variables: DATABASE_URL, APP_URL, BETTER_AUTH_SECRET, ENCRYPTION_KEY, CRON_SECRET (openssl rand -hex 32), plus RESEND_API_KEY and Google settings as needed.

Deploy

vercel.json sets the build command to pnpm vercel-build, which applies migrations and then builds the widget and the app. Every later deploy migrates the same way.

Run the scheduler every minute

Cauliflower needs /api/cron called every minute for reminders, webhook retries, calendar sync, drop-off follow-ups and scheduled posts. First delivery attempts for emails and webhooks happen right after the request that triggered them, so the tick only handles what's due later. INTERNAL_CRON is ignored on Vercel, and the database pool defaults to 3 connections per function instance.

Vercel's Hobby plan only runs crons once a day, so vercel.json registers a daily tick as a backstop and the per-minute tick comes from a small Cloudflare Worker in deploy/cloudflare-cron (free plan is enough):

Terminal
cd deploy/cloudflare-cron
npx wrangler login
# set APP_URL in wrangler.toml to your domain
npx wrangler secret put CRON_SECRET    # the same value as in Vercel
npx wrangler deploy

npx wrangler tail should show tick ok every minute. On a Pro plan you can instead change the schedule in vercel.json to * * * * *. Any other scheduler works too: send Authorization: Bearer <CRON_SECRET> to /api/cron every minute.

If APP_URL is not set, Cauliflower falls back to the project's production domain from Vercel's system variables. Set it explicitly once you add a custom domain.

Plain Node.js on a VM

On a VM or bare-metal server, build the standalone output and run it under a process manager:

Terminal
pnpm install --frozen-lockfile
pnpm build
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
cp -r drizzle .next/standalone/

Then run node server.js from .next/standalone with your environment. A systemd unit keeps it running:

Text
[Unit]
Description=Cauliflower
After=network.target

[Service]
WorkingDirectory=/opt/cauliflower/.next/standalone
EnvironmentFile=/etc/cauliflower.env
Environment=NODE_ENV=production PORT=3000 HOSTNAME=127.0.0.1
Environment=RUN_MIGRATIONS=true INTERNAL_CRON=true
ExecStart=/usr/bin/node server.js
Restart=always
User=cauliflower

[Install]
WantedBy=multi-user.target

Put DATABASE_URL, APP_URL, BETTER_AUTH_SECRET and the rest in /etc/cauliflower.env. Copying drizzle/ lets RUN_MIGRATIONS=true apply migrations on start; alternatively run pnpm db:migrate from the checkout before restarting.

External scheduler

If you turn INTERNAL_CRON off — or on any platform without in-process work — call the cron route every minute from a scheduler you trust:

Terminal
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://cal.example.com/api/cron

The route answers 401 with a wrong secret and 503 when CRON_SECRET isn't set. It returns a JSON summary of the work done in that tick.

Upgrading

Back up the database first, then:

  • Docker Compose: git pull && docker compose up -d --build. The new container applies pending migrations on boot.
  • Vercel: deploy the new commit; vercel-build migrates before building.
  • VM: git pull, pnpm install --frozen-lockfile, pnpm build, repeat the copy steps, then restart the service (or run pnpm db:migrate first if RUN_MIGRATIONS is off).

Migrations only move forward. To roll back an upgrade, restore the backup taken before it along with the previous version. Admin → System shows how many migrations are applied and when the latest ran.

Backups

All state is in Postgres, so a database backup is a complete backup. With the bundled database:

Terminal
docker compose exec -T db pg_dump -U cauliflower -Fc cauliflower > cauliflower-$(date +%F).dump

Restore into an empty database with pg_restore --clean --no-owner -d <database-url> cauliflower-2026-10-07.dump. Managed Postgres providers usually offer point-in-time recovery, which is even better.

Back up your secrets too. OAuth tokens, CRM credentials and Apollo keys are encrypted with ENCRYPTION_KEY (or a key derived from BETTER_AUTH_SECRET when it isn't set). Restore with the same keys: with a different ENCRYPTION_KEY (or a different BETTER_AUTH_SECRET when no encryption key is set), stored credentials can't be decrypted and every calendar and integration has to be reconnected.

Seeding demo data

To explore reports with realistic data, seed a demo workspace from a source checkout:

Terminal
DATABASE_URL=postgres://... pnpm db:seed

It creates a Demo Co workspace (slug demo) with 8 people, 2 teams, booking links, a router and about 60 days of routing and meetings. Sign in as [email protected] with password demo-password-123. If the installation has no platform admin yet, the seed makes the demo owner a platform admin, so seeding never leaves an install without one. Running it again recreates the workspace from scratch.

Not for production

The seed refuses to run when NODE_ENV=production unless you pass --force, because it creates accounts with a published password. Seed a development or staging database instead. The seeded accounts count as existing users, so if you seed before signing up yourself, sign-up is invite-only from then on: sign in as the demo owner and invite yourself from the Demo Co workspace, or set SIGNUP_MODE=open.

The production Docker image doesn't include the seed script; run it from a checkout with pnpm install done, pointing DATABASE_URL at the database you want to fill.