---
url: https://docs.signalscout.run/self-hosting/configuration.md
---
# 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.
