Skip to content

Configuration reference

Every setting SignalScout reads, in the order .env.example lists them, with the reason written beside each one. Set a value in the .env file next to docker-compose.yml, then run docker compose up -d so the containers read it.

You rarely need more than the short file you copied at install, .env.example.self-hosted. A setting marked in the short file is one it already names. Every other setting has a default that is the self-hosted answer.

Application

NODE_ENV

Default: development

development | test | production

LOG_LEVEL

Default: info · in the short file

fatal | error | warn | info | debug | trace | silent

PORT, HOST

  • PORT — default 3000 · in the short file
  • HOST — default 0.0.0.0

The port the API listens on, inside the container and on the host.

WEB_PORT

Default: 5173

Where the Vite dev server listens, for pnpm dev only. The UI is a separate origin from the API in development, and the API trusts a sign-in from this port; worktrees.mjs writes it per worktree. Nothing in a container reads it: there one process serves the UI and the API on one origin. BUG-031.

WORKER_IN_PROCESS

Default: true · in the short file

  • true — one process runs the API and the worker. The default; ~120 MB.
  • false — the worker runs as a second container from the same image. When you set this to false, also set COMPOSE_PROFILES=worker below, so docker compose up starts that container.

WEB_DIST_PATH

Default: empty

Absolute path to the built UI. Leave empty to use the build next to the API.

Database

DATABASE_URL

Default: postgres://intentwatch:intentwatch@localhost:5432/intentwatch · in the short file

Read by the app, by pnpm dev and by pnpm test. Inside compose the app reaches Postgres as postgres, so compose sets its own value there; this one is the value host processes use. The Postgres role and database keep the old product name on purpose. The rename to SignalScout does not travel into a database that already holds rows; renaming these gives you an empty one. A fresh install may use any name.

POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB

  • POSTGRES_USER — default intentwatch · in the short file
  • POSTGRES_PASSWORD — default intentwatch · in the short file
  • POSTGRES_DB — default intentwatch · in the short file

Compose creates the database from these. Change the password before you put this anywhere other than your own machine, and change DATABASE_URL with it.

Secrets

AUTH_SECRET

Default: empty · in the short file

Signs the session cookie. Base64 of 32 bytes, or any 32 characters:

openssl rand -base64 32

Required. The application refuses to start without it, because an instance with no login serves its inbox and its provider keys to whoever finds the port. There is no default and there will not be one: a shipped default would let anybody holding this source mint a session for any instance running it.

pnpm dev writes one here the first time it runs, so a clean checkout starts.

AUTH_SIGNUP

Default: closed

Whether a stranger may create an account: closed | open

  • closed — the default, and the self-hosted shape. The first visit makes one account and every attempt after it is refused by the server.
  • open — anybody who reaches the login screen may register. The cloud shape.

Opening it changes whose keys pay. With signup open, the provider keys and AI_API_KEY in this file are no longer offered to any account: each person pastes their own on the Connections and Models screens, and a job with no key does not run. MACHINE_KEYS below is the one way to change that.

Leave it closed on a machine you run for yourself. With it open, set AUTH_EMAIL_VERIFICATION=required too, or anybody may register an address they do not own.

MACHINE_KEYS

Default: empty

Whose keys pay: account | instance

Empty takes the default for AUTH_SIGNUP: instance when closed, account when open. That is the rule above, and a self-hosted machine never needs to set this.

  • instance — every account's polls and model calls fall back to the keys in this file. With signup open, that is a hosted instance that pays for its accounts; put a spend guard in front of it, because the bill is yours. What does not open with the keys: a stranger's webhook is still kept off your own network, and the instance's WEBHOOK_SIGNING_SECRET is still nobody's to sign with.
  • account — nothing falls back. A monitor whose owner pasted no key does not run, and the screen says which key it wants.

AUTH_EMAIL_VERIFICATION

Default: off

Whether an address is proven before an account is used: off | required

  • off — the default, and the self-hosted shape. The address is taken as given, and a session starts the moment the account exists.
  • required — a link is sent to the address, and nobody is signed in until they open it. The cloud shape.

Set this to required wherever AUTH_SIGNUP is open. Without it, anybody can register with an address they do not own — including one somebody else is about to use, because the column is unique — mint a fresh trial from an address nobody has to reach, and receive the digests meant for whoever really owns it.

