Where the configuration lives: shipped defaults + user config directory
All Job Radar settings are YAML, in two layers:
| Layer | Location (environment variable, default) | Who edits it | In git |
|---|---|---|---|
| Shipped defaults | JOB_RADAR_DEFAULTS_DIR, default config/ |
Changes only with product versions | Yes (ships with the product) |
| User config | JOB_RADAR_CONFIG_DIR, default data/config/ |
You, the web app (/settings, /interests, /sources/registry), pnpm config:* |
No (data/ is ignored) |
| Profile | JOB_RADAR_PROFILE_DIR, default data/profile/ |
You, the web app /me | No |
Relative paths are resolved against the project directory (not the current directory). In Docker, the mounts and variables of web and runner are in compose.yaml (web: ./config:/app/config:ro, ./data/config:/app/data/config; runner mounts both read-only); the host ai-worker gets all three directories written explicitly into its launchd plist by scripts/ops/install-ai-worker.sh.
Which copy is read
Section titled “Which copy is read”- If the user directory has the file, it is read; if not, the one in the defaults directory is read. The layers are not merged: a file in the user directory takes effect as a whole.
- Saving in the web app always writes to the user directory; the first time a file that only exists in the defaults directory is saved, it is generated from the default file as a base (the default file’s comments are carried over). The defaults directory is never written. A save also leaves out keys the file does not have yet when the value is just what the schema defaults to (it would pin a default that a later product update may change); keys the file already has are kept, and a value that differs from the default is written.
- Hot reload watches both layers: a user file appearing, being deleted (falling back to the default), or a default file changing (when you have no copy of your own) all take effect within about 5 seconds. The runner and the ai-worker watch every config file,
me.yamlandpreferences.yamlincluded, and also re-read them at the start of each cycle and each request, so a change (for example the market saved in the setup wizard or on/me) applies to the next cycle even when it starts right away, without a restart. pnpm config:checkprints which layer each file is read from:(user),(default),(user override of a shipped file).
Two kinds of files
Section titled “Two kinds of files”| Kind | Files | First-time setup | On product upgrade |
|---|---|---|---|
user |
schedule, search, sources, ai, notify, interests, ui, breakdown, resume, insights, language, setup, salary (salary.md) | Copied from the defaults directory to the user directory, yours from then on | Your files are never changed automatically; format changes are carried over by migrations (config_version) |
shipped |
models (model catalog and prices), pipeline (parsing rules, city and skill tables), signal-presets (the presets of the “Add category” dialog on /interests, see signals.md) |
Not copied; always read from the defaults directory | Follows product updates |
A user file that a product upgrade introduces is copied from the defaults on the next start; when the user directory
already held configuration (an existing install), a file can be written with the behaviour the install had so far
instead (language.yaml: Chinese, see languages.md). The setup wizard uses the same “existing
install” check.
To change a shipped file, copy it into the user directory and edit it there (the whole file overrides). The cost is that later product updates to that file no longer take effect: pnpm config:check and the doctor’s config.layers will keep saying “you override models.yaml”. To follow the product again, delete your copy in the user directory.
Market packs (config/packs/<id>/: countries, cities, local-language rules) are shipped the same way: a copy in data/config/packs/<id>/ replaces the shipped pack as a whole. See market-packs.md.
Commands
Section titled “Commands”Run these in the project directory.
pnpm config:init # create the user directory, copy missing user files and profile templates; existing ones are untouched (safe to repeat)pnpm config:init --from <dir> # copy from another directory (for migrating an old install), and also copy shipped files that differ from the defaultspnpm config:migrate # list the files to migrate and their diffs, write nothingpnpm config:migrate --write # migrate: first back up the original files to data/config/.backup/<timestamp>/, then writepnpm config:check # validate both layers (default-directory files must be valid even when overridden), print which layer each file is read frompnpm config:example # list the example setups; `pnpm config:example <id> [--dry-run]` applies one (see below)web and runner also run config:init once at startup (filling in whatever is missing; the runner has the user directory read-only inside the container and does not need to write when the directory is already complete); step 4 of scripts/ops/deploy.sh runs config:init -> config:migrate --write -> config:check in turn.
setup.yaml
Section titled “setup.yaml”setup.yaml holds the state of the first-run setup (completed_at, completed_by, steps); the setup wizard and
pnpm run setup write it. On a fresh install it starts pending (every page opens /setup, the runner holds back its
scheduled cycles). When config:init (or the web / runner start-up) finds a user directory that already holds config files
but no setup.yaml, it writes one marked complete (completed_by: existing_install), so an existing install never sees
the wizard. An unreadable file counts as complete. See setup.md.
Example setups
Section titled “Example setups”The shipped defaults are deliberately neutral: one placeholder keyword group (targets: [Software Engineer]), no
company career boards (ats_boards: []), no extra title words for the board channels (include_terms: []), time zone
America/New_York and a US example market in the profile templates. The setup wizard (or pnpm run setup) replaces
them with your answers.
Ready-made setups ship as examples in config/examples/<id>/. Each holds an example.yaml (name, description) and
partial copies of user files with only the keys it sets. They are never read unless you apply one:
pnpm config:example # list thempnpm config:example ai-engineering-europe --dry-run # show which settings would changepnpm config:example ai-engineering-europe # apply: back up the files that change to data/config/.backup/<timestamp>/, then writeApplying merges each file into your copy in data/config/ (comments kept): maps key by key, lists replaced, except
ats_boards, where the boards you do not have yet are added; keyword_groups is replaced only while it is still the
shipped placeholder, otherwise the example’s groups are added next to yours. A list that replaces entries you have is named in a warning (also with –dry-run). Applying twice changes nothing.
| Example | What it sets |
|---|---|
ai-engineering-europe |
search.yaml keyword groups for AI / LLM / ML engineering, forward deployed and AI solutions roles; sources.yaml AI title words for the job board and company board channels and ten example company career boards (Greenhouse, Lever, Ashby, Personio, SmartRecruiters); ai.yaml translation glossary for those job titles. Market, cities and time zone are not part of it: pick them in the wizard (the example was made for the Germany pack, Berlin, Europe/Berlin). |
To share your own setup the same way, put a directory with an example.yaml and the partial files next to it.
Protected keys (edited by hand only)
Section titled “Protected keys (edited by hand only)”Some values decide which program the host runs or where a credential is sent. The runner and the host ai-worker run with
every secret of .env, so the web app (settings forms, advanced editor, setup wizard, source registry) may not change
these keys; you edit them by hand in the file in your config directory:
| File | Protected keys |
|---|---|
ai.yaml |
providers.*.command (the claude / codex executables), providers.*.base_url (DeepSeek, MiMo), providers.codex_cli.config_overrides, providers.codex_cli.disable_features, providers.codex_cli.trim_context |
notify.yaml |
telegram.api_base_url |
sources.yaml |
every base_url, api_base_url and eu_api_base_url, jobspy.url, jobspy.proxies, sources.gmail.host / port / secure, linkedin.session.site.auth_file, linkedin.apify.actor_id |
resume.yaml |
claude_command, claude_args, resume_kit_path, master_resume_path, pdf_dir, jd_export_dir, jd_export_host_dir |
ui.yaml |
everything under web_access (the login limits, see Web access) |
- A save from the web is refused when the value of a protected key differs from the one in the file and from the shipped default. Writing back the value the file already has, or the shipped default (for example “Restore default”, or a key that an older file did not have yet), is allowed.
- The settings forms show these fields read-only with a lock note; the advanced editor lists them above the text and marks a changed one as a problem on its line (“locked key”), so Save stays off.
- To allow the web to change them anyway (for example on a machine where only you can reach the web), set
JOB_RADAR_ALLOW_WEB_EDIT_PROTECTED=1in.envand restart the web (docker compose up -d web). The forms still show the fields locked; use the advanced editor. - The list lives in
src/lib/config/protected-keys.ts.
Checked again where they are used
Section titled “Checked again where they are used”The lock above is the web side. The ai-worker and the runner do not trust the file either (it may have been edited by
hand, or with JOB_RADAR_ALLOW_WEB_EDIT_PROTECTED=1), so a few values are checked again where they are used, whatever the
file says (shipped values always pass):
ai.yaml providers.claude_cli.command/providers.codex_cli.commandmust name theclaude/codexexecutable (bare name or absolute path);providers.codex_cli.config_overrideskeeps only the context switches set tofalse; the codex tool restrictions are always applied, whateverdisable_featuresandtrim_contextsay.base_url/api_base_urlof the services that receive a credential (DeepSeek, MiMo, Telegram, JSearch, Tavily, Brave, Apify) must point to the vendor’s own host, andsources.gmailtoimap.gmail.com:993with TLS, unlessJOB_RADAR_ALLOW_CUSTOM_ENDPOINTS=1is set in.env. Credentialed requests never follow a redirect.- The claude / codex model names must be plain names (no spaces, not starting with
-).
The two opt-outs are separate: JOB_RADAR_ALLOW_WEB_EDIT_PROTECTED=1 only lets the web write the keys,
JOB_RADAR_ALLOW_CUSTOM_ENDPOINTS=1 lets the processes send credentials to a host of your own. What happens when a check
fails is in ai-providers.md; the doctor’s config.guards
lists every value that is ignored or refused.
Which host names reach the web (JOB_RADAR_ALLOWED_HOSTS)
Section titled “Which host names reach the web (JOB_RADAR_ALLOWED_HOSTS)”The web answers only requests addressed to a known host name. This stops DNS rebinding: a web page of another site that
re-points its own name at 127.0.0.1 would otherwise talk to the dashboard as if it were the dashboard itself. Allowed
without any setting:
localhost,127.0.0.1,[::1](any port);*.ts.net(Tailscale:tailscale serve, MagicDNS).
The list never comes from a config file the web can write: notify.yaml: public_base_url (the link in Telegram messages)
does not add its host. A request whose Host, X-Forwarded-Host or Origin names anything else gets
421 Misdirected Request with a short explanation. A request that changes something (a form, a Server Action, anything
but GET / HEAD) must also come from the very address it is sent to: a page on another allowed host, such as another
machine of your tailnet or another program on localhost:5173, is a different site and is refused too. Only origins
listed in JOB_RADAR_ALLOWED_HOSTS are accepted besides the request’s own host. If you reach the web under another name (a reverse proxy such as Caddy or nginx, a LAN name, a LAN IP), add
it to .env and restart the web:
JOB_RADAR_ALLOWED_HOSTS=jobs.example.com,*.home.example,192.168.1.20Entries are comma-separated host names or IP addresses (IPv6 in brackets); *.example.com allows every name below
example.com (the part after *. needs at least two labels, so *.com is ignored); name:port allows only that port.
A reverse proxy must forward the public host name, as Host or as X-Forwarded-Host (Caddy does by default; with
nginx use proxy_set_header Host $host; or proxy_set_header X-Forwarded-Host $host;). Otherwise forms and actions are
refused, because the browser’s origin does not match the address the web sees. For the Docker image, the Server Actions
origin list is fixed when the image is built; forwarding the host makes it unnecessary.
Web access: optional password, bind address and port
Section titled “Web access: optional password, bind address and port”By default the web listens on 127.0.0.1:3000 only and has no login: on your own machine nobody else can reach it, and
your phone comes in through tailscale serve. A password is optional. Set one when anybody but you can reach the
dashboard: through tailscale serve on a tailnet you share, on the LAN, or behind a reverse proxy.
pnpm web:password # asks twice (no echo), prints one line# JOB_RADAR_WEB_PASSWORD_HASH=scrypt:131072:8:1:... -> paste it into .envdocker compose up -d web # the web reads .env when the container is createdThe variables (all in .env, never in a file the web can write):
| Variable | Default | Meaning |
|---|---|---|
JOB_RADAR_WEB_PASSWORD_HASH |
empty | The password as an scrypt hash from pnpm web:password (recommended). Empty = no login. |
JOB_RADAR_WEB_PASSWORD |
empty | The simple form: the password in plain text. When both are set the hash wins. The doctor suggests the hash. |
JOB_RADAR_WEB_BIND |
127.0.0.1 (compose) |
The host address compose publishes the web on, and the address pnpm start / pnpm dev listen on. 0.0.0.0 (or a LAN address) opens it to the network: set a password first; the doctor fails compose.web otherwise. Outside compose, unset means every interface (and no local exemption, see below). |
JOB_RADAR_WEB_PORT |
3000 |
The host port of the web (a second instance needs its own, see multiple-instances.md). The doctor follows both. |
JOB_RADAR_SESSION_SECRET |
generated | The key that signs the session cookies. Unset: 32 random bytes are written to data/config/.web-session-secret (mode 0600) on first use. Set it (at least 32 characters) only to share sessions between machines. |
JOB_RADAR_WEB_LOCAL_LOGIN |
empty | 1: requests from this machine need the password too (see below). Recommended whenever the web is reachable from elsewhere. |
How it behaves with a password:
- A visitor without a session who opens a page goes to
/loginand comes back to the page after signing in. Everything else (API calls, forms, background requests) answers401until then./api/healthstays open for health checks. - A session lasts
ui.yaml: web_access.session_days(30 days); the cookie isHttpOnlyandSameSite=Lax. Over https (tailscale serveis https) it is called__Host-jr_sessionand isSecure(the browser then only sends it to this exact host, over https); over plain http it isjr_session. Sessions made over https before this cookie name existed are not carried over: after that upgrade you sign in once more on each device that comes in through https. The sign-out button is at the bottom of the sidebar (in the menu on a phone). - Ending every session (a lost phone, a shared browser): Settings → Signed-in browsers → Sign out everywhere signs out every
browser at once, this one too. It raises a counter in
data/config/.web-session-generation; every session issued before is refused from then on, nothing else changes. Changing the password ends every session as well; so does deletingdata/config/.web-session-secret(or changingJOB_RADAR_SESSION_SECRET) followed bydocker compose up -d web. - Failed logins are limited before the password is even checked:
web_access.login_max_failures(5) withinlogin_window_minutes(15) lock that source forlogin_lockout_minutes(15). The source is the lastX-Forwarded-Foraddress, the one the nearest hop saw (tailscale servesets it to the device’s tailnet address). Onceglobal_max_failures(30) failures from all sources together fall within the window, every login has to wait after each further failure:global_delay_seconds(2) at first, twice as long each time, at mostlogin_lockout_minutes. A browser that still has a valid session is not held up by that wait. At mostmax_concurrent_logins(4) checks run at once (each takes about 128 MB of memory for a moment). The web log records each lockout and the start of each wait, never the password or the address.min_password_length(10) is the shortest passwordpnpm web:passwordaccepts. These keys are protected: change them by hand indata/config/ui.yaml. - Signing in over plain http from another machine shows a warning on the login page: the password then crosses the
network unencrypted. Use
tailscale serve(https) or an https reverse proxy. - A hash that cannot be read (copied incompletely, an old format) does not turn the login off: nobody can sign in, the
login page says signing in does not work right now, and the doctor’s
web.accesscheck fails. Runpnpm web:passwordagain.
From this machine without the password. A request that names this machine (Host and X-Forwarded-Host are
localhost, 127.0.0.1 or [::1]), whose forwarded client address is loopback (in compose also a private address: the
Docker gateway), that did not come over https through a proxy and that carries none of the headers only a proxy adds,
skips the login, so the browser on the machine itself needs no password. A client can send all of these headers itself,
so the exemption is only on while JOB_RADAR_WEB_BIND is set to a loopback address, that is, while nothing but this
machine can connect:
- compose always sets it (
127.0.0.1unless you change it) and publishes the port there; pnpm startandpnpm devmake the server listen onJOB_RADAR_WEB_BIND(or on-H <address>when you pass one). Without either they listen on every interface, and then every request needs the password,http://localhost:3000too. SetJOB_RADAR_WEB_BIND=127.0.0.1in.envto keep the exemption;pnpm run doctorwarns (web.access) when a password is set, the web does not run in compose and the variable is unset;- running
next startdirectly is not supported here: it ignoresJOB_RADAR_WEB_BINDand listens on every interface, so withJOB_RADAR_WEB_BIND=127.0.0.1in.envanyone on the network could claim to belocalhost. Usepnpm start. It starts Next throughtsx, a development dependency: run it in a checkout installed with plainpnpm install, notpnpm install --prod.
Any address that is not loopback switches the exemption off.
The rule: JOB_RADAR_WEB_BIND must name the address the web is published on. It is how the web learns it is
reachable from the network. Publishing it some other way (editing ports: in compose.yaml, a compose override, a port
forward) while the variable stays unset or 127.0.0.1 leaves the local exemption on; the doctor’s compose.web check
fails with “local login exemption is still on”. Set JOB_RADAR_WEB_BIND to the published address (and a password).
Residual risk: anything on this machine that passes connections on to the web looks local to it. That covers other
containers on the same compose network, a plain TCP forward (tailscale serve --tcp, socat, ssh -R, a router or VPN
port forward to this machine), and a reverse proxy on this machine that you configured to send Host: 127.0.0.1 without
forwarding headers. tailscale serve in its normal https mode is safe: it adds the forwarding headers, so its visitors
always need the password. When the web is reachable from anywhere but this machine, set JOB_RADAR_WEB_LOCAL_LOGIN=1:
then every request needs the password, and you sign in once on the host as well.
Without a password, a red banner on every page says so whenever the dashboard is reached from another machine (or listens
beyond loopback), and the doctor’s web.access check warns.
Who may read the files
Section titled “Who may read the files”The web and the runner write config files with mode 0600 (owner only): data/config/*.yaml can hold tokens, and
data/profile/ your profile. On Linux the containers run as PUID:PGID (default 1000:1000), which must own data/;
see host-setup.md. Docker Desktop on macOS maps ownership itself.
Two browser tabs (or a phone and the desktop) editing the same settings form or profile: the second save is refused (the file
was changed elsewhere) and the form shows the saved values, instead of overwriting the first. Every save of a
config file from the web also keeps the previous text in data/config/.backup/editor/ (the advanced editor’s
backups, ui.yaml: config_editor.backups_keep copies per file).
Versions and migrations (config_version)
Section titled “Versions and migrations (config_version)”- Each config can carry a top-level
config_version: <integer>; omitted = 1. - The code has a current version for each file (the
toof the last migration step insrc/lib/config/migrations/<file>/NNN-*.ts). - A file older than the code: it is migrated in memory before validation and works as usual; the doctor suggests running
pnpm config:migrate --writeto write the migration back (comments are kept, the original goes to.backup/). - A file newer than the code (the code was rolled back): an explicit error (
ConfigError, doctor shows a red failure), no guessing. Upgrade the code, or take the pre-migration file back fromdata/config/.backup/<timestamp>/. - Files in the defaults directory ship with the product and are always at the current version (checked by
config:check).
Writing a migration (for developers): in src/lib/config/migrations/<file name>/NNN-slug.ts, export a { from, to, description?, migrate(doc) } (to = from + 1; migrate edits the yaml Document in place, keeps comments, and does no I/O), register it in order in CONFIG_MIGRATIONS in migrations/index.ts, and change the default-directory copy of the file to the new format with the new config_version. The contract is in src/lib/config/migrations/types.ts.
Recheck of watched jobs (pipeline.yaml + ui.yaml)
Section titled “Recheck of watched jobs (pipeline.yaml + ui.yaml)”The maintenance step rechecks the posting pages of starred and followed jobs (see usage.md “Closed jobs”). Its keys live in two files; both blocks may be missing (older files): the defaults below apply.
| File | Key | Default | Meaning |
|---|---|---|---|
pipeline.yaml (shipped) |
lifecycle.watch_recheck.interval_hours |
72 | A watched job is rechecked at most once in this many hours |
lifecycle.watch_recheck.max_per_cycle |
10 | Jobs rechecked per run | |
lifecycle.watch_recheck.per_host_min_interval_ms |
3000 | Least time between two requests to one site (robots.txt included) | |
lifecycle.watch_recheck.timeout_ms |
10000 | Per request | |
lifecycle.watch_recheck.max_body_bytes |
1048576 | Longest page read; the rest is not downloaded | |
lifecycle.watch_recheck.user_agent |
JobRadar/2 (self-hosted job tracker; link check) |
Sent with the recheck and the possibly-closed link check; its first word is the robots.txt product token | |
ui.yaml (user) |
job_checks.closed_recheck.enabled |
true |
Off: no recheck (the possibly-closed link check keeps running) |
job_checks.closed_recheck.extra_phrases |
[] |
Your phrases (regex, case-insensitive), added to the packs’ closed_posting.phrases. Only a safe subset is accepted: words, character classes, alternation (a|b), \b, optional parts ? (at most 8) and {n,m} up to 5, at most 200 characters; no *, +, {n,} or backreferences. pnpm config:check names an invalid or unsafe one by its path. They search at most the first 200,000 characters of a page’s visible text, within 250 ms per page (a page whose search runs out of time counts as not checked) |
The ui.yaml block is edited in the YAML or the advanced editor (no form). An older version rejects it: remove it before
rolling back.
Backup
Section titled “Backup”Every backup, the runner’s daily one (backup.config_archive, on by default) and scripts/ops/backup.sh, leaves a ….config.tgz (data/config + data/profile, without .backup/ and the sign-in key .web-session-secret) next to the database dump. The backup section of schedule.yaml also has pre_migrate (take a backup before a database migration), keep_system (how many safety backups of each kind to keep) and keep_old_databases (how many databases replaced by a restore to keep). .env and .auth/ are in no backup. See backup-restore.md, including “Restoring the configuration”.
Switching from a single directory
Section titled “Switching from a single directory”Older versions kept the live configuration in git as config/. When upgrading to layered configuration, step 4 of scripts/ops/deploy.sh sees that data/config/ is empty, and pnpm config:init copies all user files from the then-current config/ there (at that moment the defaults are the old live configuration, so the result equals pnpm config:init --from config). From then on the web app only writes data/config/, and config/ goes back to being the “product defaults” and no longer dirties the checkout. See sections 4 and 5 of deploy.md for details.
Synced from docs/ in Joevonlong/job-radar@2947f47. Job Radar is not affiliated with any job board. Code under AGPL-3.0-only.