Requirements
- PostgreSQL 16 or newer.
db:setuprefuses older servers. The schema is tested on 16, 17 and 18. - Either Docker (with Compose), or Node.js 20.9+ / Bun 1.2+ to run from source. The Docker image and the repository’s tooling use Bun.
- A public HTTPS origin if you want webhooks to reach you (Unipile, Gmail push) and email links to work for other people.
- A Resend account for sign-up, password-reset and invitation email (required in production, see Email).
Option A: Docker Compose
The repository’sdocker-compose.yml defines four services:
Steps:
- Copy the example environment file and fill it in. The values you must set
are listed in configuration.md:
BETTER_AUTH_SECRET,INTEGRATION_CREDENTIALS_KEY,UNSUBSCRIBE_SECRET,CRON_SECRET,OUTREACH_TICK_SECRET,BETTER_AUTH_URL,POSTGRES_PASSWORD, and the Resend settings (Resend can wait until you invite people). docker compose up -d- Open the public URL and sign up.
- The bundled
dbservice does not use TLS. The app connects with TLS by default, so a database without it needsDATABASE_SSL=disable. If you pointDATABASE_URLat a managed database, leaveDATABASE_SSLunset. - Set
POSTGRES_PASSWORD(required by Compose, and used to build the app’sDATABASE_URL) and, if port 3000 is taken on the host,PORT(the published host port).BETTER_AUTH_URLis read at runtime, so one image works for any domain. - On a server with no Resend account yet, you can still create and verify the
first account: the verification email is written to the app log, so run
docker compose logs appand open the link. Set up Resend before inviting anyone (see Email). - The app is designed to run as a single instance. The outreach sender loop
has no distributed lock; two instances would each send. Do not scale
appto more than one replica. (The enrichment and CRM workers are safe to overlap; the outreach scheduler is not.)
Using your own PostgreSQL
The bundleddb service is the default. To use a PostgreSQL 16+ you already
run (a hosted one, for example) instead, add to .env:
docker compose up -d as usual. The db service is not started, and
the setup service creates the schema in your database on first start, so the
database must be empty then. POSTGRES_PASSWORD is unused in this mode; leave
the example value. This needs Docker Compose 2.24 or newer.
Option B: from source
bun run db:setup reads DATABASE_URL (and DATABASE_SSL) from the
environment. Bun loads .env.local automatically for it. It applies
db/schema.sql and db/seed.sql in one transaction and refuses to run if the
database already contains tables. See database.md.
next start reads .env.local as well. Put the process behind a reverse proxy
(Caddy, nginx, a load balancer) that terminates TLS and forwards to port 3000.
Run it under a supervisor (systemd, Docker, PM2) so it restarts on failure.
First sign-up and the organization
- Open
/sign-upand create an account. Email verification is required before password sign-in works, so the verification email must be deliverable (see Email). With noRESEND_API_KEYthe email is written to the server log instead (with a one-time warning in production), so you can copy the link from there. - A signed-in user with no organization is sent to
/onboarding, which asks you to create one (a name and a URL slug). You become its owner. - Everything in AgentSDR belongs to an organization. Invite teammates from Settings, Members; an invitation is sent by email and expires after 7 days.
- Connect your services in Settings, Integrations (integrations.md).
Who may sign up
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.
Public URL
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. 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.
RESEND_API_KEY and AUTH_EMAIL_FROM (an address on a domain you have
verified in 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,
but you need Resend before inviting anyone. This is separate from the email your campaigns send,
which goes out through the Gmail accounts you connect.
Optional: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to add “Continue
with Google” (integrations.md).
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. Otherwise use
your platform’s scheduler or the host’s cron with curl.
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, not values from the
code: the code does not state a frequency for them, only that each run
respects working hours, limits and an overlap guard.
Authentication:
OUTREACH_TICK_SECRETendpoints take the secret as?secret=...or thex-tick-secretheader. The endpoint answers 401 when the variable is unset.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 that must be reachable
These endpoints are public (no session cookie) and need your instance to be reachable from the internet. See integrations.md for how to register each one.
Do not expose
/api/webhooks/[id] publicly; it is behind sign-in on purpose.
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.
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 (Backups).
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.git log --diff-filter=A --name-only --format= <old>..<new> -- scripts/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. - Rebuild and restart (
bun run build && bun run start, ordocker compose pull && docker compose up -d). - Optionally verify:
bun run db:checkaudits the database against the tenancy registry.
INTEGRATION_CREDENTIALS_KEY or BETTER_AUTH_SECRET during an
upgrade (see configuration.md).
Backups
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 pg_restore --no-owner --dbname "$DATABASE_URL" agentsdr.dump. Call
recordings live in R2, not in the database; back that bucket up separately.
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).