required needs SMTP_HOST and SMTP_FROM further down, and the process refuses to start without them. That check is not fussiness: with no mail server the link is never sent, so the instance would refuse every account it has, including yours, with a message about an email nobody posted.

The link works for 24 hours. Signing in again sends a new one, which is the only way back for somebody whose link expired.

APP_URL

Default: empty

Where this instance answers, for the links a notification carries. Not derived from a request header: an address built from something the caller controls is an address the caller chooses.

AUTH_URL

Default: empty

Where this instance answers, when a proxy rewrites the host. Leave it empty whenever one process serves the UI and the API on one origin, which is the normal install: the address is then read from each request.

TRUST_PROXY

Default: empty

Which connections may say who the client is, through X-Forwarded-For. The sign-in rate limit counts by that address. Empty trusts loopback and the private networks, where a reverse proxy in front of this sits, and nothing from the internet. off trusts nobody. Or a comma-separated list of addresses and ranges, when the proxy is somewhere else.

AUTH_TRUSTED_ORIGINS

Default: empty

Origins allowed to sign in, besides this instance's own address. Comma separated. Leave it empty for the normal install.

Set it when the UI is served from somewhere else than the API, or when a proxy rewrites the host without rewriting the browser's Origin. Getting it wrong looks like a login that answers 403 "Invalid origin" every time.

pnpm dev needs no entry here: the API trusts the Vite dev server on 5173 by itself while NODE_ENV is development, and never in production.

ENCRYPTION_KEY

Default: empty · in the short file

Encrypts credentials stored in the database. Base64 of 32 bytes:

openssl rand -base64 32

It encrypts the keys pasted on the Connections and Models screens, and each account's webhook signing secret. A key in this file needs no encryption: it is already outside the database and outside git. So an instance whose keys all live here may leave it empty, and the screens then say they cannot store one.

Once a provider key is stored, this stops being optional: the API and the worker decrypt every stored provider key at boot and refuse to start if they cannot. Losing this value loses what it encrypted. The rotation steps are at https://docs.signalscout.run/self-hosting/maintenance

Sources

BRIGHTDATA_API_KEY

Default: empty

Bright Data, for Reddit. This key buys nothing here now. US-158 switched the Reddit connector that spends it off on 2026-09-17: it bills a record per post, about five times what ScrapeCreators or SocialCrawl charge for the same subreddit page. The variable stays because the connector still ships and this file lists one variable per credential field the build registers. Setting it does nothing until somebody deletes notOffered from packages/engine/src/sources/providers/brightdata/reddit.ts.

The name is Bright Data's, not Reddit's: one key serves every platform we fetch through them, so it is set once and rotated once. REDDIT_API_KEY was the old name and is still read, so an instance that upgrades keeps polling. It is going; rename it when you next edit this file.

SCRAPECREATORS_API_KEY

Default: empty · in the short file

ScrapeCreators, the other provider that fetches Reddit. A new account gets 100 credits and needs no card. One credit is one request, whatever the request returns, and a request returned 7 to 23 posts when we measured it — so it is much cheaper per post than Bright Data, which bills per record. $47 buys 25,000 credits; get the key at https://scrapecreators.com.

SOCIALCRAWL_API_KEY

Default: empty · in the short file

SocialCrawl, the provider that fetches X. A new account gets 100 credits and needs no card. One credit is one request and a request returned 20 posts when we measured it; a search that matches nothing is refunded. £15 buys 2,500 credits, £49 buys 20,000; get the key at https://www.socialcrawl.dev.

X's own API is not used and needs no key here. It sells the same searches at $0.005 per post read with no free tier, which is about thirty times more, and it can close a developer account the way Reddit closed registration.

APIFY_API_TOKEN

Default: empty · in the short file

Apify, for LinkedIn. US-057.

The second provider for that platform, and the only one of three that answers "who said this in the last hour": every post in the measurement was under ninety minutes old, where the alternatives return posts days or weeks old ranked by relevance. It is not the cheap one — fifty posts cost $0.10 on a free Apify plan against $0.0094 elsewhere. Freshness is what it buys.

What we actually run is one actor somebody else publishes, harvestapi/linkedin-post-search, and it needs no LinkedIn account or cookie. It bills per post returned, so an empty search still costs $0.00105 and a poll that re-collects what it already has is charged for it again.

