How configuration works
ReachCrew keeps almost nothing in environment variables. The environment holds only four kinds of thing:- What the app needs before it can read the database: the database connection, the sign-in secret and the encryption key.
- Shared platform services: the email service that sends sign-in mail (Resend) and the optional Google sign-in button.
- Public URLs: where people reach the app. (Optionally, passwords for calling the job endpoints from outside.)
- Tuning knobs and pause switches: optional, with working defaults.
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:
Minimum production configuration
Copy this block into.env and fill it in. A line starting with # is a
comment.
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 withbun 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:
- Back up the database.
- Put the new key in
INTEGRATION_CREDENTIALS_KEY(at least 32 characters) and the old key inLEGACY_INTEGRATION_CREDENTIALS_KEYS(comma-separated if there is more than one), in the shell where you run the script. - Dry run. It decrypts and re-encrypts in memory, rolls back, and prints a
summary with key fingerprints (never the secrets):
- Check
unresolvedis0in the summary. A row isunresolvedwhen none of the keys you gave can decrypt it. - Apply:
- Set the new key as
INTEGRATION_CREDENTIALS_KEYon every running instance and restart them. Rows already on the new key are reportedalready_current, so re-running is safe. The script connects over TLS, so pointDATABASE_URLat a database that offers it.
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 isREACHCREW_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 whenNODE_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
OnSIGTERM (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 of1, 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. WithSENTRY_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 insrc/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 exampleGRID_HTTP_SECRET_ACME. Anything else is refused when the column is saved and again when it runs, so a member cannot sendDATABASE_URLorINTEGRATION_CREDENTIALS_KEYto 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 withBearer, it is sent as is. - If the variable is not set, the cell fails with
Environment variable <NAME> is not set on this server.
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 inscripts/, 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.