Skip to main content
Do these once your install works locally or on a server and you are ready for real people and real sending. Each item links to the detail below.
What you need

Checklist

  1. Domain, HTTPS and the public URL
  2. Sign-up policy and the operator
  3. Transactional email (Resend)
  4. Scheduled jobs
  5. Webhooks
  6. Database connections
  7. Backups
  8. 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.
Invitations work in both modes. Invite teammates from Settings, Members; an invitation is sent by email and expires after 7 days. The “platform operator” is an owner or admin of the organization flagged 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. Set RESEND_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 the cron sidecar does this. On Dokploy use Schedule Jobs. Otherwise use the host’s cron with curl (examples).

In-process (nothing to configure)

Started from src/instrumentation.ts when the server boots:

External (you must schedule these)

Every request is POST. 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_SECRET endpoints take the secret as ?secret=... or the x-tick-secret header. The endpoint answers 401 ({"error":"unauthorized"}) when the variable is unset or does not match.
  • CRON_SECRET endpoints take Authorization: Bearer $CRON_SECRET. With the secret the call serves every organization; a signed-in member calling the same route only runs their own organization.
If you do not use LinkedIn, the 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 in extensions/whatsapp-recorder/. The extension only talks to AgentSDR addresses it knows. To use it with your own domain:
  1. Build it with bun run build:recorder and load extensions/whatsapp-recorder/dist as an unpacked extension in Chrome (chrome://extensions, Developer mode, Load unpacked).
  2. Open the extension’s Options (chrome://extensions, Details, Extension options), add your AgentSDR address (for example https://sdr.yourcompany.com), and allow access when Chrome asks.
  3. Reload any AgentSDR tab that was already open.
Each person who places calls does this once in their own Chrome; nothing is rebuilt per domain. Recordings are stored in your Cloudflare R2 bucket (Cloudflare R2); see WhatsApp calling.

Database connections

The app uses one connection pool of at most DB_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 of INTEGRATION_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:
Call recordings live in R2, not in the database; back that bucket up separately. With the bundled Compose database the data is in the db-data volume: docker compose down -v deletes it.

Upgrading

Upgrading an existing install is not done with db:setup. That script only initialises an empty database and refuses to run otherwise.
  1. Back up the database (above).
  2. git pull (or pull the new image tag).
  3. 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.
  4. Run each new migration against your database, for example bun scripts/<name>.ts (older ones are plain JavaScript, run with node scripts/<name>.js). They load .env.local and connect with DATABASE_URL, are guarded with IF NOT EXISTS / IF EXISTS, and are safe to re-run. The Docker image contains only scripts/db/setup.ts, so run migrations from a checkout of the repository that can reach your database.
  5. Rebuild and restart: bun run build && bun run start, or docker compose up -d --build (or docker compose pull && docker compose up -d if you use a prebuilt image).
  6. Optionally verify: bun run db:check audits the database against the tenancy registry.
Never change 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 of 1, 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.
PEOPLE_MIGRATION_MODE and PAUSE_CAMPAIGN_MUTATIONS also return 503 for POST calls to /api/linkedin/jobs/* and /api/outreach/mailboxes/*, which includes your scheduled jobs. Leave them off in normal operation.

Next

Integrations

Connect the services each channel needs.

Troubleshooting

Exact messages and fixes.

Configuration reference

Every environment variable.

Responsible use

Sending limits and compliance.