Skip to main content
This page lists every environment variable the running app reads, plus the few that Docker Compose and the setup scripts read. Each one says whether you need it, what happens without it, and how to get a value.

How configuration works

ReachCrew keeps almost nothing in environment variables. The environment holds only four kinds of thing:
  1. What the app needs before it can read the database: the database connection, the sign-in secret and the encryption key.
  2. Shared platform services: the email service that sends sign-in mail (Resend) and the optional Google sign-in button.
  3. Public URLs: where people reach the app. (Optionally, passwords for calling the job endpoints from outside.)
  4. Tuning knobs and pause switches: optional, with working defaults.
Everything else is set per organization, inside the app, and stored encrypted in the database. There is no environment fallback for any of it: If you are looking for UNIPILE_API_KEY, OPENROUTER_API_KEY, R2_* or a Google service-account key: they are not read. See Removed variables.

Which file holds the values

From source, start from .env.example: cp .env.example .env.local. With Compose, edit the values in docker-compose.yml or write NAME=value lines in a .env. Restart the app after any change. The pause switches are re-read on every call, but most other values are read once when the server starts. Generate every secret on this page with:
That prints 64 random hex characters. Use a different value for each secret.

Minimum production configuration

Copy this block into .env and fill it in. A line starting with # is a comment.
Optional additions: AUTH_SIGNUP=open if anyone may sign up, and GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET for “Continue with Google”.

Database

PostgreSQL 16 or newer. Create the tables in an empty database with bun run db:setup (the Docker image does this for you on first start, see REACHCREW_SKIP_DB_SETUP).

DATABASE_URL

DATABASE_SSL

DB_POOL_MAX

POSTGRES_PASSWORD (Docker Compose only)

COMPOSE_FILE (Docker Compose with your own PostgreSQL)

Set COMPOSE_FILE=docker-compose.yml:docker-compose.external-db.yml in .env to skip the bundled database and use the DATABASE_URL you provide. This is read by Docker Compose itself, not by the app. Needs Docker Compose 2.24 or newer. With it, set DATABASE_SSL empty unless your database has no TLS. See Docker Compose.

Auth and sign-up

ReachCrew uses Better Auth: email and password with verified email, plus Google when configured.

BETTER_AUTH_SECRET

AUTH_SIGNUP

Secrets

INTEGRATION_CREDENTIALS_KEY

Treat this key as permanent. Set it once, before anyone connects an integration, and keep a copy outside the database (a password manager). The credentials are encrypted with it and the database stores only the encrypted text. If you change it or lose it, every saved credential becomes unreadable, and you must reconnect each integration by hand. Rotating on purpose. scripts/rotate-integration-credentials.ts re-encrypts the stored credentials from an old key to a new one in one transaction:
  1. Back up the database.
  2. Put the new key in INTEGRATION_CREDENTIALS_KEY (at least 32 characters) and the old key in LEGACY_INTEGRATION_CREDENTIALS_KEYS (comma-separated if there is more than one), in the shell where you run the script.
  3. Dry run. It decrypts and re-encrypts in memory, rolls back, and prints a summary with key fingerprints (never the secrets):
  4. Check unresolved is 0 in the summary. A row is unresolved when none of the keys you gave can decrypt it.
  5. Apply:
  6. Set the new key as INTEGRATION_CREDENTIALS_KEY on every running instance and restart them. Rows already on the new key are reported already_current, so re-running is safe. The script connects over TLS, so point DATABASE_URL at a database that offers it.
The old AUTH_SECRET value is also tried automatically as a legacy key.

UNSUBSCRIBE_SECRET

Do not change it after you have sent email. Links already sitting in people’s inboxes stop verifying, so those people can no longer unsubscribe by clicking. That is a compliance problem. See Responsible use.

URLs

BETTER_AUTH_URL

NEXT_PUBLIC_APP_URL

OPENROUTER_SITE_URL

CLIENT_IP_HEADER

Set it only while the server is reachable through that proxy alone; a client reaching it directly could set any header. See Your own domain.

APP_DOMAIN (Docker Compose with HTTPS)

DEFAULT_PHONE_COUNTRY

PORT (Docker Compose)

Email for sign-in (Resend)

ReachCrew sends sign-up verification, password reset and invitation emails through Resend. This is separate from your outreach mailboxes, which send through Gmail. Setup steps: Resend.

RESEND_API_KEY

AUTH_EMAIL_FROM

Customer support

SUPPORT_EMAIL

Billing (Stripe)

Off by default: a self-hosted ReachCrew has no plans, no limits and no billing pages. Only a deployment that sells ReachCrew (app.reachcrew.space) turns it on. The design is REACHCREW_BILLING_IMPLEMENTATION_PLAN.md; the code is src/lib/billing/. Before turning it on, run bun run scripts/create-billing-tables.ts, exempt existing workspaces with scripts/seed-billing-exemptions.ts, and check the Prices with scripts/check-stripe-prices.ts. Open sign-up (AUTH_SIGNUP=open) only after billing is on, or new workspaces use the product unpaid.

BILLING_MODE

STRIPE_SECRET_KEY

STRIPE_WEBHOOK_SECRET

STRIPE_PRICE_LOOKUP_PREFIX

MARKETING_SITE_URL

Google sign-in

Adds a “Continue with Google” button. Setup steps: Google sign-in. This is only about people logging in; connecting a Gmail mailbox for sending is a per-organization integration, Google Workspace.

GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET

Scheduled jobs

