answerLoopsanswerLoops Docs
Self-Hosting

Environment Variables

Every environment variable answerLoops uses, with defaults and requirements.

Copy .env.example to .env and fill in the values below.

answerLoops uses Auth.js v5. The required env vars are AUTH_URL and AUTH_SECRET. If you see NEXTAUTH_URL or NEXTAUTH_SECRET in older docs or guides, those are the v4 names — they will not work here.

Core (required)

VariableDescriptionExample
DATABASE_URLPostgreSQL connection stringpostgresql://community:community@postgres:5432/community
DIRECT_DATABASE_URLNon-pooled connection string, used only for LISTEN/NOTIFY (config hot-reload, live team updates, live dashboard updates). Required if DATABASE_URL goes through a connection pooler (Neon's -pooler endpoint, PgBouncer, Supabase's pooled port) — pooled connections don't reliably deliver NOTIFY to a LISTENer, since the pooler can swap the physical backend between statements. Falls back to DATABASE_URL if unset, which is correct for a plain unpooled Postgres instance (e.g. the local docker compose Postgres) but silently breaks hot-reload on a pooled provider.Neon: the same connection string with -pooler removed from the hostname (Neon dashboard → Connection Details → toggle "Pooled connection" off)
AUTH_URLFull public URL of your deploymenthttp://localhost:3000 or https://app.example.com
AUTH_SECRETRandom 32-byte hex — signs JWT sessions and the OAuth state for channel connect flowsopenssl rand -hex 32
ENCRYPTION_KEYRandom 32-byte hex — encrypts stored API keys and bot tokens at rest. Required in production: with NODE_ENV=production and this unset, saving any credential throws rather than writing it in cleartext.openssl rand -hex 32

Networking (optional)

VariableDescriptionExample
TRUST_PROXY_HOPSHow many proxies sit between the public internet and the app. Defaults to 11

Rate limiting needs the real client IP, and it reads that from x-forwarded-for. That header is a list each proxy appends to, so its leftmost entry is whatever the caller claimed — trusting it lets anyone mint a fresh rate-limit bucket per request just by rotating a header value. answerLoops instead counts in from the right by TRUST_PROXY_HOPS, reading the entry your own infrastructure appended.

Multi-domain (optional)

Only relevant if you're running the dashboard on a separate subdomain from your marketing/root domain (e.g. app.example.com alongside example.com) while both point at the same deployment. Most self-hosted setups use a single domain and can skip this section entirely.

VariableDescriptionExample
NEXT_PUBLIC_APP_URLBase URL of the dashboard subdomain. When set, marketing-page CTAs link there instead of a relative /dashboard path. Leave unset for a single-domain deployment.https://app.example.com
AUTH_COOKIE_DOMAINShares the session cookie across every subdomain of your apex domain. Required alongside NEXT_PUBLIC_APP_URL — without it, a user signed in on the root domain appears logged out the moment they land on the app subdomain, since a session cookie is host-only by default. Leave unset for a single-domain deployment..example.com

With NEXT_PUBLIC_APP_URL set, auth.ts redirects any request for a path not listed in its WEBSITE_PATHS/PUBLIC_PATHS arrays from the root domain over to the app subdomain — that's how a dashboard link typed on the marketing domain still lands in the app. Every marketing page you add (a new landing page, comparison page, etc.) must be added to both arrays, or it 307s off the marketing domain to the app subdomain's login screen instead of rendering — unreachable to visitors and to search crawlers alike.

Set this to the actual number of proxies in front of the app. Too high and you read a value the client controls, which defeats per-IP limiting. Too low and everyone behind your edge shares one bucket, which throttles legitimate traffic. The default of 1 is correct for a single load balancer or CDN (Railway, Fly, a lone nginx). Add one for each additional layer — Cloudflare in front of Railway is 2.

Cloudflare's cf-connecting-ip header is used when present and takes precedence, since Cloudflare overwrites rather than appends it.

VariableDescriptionExample
ORIGIN_VERIFY_SECRETOptional secret that locks the proxy-supplied client-IP header's trust to traffic that actually passed through your edge proxy/CDNopenssl rand -hex 32

The proxy-supplied client-IP header (cf-connecting-ip or equivalent) is only spoof-proof if the origin is unreachable except through your edge proxy. If your origin is still reachable directly, anyone can set that header themselves and mint a fresh rate-limit bucket per request, bypassing both the per-IP limiter and your edge's own WAF/DDoS layer.

