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
- 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.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)
Sign-up verification, password reset and invitations are sent through Resend. SetRESEND_API_KEY and AUTH_EMAIL_FROM (an address on a domain you have verified in Resend), for example AgentSDR <[email protected]>. 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: enough to verify the first account from the logs (docker compose logs app), but you need Resend before inviting anyone. Email verification is required before password sign-in works.
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
Some work runs inside the app process; the rest must be triggered by something calling an HTTP endpoint. In Compose thecron sidecar does this. On Dokploy use Schedule Jobs. Otherwise use the host’s cron with curl (examples).
In-process (nothing to configure)
Started fromsrc/instrumentation.ts when the server boots:
External (you must schedule these)
Every request isPOST. Replace $APP with your public origin.
The schedules for the four LinkedIn jobs are suggestions. Any reasonable frequency works: each run respects working hours and daily limits, and the search queue never runs twice at once. The Compose sidecar uses
docker/cron/crontab: build-queue 00:05, watch 03:30, run-outreach every 15 minutes, run-search-queue and replay-webhooks every 10 minutes, reset-daily-limits 00:00 (UTC).
Authentication:
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.
CRON_SECRET jobs are not needed. If you do not use email outreach, the OUTREACH_TICK_SECRET jobs are not needed.
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, and schedule mailboxes/watch so the watch is renewed.
WhatsApp call recorder extension
Calls placed from AgentSDR are recorded by the Chrome extension inextensions/whatsapp-recorder/. The extension only talks to AgentSDR 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 AgentSDR address (for examplehttps://sdr.yourcompany.com), and allow access when Chrome asks. - Reload any AgentSDR tab that was already open.
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. That script only initialises an empty database and refuses to run otherwise.
- 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.ts, so run migrations from a checkout of the repository that can reach your 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.