The scheduled jobs run inside the app when NODE_ENV=production (bun run start or the Docker image), never in a demo deployment: the daily rollover, LinkedIn outreach every 30 minutes, LinkedIn webhook replay every 10 minutes and history pruning. Nothing to schedule and no secrets to set. The daily rollover (email send queues, Gmail watch renewal, LinkedIn daily limits) runs at the start of each organization’s day, set in Settings, Organization, Defaults: Time zone (default UTC) and New day starts at (default 00:00). Details: Going to production. The same work is also available as endpoints, for a manual run or an external cron. Each is protected by one of two optional secrets. While the in-app scheduler is on, run-outreach and replay-webhooks answer { "ok": true, "skipped": "already ran for slot ..." } if it already ran that slot, and the other three skip organizations whose daily rollover already ran today. Add ?force=1 to skip this check.

INTERNAL_SCHEDULER

REACHCREW_SKIP_DB_SETUP

OUTREACH_TICK_SECRET

CRON_SECRET

Workers and tuning

Four workers start with the server, beside the scheduled jobs above: The email and WhatsApp loops have no distributed lock, so run one instance of the app. The enrichment and CRM workers claim work safely, so two instances overlapping during a deploy are fine. All of the following are optional. Values must be numbers in milliseconds unless noted. Sending limits are not environment variables. Emails a day per mailbox, the gap between emails, LinkedIn invitations a day and per run, WhatsApp warm-up, new chats a day and the gap between sends are set per organization in Workspace → Sending rules (/workspace/sending-rules), with hard ceilings and a warning past the safe edge. A LinkedIn account, WhatsApp number or mailbox can override its daily limit. The rules, their defaults and bounds are in src/lib/channels/rules.ts; see Sending rules. The WHATSAPP_NEW_CHAT_WARMUP_HOURS, WHATSAPP_NEW_CHATS_PER_DAY and WHATSAPP_MIN_SECONDS_BETWEEN_SENDS variables of earlier versions are no longer read.

Stopping and deploys

On SIGTERM (a deploy, docker compose down, an upgrade) the app starts no new work and gives what is running, such as a LinkedIn send, time to finish before it exits. Next.js would otherwise exit at once and leave interrupted sends for an admin to reconcile. Read in src/lib/lifecycle.ts.

Pause switches

Switches that stop outbound work without a deploy. They were written for the unified-People data migration, are off by default, and normally stay off. A value of 1, true, yes or on (case-insensitive) turns one on; anything else is off. They are re-read on every call, so on platforms that refresh the environment they take effect without a restart. All are read in src/lib/migration/controls.ts. They are also an emergency brake: set PAUSE_EMAIL_OUTBOUND=true and restart, and sending stops without touching any data. Remove it and restart to resume. .env.example lists them as false; PAUSE_WHATSAPP_OUTBOUND is read by the code but is not in .env.example, so add it yourself.

Error tracking (Sentry)

Optional. With SENTRY_DSN set, errors reach a Sentry project (sentry.io, or a self-hosted Sentry or GlitchTip): errors thrown while serving a page or route, every console.error on the server (which is how the email, WhatsApp, scheduler, CRM and Tables workers report failures), and errors in the browser. Unset, nothing is sent and the browser loads none of the SDK. Code: src/lib/observability/. Privacy: cookies, headers and IP addresses are never attached (sendDefaultPii is off), and email addresses and +-prefixed phone numbers are masked in every message before it leaves. Other lead data can still appear in an error message, so list Sentry as a subprocessor if you host for others.

SENTRY_DSN

SENTRY_ENVIRONMENT

SENTRY_BROWSER

SENTRY_TRACES_SAMPLE_RATE

Prospecting (domain qualification)

Used only by the Domains / qualification feature, which is slated for removal. Apollo for Tables is a separate per-organization connection, see Enrichment providers. Read in src/lib/qualification/config.ts.

User-named variables (Tables HTTP column)

The HTTP column in Tables can authenticate with a bearer token read from an environment variable whose name the user types in the column settings (authEnvVar). The secret therefore comes from the server’s environment and never from the database, so it does not end up in database dumps.
  • The name must start with GRID_HTTP_SECRET_, for example GRID_HTTP_SECRET_ACME. Anything else is refused when the column is saved and again when it runs, so a member cannot send DATABASE_URL or INTEGRATION_CREDENTIALS_KEY to a URL of their choosing. Set the variables your users reference in the same place as the rest of your environment, then restart.
  • The value is sent as Authorization: Bearer <value>. If it already starts with Bearer , it is sent as is.
  • If the variable is not set, the cell fails with Environment variable <NAME> is not set on this server.
The HTTP column also refuses to call loopback, private, link-local (including cloud metadata) and CGNAT addresses, checked on the resolved address and again on every redirect. A self-hosted install that needs internal APIs can set GRID_HTTP_ALLOW_PRIVATE=true. Read in src/lib/grid/runners/http.ts.

Public demo

DEMO_MODE

true turns the deployment into a public, read-only demo: visitors are signed in as the seeded demo user without a password, every write is refused, no background work starts, and the seeded data’s dates move forward each day. Only for a deployment with its own database seeded by bun run db:seed:demo. See Public demo. Default off.

Set by the platform

You normally do not set these.

Script-only variables

Read by tooling in scripts/, never by the running app.

Removed variables

AUTH_PASSWORD and AUTH_SECRET belonged to the old single shared password and are no longer read by the app (AUTH_SECRET survives only as a legacy key in the rotation script). OPENROUTER_API_KEY, UNIPILE_*, the GOOGLE_* service-account keys and R2_* are not read either: those services are connected in the app. See integrations.md.

Next