Appearance
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— default3000· in the short fileHOST— default0.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, sodocker compose upstarts 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— defaultintentwatch· in the short filePOSTGRES_PASSWORD— defaultintentwatch· in the short filePOSTGRES_DB— defaultintentwatch· 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 32Required. 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 32It 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— defaultanthropic· in the short fileAI_MODEL— defaultclaude-haiku-4-5· in the short fileAI_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 emptyAI_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 fileAI_EMBEDDING_MODEL— default emptyAI_EMBEDDING_API_KEY— default empty · in the short fileAI_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— defaulton· in the short fileAI_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 emptyAI_DRAFT_MODEL— default emptyAI_DRAFT_API_KEY— default emptyAI_DRAFT_BASE_URL— default emptyAI_DRAFT_INPUT_PRICE_MICROS— default emptyAI_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 emptyAI_PLAN_MODEL— default emptyAI_PLAN_API_KEY— default emptyAI_PLAN_BASE_URL— default emptyAI_PLAN_INPUT_PRICE_MICROS— default emptyAI_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 fileAI_TRIAGE_MODEL— default empty · in the short fileAI_TRIAGE_API_KEY— default emptyAI_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 emptyAI_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 fileSMTP_PORT— default587· in the short fileSMTP_SECURE— defaultfalse· in the short fileSMTP_USER— default empty · in the short fileSMTP_PASSWORD— default empty · in the short fileSMTP_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.