What you need
- A running install (Docker Compose, from source or Dokploy).
- A domain you control and a Resend account.
- About 30 minutes, plus DNS propagation.
Checklist
- Domain, HTTPS and the public URL
- Sign-up policy and the operator
- Transactional email (Resend)
- Scheduled jobs
- Webhooks
- Error tracking and dashboards (optional, recommended)
- Database connections
- Backups
- Know how to upgrade and the PAUSE switches
Domain, HTTPS and the public URL
Put the app behind a reverse proxy or platform router (Caddy, nginx, a load balancer, Traefik on Dokploy) that terminates TLS and forwards to port 3000. Point a DNS record at it. Your own domain walks through it for a subdomain such asapp.example.com, including a ready Caddy setup for Docker Compose.
BETTER_AUTH_URL is the one required public-origin setting (for example https://sdr.example.com, no trailing slash). It is read at runtime and used for sign-in and Better Auth’s trusted origin, email links (including unsubscribe links in sent campaigns), and Unipile hosted-auth redirect and notify URLs. One image therefore works for any domain: change the variable and restart.
NEXT_PUBLIC_APP_URL is a legacy fallback, used only if BETTER_AUTH_URL is unset; it is inlined at build time, so prefer BETTER_AUTH_URL.
A localhost value works for development but is not reachable by Unipile or by the recipients of your email. Unipile webhooks are not registered while the origin is a local address (see Webhooks).
You need a public HTTPS origin if you want webhooks to reach you (Unipile, Gmail push) and email links to work for other people.
Who may sign up
The first account on a fresh install can always be created. After that,AUTH_SIGNUP controls it:
invite-only(default): the instance’s very first account can always be created. After that an account is created only for an email address with a pending, unexpired invitation; any other sign-up gets Better Auth’s normal “check your email” response and no account is created (the sign-up page shows an invite-only notice, and the sign-in page no longer offers “Create one”). Only owners and admins of the first organization (or the first user, before any organization exists) can create organizations. This covers Google sign-in too.open: anyone who can reach the instance can create an account and an organization. Suited to a public, multi-customer deployment; set it explicitly.
initial (installs migrated from the single-workspace version), otherwise of the oldest organization. On a fresh install that is the first organization you create. Only the operator sees system-wide job logs (Settings, LinkedIn jobs); everything else is per organization.
Transactional email (Resend)
Invitations, sign-up verification and password reset are sent through Resend. SetRESEND_API_KEY and AUTH_EMAIL_FROM (an address on a domain you have verified in Resend), for example ReachCrew <auth@yourdomain.com>. Setup steps: Resend.
Without RESEND_API_KEY, verification, reset and invitation emails are written to the server log (with a one-time warning in production) instead of sent. The first admin account, created on the setup page, needs none of them; you need Resend before inviting anyone, since invited people must confirm their email before password sign-in works.
Set SUPPORT_EMAIL too, and customers can write to you from Help → Contact support: each request arrives as an email you answer by replying (configuration).
This is separate from the email your campaigns send, which goes out through the Gmail accounts you connect (Google Workspace).
Optional: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to add “Continue with Google” (Google sign-in). The OAuth redirect URI is <BETTER_AUTH_URL>/api/auth/callback/google.
Scheduled jobs
The scheduled jobs run inside the app, so there is nothing to set up: no cron container, no Dokploy Schedule Jobs, no secrets. They run only in production (bun run start, or the Docker image), never in a demo deployment, and INTERNAL_SCHEDULER=false turns them off if you would rather call the endpoints from an external cron (examples).
The jobs
The LinkedIn search queue is not scheduled: it runs when someone clicks Run.
The daily rollover follows each organization’s clock. Set it in Settings, Organization, Defaults: Time zone (an IANA name such as
Europe/Berlin, default UTC) and New day starts at (HH:MM, default 00:00). Daylight saving is handled. The first-run setup page stores the browser’s time zone for the first organization. Changing the zone or time never repeats the same day’s rollover, except that moving the start of the day later can give a second rollover within 24 hours, because it is a new day under the new setting.
Each run is claimed in the database (table scheduled_job_runs), so a scheduled slot runs once however many times the app restarts, even while two instances overlap during a deploy. After downtime only the latest missed slot runs. After upgrading, a job with no recorded run waits for its next slot, so the daily work an old cron already did today is not repeated. The app logs one line per run, for example [scheduler] linkedin-run-outreach @ 2026-10-08T09:30:00.000Z ok in 1.2s, or FAILED with the error.
Also in the app
Started fromsrc/instrumentation.ts when the server boots:
Running a job by hand, or from outside
The same work is available as endpoints, for a manual run or an external cron (needed only withINTERNAL_SCHEDULER=false). Every request is POST; replace $APP with your public origin. CRON_SECRET and OUTREACH_TICK_SECRET are optional: set one only to call its endpoints from outside. Unset, the endpoints refuse every secret call. In Docker Compose, add them to the environment block of docker-compose.yml.
Two scheduled jobs have no endpoint:
linkedin-v2-tick (the v2 LinkedIn engine, every minute) and lead-import-sweep (resumes interrupted list imports). They run only in the in-app scheduler, so keep INTERNAL_SCHEDULER on for an organization that uses the v2 engine; with it off, nothing sends for that organization and nothing says so except a stale lastV2TickAt on the health check below.
With the in-app scheduler on, these calls do not repeat its work:
run-outreachandreplay-webhooksshare the scheduler’s slot. If it already ran, they answer{ "ok": true, "skipped": "already ran for slot ..." }.build-queue,mailboxes/watchandreset-daily-limitsskip organizations whose daily rollover already ran today (the JSON lists them underalreadyRolledOver) and do the rest.- Add
?force=1to any of them to skip this check.
OUTREACH_TICK_SECRETendpoints take the secret as?secret=...or thex-tick-secretheader. The endpoint answers 401 ({"error":"unauthorized"}) when the variable is unset or does not match.CRON_SECRETendpoints takeAuthorization: Bearer $CRON_SECRET. With the secret the call serves every organization; a signed-in member calling the same route only runs their own organization.
Health check
GET /api/health is public and answers { "ok": true, "db": true } (200) while the database responds, or { "ok": false, "db": false } (503) when it does not: point your load balancer or uptime check at it. With Authorization: Bearer $CRON_SECRET it adds the counters to alert on, as numbers only (no organization, lead or message is named):
Webhooks
These endpoints are public (no session cookie) and need your instance to be reachable from the internet.
Do not expose
/api/webhooks/[id] publicly; it is behind sign-in on purpose.
Unipile registers itself. Saving the Unipile integration registers the two webhooks (connection-accepted and unipile-message) in your Unipile workspace, each URL carrying org=<organization id> and each delivery the generated secret as x-unipile-secret. Registration is skipped when the origin is localhost, so save the integration again after the app runs at its public address. See Unipile. A single /api/webhooks/unipile-message receives every Unipile messaging event and routes by account type; the two paths above stay live for hand-made registrations and the LinkedIn replay job.
Gmail push is optional and set up in Google Cloud: a Pub/Sub subscription pushing to /api/outreach/webhooks/gmail-watch. Follow Gmail reply sync, the daily rollover renews the watch.
WhatsApp call recorder extension
Calls placed from ReachCrew are recorded by the Chrome extension inextensions/whatsapp-recorder/. The extension only talks to ReachCrew addresses it knows. To use it with your own domain:
- Build it with
bun run build:recorderand loadextensions/whatsapp-recorder/distas an unpacked extension in Chrome (chrome://extensions, Developer mode, Load unpacked). - Open the extension’s Options (
chrome://extensions, Details, Extension options), add your ReachCrew address (for examplehttps://sdr.yourcompany.com), and allow access when Chrome asks. - Reload any ReachCrew tab that was already open.
Error tracking
Without it, a failing worker shows up only indocker compose logs app. Set SENTRY_DSN to a Sentry project’s DSN (a self-hosted Sentry or GlitchTip works too) and restart: errors from pages and routes, every server console.error (the email, WhatsApp, scheduler, CRM and Tables workers report failures that way) and browser errors reach that project. Email addresses and phone numbers are masked before anything is sent, and cookies, headers and IP addresses are never attached. Then set an alert rule in Sentry for new issues. Details and the other SENTRY_* settings: configuration.
Pair it with an uptime check on /api/health: Sentry sees errors, not a scheduler that silently stopped. For searchable logs and dashboards of sends, replies and job health, add Grafana: Logs and dashboards.
Database connections
The app uses one connection pool of at mostDB_POOL_MAX connections (default 8). PostgreSQL’s max_connections is shared by everything that points at the server. When it is exhausted, every query fails at once with remaining connection slots are reserved for roles with the SUPERUSER attribute: that means capacity, not a bug. Before raising DB_POOL_MAX, check select count(*) from pg_stat_activity, and allow for a rolling deploy briefly running two containers (two pools).
Backups and restore
Back up the PostgreSQL database; it holds everything, including the encrypted integration credentials. Keep a copy ofINTEGRATION_CREDENTIALS_KEY separately: without it the stored credentials cannot be decrypted and every integration has to be reconnected.
pg_dump must be at least as new as the server. Restore into an empty database with:
db-data volume: docker compose down -v deletes it.
Upgrading
Upgrading an existing install is not done withdb:setup, and the app’s start-up setup does not do it either: both only initialise an empty database. On an existing one the app prints Database already initialised — nothing to do. and changes nothing.
- Back up the database (above).
-
git pull(or pull the new image tag). -
Find the migrations added since your last version: the schema history is the set of scripts in
scripts/, applied in the order they were committed. This lists the scripts added between two versions:The release notes in CHANGELOG.md name any migration you must run. -
Run each new migration against your database, for example
bun scripts/<name>.ts(older ones are plain JavaScript, run withnode scripts/<name>.js). They load.env.localand connect withDATABASE_URL, are guarded withIF NOT EXISTS/IF EXISTS, and are safe to re-run. The Docker image contains onlyscripts/db/setup.tsandscripts/create-scheduled-job-runs.ts, so run other migrations from a checkout of the repository that can reach your database. For the scheduler’s migration on Docker Compose:The entrypoint suppliesDATABASE_URLfor the bundled database. -
Rebuild and restart:
bun run build && bun run start, ordocker compose up -d --build(ordocker compose pull && docker compose up -dif you use a prebuilt image). -
Optionally verify:
bun run db:checkaudits the database against the tenancy registry.
INTEGRATION_CREDENTIALS_KEY or BETTER_AUTH_SECRET during an upgrade (see the configuration reference). To rotate the integration key deliberately, use bun run rotate:integration-credentials (new key in INTEGRATION_CREDENTIALS_KEY, old one in LEGACY_INTEGRATION_CREDENTIALS_KEYS).
PAUSE switches
Operational kill switches, off by default. A value of1, true, yes or on (case-insensitive) turns one on. They are re-read on every call. Setting PAUSE_EMAIL_OUTBOUND=true and restarting stops sending without touching data, which makes them an emergency brake.
Next
Integrations
Connect the services each channel needs.
Troubleshooting
Exact messages and fixes.
Configuration reference
Every environment variable.
Responsible use
Sending limits and compliance.