The price is tiered by Apify plan: $0.002 a post on FREE and BRONZE, $0.0015 on GOLD and above. The connector counts the dearest, so a person on a better plan is told they spent more than they did rather than less. Get a token in the Apify console under Settings, Integrations.

SOCIALDATA_API_KEY

Default: empty · in the short file

SocialData, for X. US-061.

The second provider that can search X, and the reason to have it is not the price: until it existed, every X poll depended on one account at one company. US-006 asked three providers and only one could find a stranger on X.

It is half the price — $0.0002 a tweet against SocialCrawl's $0.0004 — but the better reason is that the window goes to the provider. A monitor's since is sent as since_time: inside the query, so a poll stops paying for posts it would throw away: a 24-hour window returned 7 tweets for $0.0014 where the same query unwindowed returned 20 for $0.0040.

Quote the value. A key here can contain a pipe character, and an unquoted KEY=value line makes the shell run the second half as a command and load an empty key — which looks exactly like a missing one.

The account is prepaid: the API answers 402 when the balance runs out, which is not a wrong key and not something retrying fixes. Top up at socialdata.tools.

HIKERAPI_ACCESS_KEY

Default: empty

HikerAPI, for Instagram through Instagram's own search. US-160 measured it on 2026-09-17 and no connector reads it yet; only the capture script does. Pay as you go: $1.00 per thousand requests, $0.60 past a balance threshold, every answered request billed, 400s included. Key under Dashboard.

The model

AI_PROVIDER, AI_MODEL, AI_API_KEY

  • AI_PROVIDER — default anthropic · in the short file
  • AI_MODEL — default claude-haiku-4-5 · in the short file
  • AI_API_KEY — default empty · in the short file

The model that scores posts against a monitor. One provider, chosen here.

openai | anthropic | google | deepseek | openrouter | ollama

Bring your own key. Nothing is sent to us, and the classification runs wherever this points. ollama needs no key and reaches http://localhost:11434/v1, so a local model is these two lines and no code change.

deepseek reaches https://api.deepseek.com/v1 and carries no price: DeepSeek charges four different rates for one model, by hour and by cache hit, so a call records no cost until the two price variables below are set.

AI_BASE_URL

Default: empty

Point the client somewhere else: a self-hosted gateway, an OpenAI-compatible server, or an Ollama that is not on localhost. Empty uses the provider's own.

AI_TIMEOUT_MS

Default: 30000

How long one classification may take, in milliseconds. A local model on a small machine is slower than an API.

AI_INPUT_PRICE_MICROS, AI_OUTPUT_PRICE_MICROS

  • AI_INPUT_PRICE_MICROS — default empty
  • AI_OUTPUT_PRICE_MICROS — default empty

What the model costs, in micro-dollars per million tokens — millionths of a dollar, so $1.00 per million is 1000000. Set these only for a model whose price we do not carry; without them the call is recorded with no cost rather than with a guessed one.

The embedding

AI_EMBEDDING_PROVIDER, AI_EMBEDDING_MODEL, AI_EMBEDDING_API_KEY, AI_EMBEDDING_BASE_URL

  • AI_EMBEDDING_PROVIDER — default empty · in the short file
  • AI_EMBEDDING_MODEL — default empty
  • AI_EMBEDDING_API_KEY — default empty · in the short file
  • AI_EMBEDDING_BASE_URL — default empty

The model that embeds posts for the pre-filter, which drops the obvious misses before the classifier is paid to read them. An embedding costs about one hundredth of a classification.

openai | google | openrouter | ollama

Neither Anthropic nor DeepSeek has an embedding endpoint, so a deployment on either has to name another provider here. Leave it empty and the pre-filter still runs its free keyword stage; everything that survives reaches the model. Nothing is dropped by a stage that is not running.

The model must return 1536 numbers per embedding, because that is the width the database stores. A model of another width is refused with a message rather than written. Empty uses text-embedding-3-small on OpenAI, and has no default anywhere else. Empty reuses AI_API_KEY, but only when the two providers are the same.

AI_EMBEDDING_PRICE_MICROS

Default: empty