To close this:

  1. Generate a secret: openssl rand -hex 32.
  2. Set ORIGIN_VERIFY_SECRET to that value in your deployment's environment.
  3. On your CDN/edge proxy, add a rule (most offer a "modify request header" or equivalent feature) that runs for all traffic and sets a request header x-origin-verify to the same secret value.
  4. Confirm requests hitting your origin without that header now get a 403 Forbidden from /api/mcp, /api/v1/agent/*, and /api/widget/chat.

Until this is configured, the proxy-supplied client-IP header is trusted unconditionally — the app has no way to tell an edge-routed request from a direct one. Set ORIGIN_VERIFY_SECRET on any deployment sitting behind a CDN or edge proxy.

OAuth (required)

Login is Google OAuth only.

VariableDescription
AUTH_GOOGLE_IDGoogle OAuth client ID
AUTH_GOOGLE_SECRETGoogle OAuth client secret

Callback URL to register: {AUTH_URL}/api/auth/callback/google

DISCORD_CLIENT_ID and GITHUB_APP_ID below are unrelated to sign-in — they're the "connect a Discord server" and "install the GitHub App" flows inside the product, available once you're already logged in with Google.

Per-org AI keys set in Settings → AI Model override these env vars for that org. The platform key is the fallback when no org key is set.

VariableProvider
OPENAI_API_KEYOpenAI (covers all features including embeddings)
ANTHROPIC_API_KEYAnthropic platform default
GOOGLE_GENERATIVE_AI_API_KEYGoogle Gemini platform default
GROQ_API_KEYGroq platform default
MISTRAL_API_KEYMistral platform default

Widget chat runtime (optional)

VariableDescription
COPILOTKIT_TELEMETRY_DISABLEDOpts the widget chat's CopilotKit runtime out of its telemetry ping. Defaults to true (set in instrumentation.ts before any route loads) — set it to false yourself if you want that telemetry on.

Discord (optional)

VariableDescription
DISCORD_TOKENPlatform shared bot token. Required for 1-click OAuth mode and for the channel picker in Settings.
DISCORD_CLIENT_IDApplication ID — enables the "Add to Discord" 1-click flow from onboarding. Register callback: {AUTH_URL}/api/discord/callback. Without it, onboarding falls back to a manual bot-token + channel-ID flow
DISCORD_APPLICATION_IDApplication ID for slash command registration (same value as DISCORD_CLIENT_ID)
DISCORD_GUILD_IDGuild ID for guild-scoped slash command registration during dev (instant vs. global's 1-hour delay)
BOT_SECRETShared secret between the bot service and /api/ingest. Auto-generated per-org via Settings; this is the env fallback.
BOT_TARGET_URLURL of the app service as seen from the bot container. No trailing slash or period. Default: http://localhost:3000

DISCORD_TOKEN must be set on both the app service and the bot service. The app service uses it to power the channel picker in Integrations → Discord. The bot service uses it to connect to Discord's gateway. If they reference different Discord applications, the bot joins a different server than users authorized.

Slack (optional)

VariableDescription
SLACK_CLIENT_IDOAuth app Client ID. Enables 1-click "Add to Slack" install.
SLACK_CLIENT_SECRETOAuth app Client Secret. Required with SLACK_CLIENT_ID.
SLACK_SIGNING_SECRETSigning secret — verifies Events API webhook payloads.
SLACK_POLL_INTERVAL_SECONDSPoll interval in polling mode. Default: 60. Minimum: 30.

Add {AUTH_URL}/api/slack/callback to your Slack app's OAuth Redirect URLs.

Google Chat (optional)

VariableDescription
GOOGLE_CHAT_SERVICE_ACCOUNT_JSONFull service-account JSON key, as a single-line string. Used to authenticate outgoing replies to the Chat API.
GOOGLE_CHAT_ENDPOINT_URLPublic HTTP endpoint URL, e.g. https://{YOUR_DOMAIN}/api/google-chat/events. Must exactly match the endpoint URL configured in the Chat app — Google verifies each request's token audience against this value.

See Google Chat App Setup for the one-time Google Cloud configuration these values come from.

GitHub App (optional)

VariableDescription
GITHUB_APP_IDApp ID from GitHub App settings page
GITHUB_APP_PRIVATE_KEYBase64-encoded PEM: base64 -i your-app.pem | tr -d '\n'
GITHUB_WEBHOOK_SECRETWebhook secret. Generate with openssl rand -hex 32 and paste without the trailing newline. Required: /api/github/webhook returns 503 and processes no deliveries until it is set, and the value must match the secret configured on the GitHub App.
GITHUB_APP_SLUGURL-safe app name (slug) — shown in your GitHub App's URL

Register these URLs in your GitHub App:

  • Callback URL: {AUTH_URL}/api/github/callback
  • Webhook URL: {AUTH_URL}/api/github/webhook

Subscribe to events: Issues, Issue comments, Discussions, Discussion comments, Push.

Telegram (optional)

VariableDescription
TELEGRAM_BOT_TOKENBot token from @BotFather. Fallback when no org token is saved.

After deploying, register the webhook: POST {AUTH_URL}/api/telegram/register (or click Register webhook in Integrations → Telegram).

Email notifications — Resend (optional)

VariableDescription
RESEND_API_KEYAPI key from resend.com. Enables email alerts (new ticket, SLA breach, resolved).
RESEND_FROMVerified sender address e.g. [email protected]
RESEND_WAITLIST_FROMFrom address for waitlist confirmation emails. Falls back to RESEND_FROM.
RESEND_WEBHOOK_SECRETSvix signing secret (whsec_...) for Resend's outbound delivery-status webhooks (email.bounced/email.complained/etc.) — see Email Channel Setup
GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRETGoogle Cloud OAuth client credentials for the "Connect Gmail" send-only integration (see Email Channel Setup) — optional, requires manual GCP OAuth-client + consent-screen setup
GMAIL_REDIRECT_URIOptional override for the Gmail OAuth callback URL (defaults to <AUTH_URL or NEXTAUTH_URL>/api/email/gmail/callback)
OUTLOOK_CLIENT_ID / OUTLOOK_CLIENT_SECRETMicrosoft Entra app registration credentials for the "Connect Outlook" send-only integration (see Email Channel Setup) — optional, requires manual Entra app registration + (for external users) Partner Center publisher verification
OUTLOOK_REDIRECT_URIOptional override for the Outlook OAuth callback URL (defaults to <AUTH_URL or NEXTAUTH_URL>/api/email/outlook/callback)

If absent, email notifications skip silently — nothing breaks. Without RESEND_WEBHOOK_SECRET, bounce/complaint tracking on outbound replies is unavailable, but sending and inbound ingest both still work. Without GMAIL_CLIENT_ID/GMAIL_CLIENT_SECRET or OUTLOOK_CLIENT_ID/OUTLOOK_CLIENT_SECRET, the respective Connect button is unavailable but every other email path still works.

URL ingest — Firecrawl (optional)

VariableDescription
FIRECRAWL_API_KEYAPI key from firecrawl.dev. Enables Settings → Import from URL to crawl docs sites into the KB.

Discourse

No environment variable. Each org connects its own Discourse forum in Integrations → Discourse by pasting a bot-scoped API key, which is stored encrypted — make sure ENCRYPTION_KEY (above) is set before connecting in any real deployment. See Discourse for the full setup.

Circle

No environment variable. Each org connects its own Circle community in Integrations → Circle using a per-org webhook secret — make sure ENCRYPTION_KEY (above) is set before connecting in any real deployment. See Circle for the full setup.

Notion KB source

No environment variable. Each org connects its own Notion workspace in Integrations → Notion by pasting an internal integration token, which is stored encrypted — make sure ENCRYPTION_KEY (above) is set before connecting in any real deployment. See Notion for the full setup.

Web push notifications (optional)

VariableDescription
VAPID_PUBLIC_KEYVAPID public key
VAPID_PRIVATE_KEYVAPID private key
VAPID_EMAILmailto:[email protected] — identifies your push endpoint

Generate keys:

pnpm dlx web-push generate-vapid-keys

Error tracking — Sentry (optional)

VariableDescription
SENTRY_DSNDSN from your Sentry project's Client Keys settings. Unset means error tracking is fully disabled — no requests to Sentry are made.
SENTRY_AUTH_TOKENOnly needed to upload source maps at build time, for readable stack traces. Unset means builds succeed without uploading maps.
SENTRY_ORGYour Sentry organization slug. Only used alongside SENTRY_AUTH_TOKEN.
SENTRY_PROJECTYour Sentry project slug. Only used alongside SENTRY_AUTH_TOKEN.

Billing / deployment mode

VariableDescriptionExample
DEPLOYMENT_MODESet to cloud only on answerLoops' own managed SaaS. Leave unset (or anything other than cloud) on a self-hosted deployment.cloud
STRIPE_SECRET_KEYOnly relevant when DEPLOYMENT_MODE=cloud. Not needed for self-hosting.
STRIPE_WEBHOOK_SECRETOnly relevant when DEPLOYMENT_MODE=cloud. Signing secret for /api/billing/webhook.
STRIPE_PUBLISHABLE_KEYOnly relevant when DEPLOYMENT_MODE=cloud. Publishable key for the embedded checkout form, read at request time. Not NEXT_PUBLIC_-prefixed on purpose: that prefix is baked in at build time. Safe in client code — it can only create payment attempts.pk_live_...
STRIPE_PRICE_STANDARD / STRIPE_PRICE_PRO / STRIPE_PRICE_ENTERPRISEOnly relevant when DEPLOYMENT_MODE=cloud. Monthly Stripe Price ID per plan — must match the plan it names, since the displayed price and the charged price come from different places.
STRIPE_PRICE_STANDARD_ANNUAL / STRIPE_PRICE_PRO_ANNUAL / STRIPE_PRICE_ENTERPRISE_ANNUALOnly relevant when DEPLOYMENT_MODE=cloud. Annual Stripe Price ID per plan. A plan missing its annual price declines annual checkout rather than billing the monthly rate.

Self-hosted deployments (the default — DEPLOYMENT_MODE unset) are never metered and every plan-gated feature (Discord/Slack integrations, CSAT scoring, simulation/dry-run mode, knowledge gap dashboard, custom AI model config, etc.) is unlocked unconditionally. You're already bringing your own AI provider, database, and hosting — there's no usage of yours for answerLoops to meter or restrict.

Don't set DEPLOYMENT_MODE=cloud on a self-hosted instance — it opts the deployment into plan-tier gating with no Stripe subscription behind it, which locks every gated feature instead of unlocking them.

On this page