---
title: Configuration
description: "enpilink's typed config layer — env > file > DB precedence, editability tiers (runtime / restart / read-only), the Configuration admin tab, reset-to-default, Dev/Prod presets, env-only masked secrets, and the config audit log."
---
enpilink has a single, typed configuration layer for the admin / control plane.
It resolves every setting through a clear precedence chain, classifies each
setting by how it can be edited (**runtime**, **restart**, or **read-only**),
keeps **secrets out of the database**, and **audit-logs** every change. The
Configuration tab groups settings into friendly functional sections and offers
**reset-to-default** and one-click **Dev/Prod presets**.
## Precedence: env > file > DB > default
Each setting is resolved from the first source that provides it:
1. **Environment variable** — highest priority. An env value always wins and
**locks** the setting (it can't be overridden in the UI).
2. **Config file** — `enpilink.config.json` in the project root.
3. **Database** — the runtime values written from the Configuration admin tab.
4. **Built-in default** — the schema's default, used when nothing else sets it.
This means an operator can pin any value via env or file and it becomes
read-only ("env-locked") in the admin UI, while everything else stays editable
at runtime.
The resolver is failure-tolerant: with no storage it returns defaults; if the
DB or a file value is bad, it falls back per-key rather than crashing a read. A
config read never throws.
The default storage engine is **`sqlite`** (a durable local `enpilink.db`) in
both dev and production, so changes you make from the Configuration tab during
`enpilink dev` — for example enabling analytics — **persist across restarts**
and are read back from the database on the next boot. Set
`ENPILINK_STORAGE=memory` for an ephemeral, file-less store (and `enpilink dev
--mock` always uses an ephemeral in-memory store). The `enpilink.db` file is
gitignored by the scaffold.
## Editability tiers
Every key is classified by how it can be edited, surfaced as `editable` on each
resolved setting:
- **`runtime`** — editable live in the Configuration tab; takes effect
immediately. These live in the DB (`analytics.*`, `retention.*`, `flags.*`,
`display.*`).
- **`restart`** — DB-editable, but the change only takes effect after a process
**restart**. These are the non-secret end-user-auth keys (`auth.enabled`,
`auth.issuer`, `auth.audience`, `auth.jwksUrl`, `auth.upstream.*`,
`auth.redirectUris`, `auth.branding.*`). The running process keeps using the
value it booted with; the UI shows a **"requires restart"** badge and, once
you change one, a "takes effect after restart" note.
- **`readonly`** — environment-only, never web-editable. These are the `admin`
gate, the secrets (`adminAuthToken`, `auth.signingKey`, `auth.clientSecret`,
`agent.ingestToken`), and the **startup-only** keys `port`, `storage`, and
`dbPath`.
All tiers still honour env > file > DB precedence, so pinning a `runtime` or
`restart` key via env/file makes it **env-locked** (read-only in the UI).
**`port`, `storage`, and `dbPath` are deploy/env concerns — not shown in the
Configuration tab.** They are read at boot from the environment to bind the port
and *open* the database, so a value persisted to the DB could never be honoured
on the next boot (the storage engine + path must be known before the DB is
opened — a chicken-and-egg). Set them via `PORT` / `ENPILINK_STORAGE` /
`ENPILINK_DB_PATH` — see [Storage](/guides/storage) and
[Deploy](/quickstart/deploy). The config API still returns them (flagged
`hidden: true`) for introspection, but the dashboard hides them and the API
rejects any write to them (403).
| Key | Env var | Default | Tier | Group | Meaning |
|---|---|---|---|---|---|
| `analytics.enabled` | `ENPILINK_ANALYTICS` | `false` | runtime | Analytics | Capture analytics events |
| `analytics.sampleRate` | `ENPILINK_CFG_ANALYTICS_SAMPLE_RATE` | `1` | runtime | Analytics | Fraction of requests sampled `[0,1]` |
| `retention.events` | `ENPILINK_CFG_RETENTION_EVENTS` | `5000` | runtime | Retention | Max events retained |
| `retention.logs` | `ENPILINK_CFG_RETENTION_LOGS` | `5000` | runtime | Retention | Max logs retained |
| `flags.liveLogs` | `ENPILINK_CFG_FLAGS_LIVE_LOGS` | `true` | runtime | Features | Expose the live-log stream |
| `display.bucketMs` | `ENPILINK_CFG_DISPLAY_BUCKET_MS` | `60000` | runtime | Display | Dashboard time-bucket width (ms) |
| `agent.enabled` | `ENPILINK_AGENT` | `false` | runtime | Agent | Capture agent HTTP requests (fingerprint + detection) |
| `agent.sampleRate` | `ENPILINK_CFG_AGENT_SAMPLE_RATE` | `1` | runtime | Agent | Fraction of requests captured `[0,1]` |
| `agent.retentionDays` | `ENPILINK_CFG_AGENT_RETENTION_DAYS` | `30` | runtime | Agent | Days to keep captured requests (`0` = forever); enforced by the pruner |
| `agent.verifyIpRanges` | `ENPILINK_CFG_AGENT_VERIFY_IP_RANGES` | `false` | runtime | Agent | Optional IP-range verification confidence tier |
| `agent.serve` | `ENPILINK_CFG_AGENT_SERVE` | `false` | runtime | Agent | Serve the self-sufficient representation to eligible chat fetchers |
| `agent.site.title` | `ENPILINK_CFG_AGENT_SITE_TITLE` | `""` | runtime | Agent | Site title in the representation (overrides `describeForAgents`) |
| `agent.site.description` | `ENPILINK_CFG_AGENT_SITE_DESCRIPTION` | `""` | runtime | Agent | Site description in the representation |
| `agent.spa` | `ENPILINK_CFG_AGENT_SPA` | `false` | runtime | Agent | Replace a client-rendered SPA's `200` shell with the representation |
| `agent.reencode` | `ENPILINK_CFG_AGENT_REENCODE` | `false` | runtime | Agent | Re-encode a real route's HTML response to markdown |
| `agent.getTransport` | `ENPILINK_CFG_AGENT_GET_TRANSPORT` | `false` | runtime | Agent | Mount the plain-GET transport (off by default; [unproven-in-use](/api-reference/get-transport)) |
| `agent.getRateLimit` | `ENPILINK_CFG_AGENT_GET_RATE_LIMIT` | `60` | runtime | Agent | GET transport default rate (requests/min per IP+tool) |
| `agent.getRateBurst` | `ENPILINK_CFG_AGENT_GET_RATE_BURST` | `10` | runtime | Agent | GET transport default burst (per IP+tool) |
| `auth.enabled` | `ENPILINK_AUTH` | `false` | restart | Security | Enable end-user OAuth on `/mcp` (restart to apply) |
| `auth.issuer` | `ENPILINK_AUTH_ISSUER` | — | restart | Security | Authorization server issuer URL |
| `auth.audience` | `ENPILINK_AUTH_AUDIENCE` | — | restart | Security | RFC 8707 audience (`aud`) tokens must carry |
| `auth.jwksUrl` | `ENPILINK_AUTH_JWKS_URL` | — | restart | Security | JWKS URL for token-signature verification |
| `auth.upstream.clientId` | `ENPILINK_AUTH_UPSTREAM_CLIENT_ID` | — | restart | Security | Upstream IdP OAuth client id (co-hosted AS) |
| `auth.upstream.authorizationUrl` | `ENPILINK_AUTH_UPSTREAM_AUTHORIZATION_URL` | — | restart | Security | Upstream authorize endpoint |
| `auth.upstream.tokenUrl` | `ENPILINK_AUTH_UPSTREAM_TOKEN_URL` | — | restart | Security | Upstream token endpoint |
| `auth.upstream.revocationUrl` | `ENPILINK_AUTH_UPSTREAM_REVOCATION_URL` | — | restart | Security | Optional upstream revocation endpoint |
| `auth.upstream.scopes` | `ENPILINK_AUTH_UPSTREAM_SCOPES` | — | restart | Security | Space-delimited scopes requested upstream |
| `auth.redirectUris` | `ENPILINK_AUTH_REDIRECT_URIS` | — | restart | Security | Host callback URLs allowed as redirect targets |
| `auth.branding.appName` | `ENPILINK_AUTH_BRANDING_APP_NAME` | — | restart | Security | Login-page app name |
| `auth.branding.logoUrl` | `ENPILINK_AUTH_BRANDING_LOGO_URL` | — | restart | Security | Login-page logo image URL |
| `auth.branding.accentColor` | `ENPILINK_AUTH_BRANDING_ACCENT_COLOR` | — | restart | Security | Login-page accent color (CSS hex) |
| `auth.branding.tagline` | `ENPILINK_AUTH_BRANDING_TAGLINE` | — | restart | Security | Login-page tagline |
| `admin` | `ENPILINK_ADMIN` | `false` | readonly | Security | Enable the prod admin plane |
| `adminAuthToken` | `ENPILINK_ADMIN_TOKEN` | — | readonly | Security | **Secret** — admin bearer token |
| `auth.signingKey` | `ENPILINK_AUTH_SIGNING_KEY` | — | readonly | Security | **Secret** — federating AS token signing key (enables guest mode) |
| `auth.clientSecret` | `ENPILINK_AUTH_CLIENT_SECRET` | — | readonly | Security | **Secret** — upstream IdP client secret |
| `agent.ingestToken` | `ENPILINK_AGENT_INGEST_TOKEN` | — | readonly | Agent | **Secret** — guards the [beacon ingest sink](/api-reference/next-middleware); when unset the endpoint is disabled (404) |
Runtime keys use the `ENPILINK_CFG_*` convention so pinning one via env never
collides with the bootstrap vars.
The `agent.*` runtime keys are documented in full — what each serves, its topology
and the honest caveats — in [Agent analytics](/guides/agent-analytics). They live
in the DB and toggle live from the Configuration tab, except the secret
`agent.ingestToken`, which is env-only like the other secrets below.
The startup-only keys below are **env-only and hidden from the Configuration
tab** (the DB can never honour them — see the note above). They're configured at
deploy time:
| Key | Env var | Default | Where to set it |
|---|---|---|---|
| `port` | `PORT` (or `__PORT`) | `3000` | [Deploy](/quickstart/deploy) — the hosting platform usually assigns it |
| `storage` | `ENPILINK_STORAGE` | `sqlite` | [Storage](/guides/storage) — `sqlite` (default, durable `enpilink.db`), `memory`, `postgres`, or custom |
| `dbPath` | `ENPILINK_DB_PATH` | `./enpilink.db` | [Storage](/guides/storage) — SQLite database file path |
## Per-key metadata
Each resolved setting carries presentation metadata so the UI can render it
clearly: a human **`label`**, a plain-language **`description`**, a functional
**`group`**, an optional **`unit`** (e.g. `ms`, `events`, `0–1 ratio`), the
schema **`default`**, the **`editable`** tier, plus runtime flags: **`source`**
(`env`/`file`/`db`/`default`), **`secret`**, **`envLocked`**, **`modified`**
(the effective value is a DB override differing from the default), and
**`restartRequired`** (a `restart` key whose persisted value differs from the
value the process booted with — a pending change awaiting a restart).
## The Configuration admin tab
The admin UI's **Configuration** tab groups settings into friendly sections
(Analytics, Retention, Features, Display, Security) rather than a raw
bootstrap/runtime split. Each row shows the human label, the raw key, the
description + unit hint, a source badge, and a **"modified"** badge when changed.
The startup-only `port`/`storage`/`dbPath` keys are **not shown** (they're
deploy/env concerns — see the note above).
- **Runtime keys** — booleans render as switches, numbers/strings as inputs
(saved on blur/Enter); each change is written to the DB and takes effect
immediately.
- **Restart keys** — editable inputs with a **"requires restart"** badge; once
changed they show a "takes effect after a restart" note.
- **Read-only keys** — secrets and `admin`, plus any env/file-pinned key, render
read-only with a source badge and a "set via ``" hint.
- **Reset** — each modified key has a reset affordance, and a **"Reset all to
defaults"** action clears every DB override (with a confirm).
- **Presets** — one-click **Dev** / **Prod** buttons (see below).
- **Change history** — the config audit trail (see below).
Writes are validated against the schema and rejected with a clear status:
unknown key → 404, secret / `admin` / startup-only (`port`/`storage`/`dbPath`)
→ 403, env-locked → 409, invalid value → 400, no storage → 409.
## Reset to default
`DELETE /__enpilink/config/:key` clears a key's DB override and audit-logs the
reset. After clearing, resolution falls back to file/env/default. The same
guardrails as a write apply (only `runtime` and non-env-locked `restart` keys;
secrets, `admin`, the startup-only `port`/`storage`/`dbPath` keys, unknown, and
env-locked keys are rejected). The storage layer backs this with
`clearConfig(key, actor?)`.
## Presets
Two built-in presets bundle runtime-key changes into one action. Presets **only
touch runtime keys** — never secrets, never `admin`, never restart-tier or
startup-only bootstrap keys — and **skip any key that is env/file-pinned**,
reporting which were applied vs skipped.
| Preset | What it sets |
|---|---|
| **Dev** | analytics on, full sampling (`1`), retention `5000`/`5000`, live logs **on**, 1-minute chart buckets — maximum visibility for local development. |
| **Prod** | analytics on but sampled to **`0.25`**, retention `20000`/`20000`, live logs **off**, 5-minute chart buckets — lower overhead and more history for busy servers. |
- `GET /__enpilink/config/presets` — list presets and the values each would set.
- `POST /__enpilink/config/preset/:name` — apply a preset (`dev` / `prod`);
returns a summary of `applied` vs `skipped` keys.
## Secrets are env-only and masked
Secret keys (`adminAuthToken`, `auth.signingKey`, `auth.clientSecret`,
`agent.ingestToken`) are **never persisted to the database and never returned in
plaintext** by any API. In the UI they render as masked (`••••••••` when set,
"(not set)" otherwise) with a secret badge and are read-only — the only way to set
a secret is its environment variable (`ENPILINK_ADMIN_TOKEN`,
`ENPILINK_AUTH_SIGNING_KEY`, `ENPILINK_AUTH_CLIENT_SECRET`, or
`ENPILINK_AGENT_INGEST_TOKEN`). The config API returns `403` on any attempt to
write a secret key. The DB layer never reads or stores them.
## The config audit log
Every config write appends a `config_audit` row capturing the timestamp, the
key, the **old and new values**, and the **actor** who made the change (from the
`x-enpilink-actor` request header, defaulting to `dev`). The Configuration tab's
change-history list surfaces these most-recent-first so you can see who changed
what and when.
## Related
- [Storage](/guides/storage) — the adapters that back config + audit
- [Observability](/guides/observability) — what `analytics.*` and `flags.liveLogs` control
- [Agent analytics](/guides/agent-analytics) — what the `agent.*` keys control
- [Admin](/guides/admin) — the bootstrap admin token and prod auth