What the embedding model costs, in micro-dollars per million tokens. We carry no price table for these, so the call is recorded with no cost until this is set. Read the number off your provider's own pricing page: a guessed price would look exactly like a measured one on the monitor list.

The triage

AI_TRIAGE, AI_DRAFT_PROVIDER

  • AI_TRIAGE — default on · in the short file
  • AI_DRAFT_PROVIDER — default empty

The model that reads everything before the classifier does, and answers one question in one word: could this author be a person to reach?

Unlike the embedding block above, empty does not mean "no stage". Every setting here falls back to the classifier's, so a deployment that names nothing still triages, on the model it already has — which is the most expensive arrangement there is. AI_TRIAGE=off is how you switch the stage off; leaving the model blank is not.

So: name a triage model that is cheaper than AI_MODEL and reliable at this one question, or set AI_TRIAGE=off. Doing neither costs you money. A cheap model that is not reliable costs you leads instead, and nothing in the inbox shows it: a post triage drops never reaches the scorer.

That is measured, not assumed, and it surprised us. A one-word answer was supposed to be the saving. It is not: a reasoning model bills its own thinking as output, so one triage answer measured 113 output tokens against a classification's 95 and cost slightly more per call than the classification it avoids. All of the saving is the price gap between the two models.

Over 46 real comments, keeping 19: a classifier ten times dearer than the triage model made the bill 48% smaller, and the same model on both stages made it 48% larger. The worker warns at startup when the two match.

A working pair, with prices read 2026-09-06: AI_MODEL=gpt-5.6-terra with AI_TRIAGE_MODEL=gpt-5.6-luna on the openai provider, which is ten to one. The default above — claude-haiku-4-5 with nothing cheaper carried — has no gap, so a deployment that keeps it pays for triage in money and is repaid only in a quieter inbox.

"on" or "off". On is the default and the answer while a triage model exists that is both cheaper than the scorer and reliable at the question. Off sends every post the free stages kept straight to the classifier, and is the right answer when the two stages would run the same model: with no price gap the stage costs 48% more, and it keeps the one risk a cascade has — a dropped post leaves no row, no inbox entry and nothing to notice. US-177. The model that writes a reply draft. US-070.

Empty means the classifier's, above. Set it when the model that scores well is not the model that writes well — a draft carries your name into somebody else's conversation, and nothing is ever posted from here.

The prices fall back to the classifier's only while no model is named. A draft billed at the scorer's rate would misreport what it cost, and that figure is shown to the person who pressed the button.

AI_DRAFT_TIMEOUT_MS, AI_DRAFT_MODEL, AI_DRAFT_API_KEY, AI_DRAFT_BASE_URL, AI_DRAFT_INPUT_PRICE_MICROS, AI_DRAFT_OUTPUT_PRICE_MICROS

  • AI_DRAFT_TIMEOUT_MS — default empty
  • AI_DRAFT_MODEL — default empty
  • AI_DRAFT_API_KEY — default empty
  • AI_DRAFT_BASE_URL — default empty
  • AI_DRAFT_INPUT_PRICE_MICROS — default empty
  • AI_DRAFT_OUTPUT_PRICE_MICROS — default empty

How long a draft may take. AI_TIMEOUT_MS is set for a classification, which runs per post and must not hold a job open; a draft runs when a person clicks, on whatever model writes best, and a reasoning model chosen for that takes longer than thirty seconds. Blank follows AI_TIMEOUT_MS.

AI_PLAN_PROVIDER, AI_PLAN_MODEL, AI_PLAN_API_KEY, AI_PLAN_BASE_URL, AI_PLAN_INPUT_PRICE_MICROS, AI_PLAN_OUTPUT_PRICE_MICROS

  • AI_PLAN_PROVIDER — default empty
  • AI_PLAN_MODEL — default empty
  • AI_PLAN_API_KEY — default empty
  • AI_PLAN_BASE_URL — default empty
  • AI_PLAN_INPUT_PRICE_MICROS — default empty
  • AI_PLAN_OUTPUT_PRICE_MICROS — default empty

The model that writes a monitor's search plan. US-269.

Empty means the classifier's, above. The plan is written once when a monitor is made and again only when somebody asks for a new one, and it decides every post the monitor will ever collect. A weak plan is a month of polling for the wrong conversations, and the call is rare enough that a model twenty times the classifier's price costs cents. The Models screen sets the same thing per account.

The prices fall back to the classifier's only while no model is named.

AI_TRIAGE_PROVIDER, AI_TRIAGE_MODEL, AI_TRIAGE_API_KEY, AI_TRIAGE_BASE_URL

  • AI_TRIAGE_PROVIDER — default empty · in the short file
  • AI_TRIAGE_MODEL — default empty · in the short file
  • AI_TRIAGE_API_KEY — default empty
  • AI_TRIAGE_BASE_URL — default empty

The triage provider may be an evaluation model, which no other job may use. US-230.

typesafe with the model jev-latest answers a typed question against the monitor and the item instead of reading a prompt. It can triage and it cannot classify, draft or embed, so it belongs on this setting alone; naming it as AI_PROVIDER fails at startup with a sentence saying so.

It also answers how sure it is, and a no below 0.6 confidence keeps the item rather than dropping it. That is the stage's own rule — only an explicit no

  • drops — applied to a model that can say it is unsure.

It needs its own key in AI_TRIAGE_API_KEY, because the key below is only reused when both jobs run on the same provider. Measured in US-229 over 227 items: the same six leads kept as gpt-5.6-luna, twelve items sent to the classifier rather than seventeen, at about an eighth of the triage cost. US-229 is still open: it is the held-out measurement, and this option ships unpromoted until that says it should be. Empty reuses AI_API_KEY, but only when the two providers are the same.

AI_TRIAGE_INPUT_PRICE_MICROS, AI_TRIAGE_OUTPUT_PRICE_MICROS

  • AI_TRIAGE_INPUT_PRICE_MICROS — default empty
  • AI_TRIAGE_OUTPUT_PRICE_MICROS — default empty

What the triage model costs, in micro-dollars per million tokens. These fall back to AI_INPUT_PRICE_MICROS and AI_OUTPUT_PRICE_MICROS only while no triage model is named. Once one is, they do not: a cheap model billed at the classifier's rate would report a saving that did not happen.

Compose

SIGNALSCOUT_IMAGE

Default: ghcr.io/rszhd/signalscout:latest

The published image the server pulls. It never compiles anything.

COMPOSE_PROFILES

Default: empty · in the short file

Leave empty for one container. Set to worker to start the second one.

Public host

APP_HOST

Default: empty · in the short file

The hostname the browser uses. docker-compose.prod.yml refuses to start without it, because a stack with no hostname would be routed nowhere and the error would arrive as a certificate that never issued.

ACME_EMAIL

Default: empty · in the short file

Where Let's Encrypt sends expiry warnings. Only docker-compose.proxy.yml reads it, so leave it empty if a proxy you already run terminates TLS.

EDGE_NETWORK

Default: signalscout-edge

The Docker network the proxy and the app meet on. The default is this product's own. Change it to join a proxy another stack on the box already runs, and then do not start docker-compose.proxy.yml at all.

TRAEFIK_NAME

Default: signalscout

Names this stack's Traefik router and service. One value per stack on a box: two stacks sharing a name share a router, and the second one to start wins.

ROBOTS_TAG

Default: all

What the proxy answers in X-Robots-Tag. Production says all. A staging or preview stack must say noindex, nofollow, or a search engine lists it.

Notifications

SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASSWORD, SMTP_FROM

  • SMTP_HOST — default empty · in the short file
  • SMTP_PORT — default 587 · in the short file
  • SMTP_SECURE — default false · in the short file
  • SMTP_USER — default empty · in the short file
  • SMTP_PASSWORD — default empty · in the short file
  • SMTP_FROM — default empty · in the short file

SMTP is optional, unless AUTH_EMAIL_VERIFICATION is required — then SMTP_HOST and SMTP_FROM are what carry the confirmation link, and the process refuses to start without them. Resend: smtp.resend.com, port 465, secure true, user resend, password your Resend API key. SMTP_FROM is an address on your verified domain. See https://docs.signalscout.run/self-hosting/email, and /self-hosting/webhooks for the webhook contract.

WEBHOOK_SIGNING_SECRET

Default: empty · in the short file

Signs every outgoing webhook. Generate with: openssl rand -hex 32 Configure the same value on your receiver. At least 32 characters.

Apache-2.0. The self-hosted build and the cloud share these guides.