Example configuration

This is the annotated example that ships with the product: every block that exists, with the reasoning beside each setting. searchcode config example prints the same file for the build you are running, and it is the best place to start a new searchcode.yaml.

Copy the blocks you need and delete the rest. Every setting has a default, so an omitted block keeps the defaults. For a short configuration that starts an instance, see the minimal configuration. For every field, its type, default and whether it reloads, see Configuration reference.

Run searchcode config validate -f searchcode.yaml on the result. It reports every problem at once and names the field of each.

# yaml-language-server: $schema=https://searchcode.com/schema/searchcode.schema.json
#
# searchcode.yaml — the annotated example.
#
# THIS FILE IS SAFE TO COMMIT TO GIT, and that is a design rule rather than a
# habit — no secret can appear inline anywhere below, because there is no field to
# put one in. Every credential is a *File or *Env reference, so a support bundle
# can carry this file verbatim and a GitOps repository can hold it.
#
# Check a file before you deploy it:
#
#   searchcode config validate -f /etc/searchcode/searchcode.yaml
#
# It prints EVERY problem, not the first, and names the field of each. It exits
# non-zero on any of them, so it belongs in CI.
#
# Everything below is optional except instance.dataDir. The values shown are the
# shipped defaults unless the comment says otherwise; delete any block you do not
# need and it keeps its default.

# ---------------------------------------------------------------------------
# instance — where this instance keeps its state
# ---------------------------------------------------------------------------
instance:
  dataDir: /var/lib/searchcode     # clones, analysis store, caches. Created 0700 at startup.
                                   # REQUIRED. Changing it requires a restart.
  diskWarnThresholdPercent: 80     # warn on /api/v1/status and the admin page; never blocks serving

# ---------------------------------------------------------------------------
# server — transport-level timeouts ONLY
#
# Operation budgets (search, clone, per-file analysis) live with the features
# that own them, below. One timeout knob meaning seven things gets tuned for the
# wrong reason.
# ---------------------------------------------------------------------------
server:
  # Two listeners, and the split is a security boundary rather than a convenience.
  #
  # The UI goes behind your auth proxy in proxy mode. The API and MCP never do:
  # an agent presents a searchcode-issued bearer credential your proxy did not
  # issue and cannot validate, and the usual fix — a skip-auth route on the proxy
  # — is an authentication bypass waiting for someone to influence the path.
  listen: 0.0.0.0:8080             # API + MCP. NEVER behind an auth proxy.
  uiListen: 0.0.0.0:8081           # the human-facing UI. Behind the proxy in proxy mode.

  readHeaderTimeout: 10s           # slowloris defence — the one that actually matters
  readTimeout: 30s                 # whole request including body
  idleTimeout: 2m0s                # keep-alive reaping

  # writeTimeout is deliberately unset. A blanket write deadline breaks streaming
  # MCP and cuts off slow brute-force searches over large estates — both would
  # fail as a truncated response rather than as an error, which is the worst way
  # for a timeout to present. Per-request deadlines are applied in the handlers
  # instead, where the right budget is known.
  writeTimeout: 0

  maxHeaderBytes: 65536            # Go's 1MB default is generous
  shutdownTimeout: 30s             # how long a graceful drain may take. Not one of the
                                   # five transport timeouts: it budgets an operation.

# ---------------------------------------------------------------------------
# license
#
# No feature is licence-gated on any tier. Repository count and user count are
# the only meters, and this build never contacts the vendor to check either.
# ---------------------------------------------------------------------------
license:
  file: /etc/searchcode/license.key   # or the SEARCHCODE_LICENSE env var; either is fine

# ---------------------------------------------------------------------------
# logging
# ---------------------------------------------------------------------------
logging:
  level: info                      # debug | info | warn | error. Reloadable — no restart.

# ---------------------------------------------------------------------------
# audit
# ---------------------------------------------------------------------------
audit:
  output: stdout                   # stdout | stderr | file | syslog
                                   # stdout needs no writable path beyond dataDir
  retentionDays: 90                # 0 keeps everything for ever. Retention deletes whole
                                   # daily files, so a record lives until its whole day has
                                   # aged out and the window is up to a day longer than this
  logSearchQueries: true           # false still leaves usage counts working
  # syslogAddress: udp://10.0.0.1:514
                                   # `output: syslog` ONLY. network://address. Empty — the
                                   # default — uses the host's local syslog socket, which is
                                   # the one of the four values that reads as a mistake and is
                                   # not. A remote address here is one of the three things that
                                   # can open an outbound connection from this process

# ---------------------------------------------------------------------------
# auth — how HUMANS authenticate. Exactly one mode, never combined.
#
# This is NOT mcp.auth.mode, which is a different setting with a different
# vocabulary. See the comment on that block.
# ---------------------------------------------------------------------------
auth:
  mode: proxy                      # proxy | local | open. Changing it requires a restart.

  # Required in proxy mode, all three. An instance that trusts identity headers
  # without a CIDR allowlist believes anything anybody sends it — and works
  # perfectly in a test. searchcode refuses to start rather than run like that.
  trustedProxyCIDRs: [10.0.0.0/8]
  sharedSecretFile: /run/secrets/proxy-shared-secret
  sharedSecretHeader: X-Searchcode-Proxy-Secret

  # auth.mode: local ONLY — built-in accounts, for deployments with no identity
  # provider and no auth proxy. Every key below is refused under any other mode
  # rather than silently ignored, because a break-glass admin that does nothing
  # is worse than one that does not exist.
  #
  # Accounts are created with `searchcode user add --admin`, never by this file.
  # There is no password hash setting and no default password: a fresh instance
  # with no `user add` and no initialAdminPassword reference has no account that
  # can sign in. There is also no SMTP block and no reset flow — this product
  # sends no mail, and password reset is `searchcode user passwd`, audited.
  local:
    argon2:
      memoryKiB: 19456               # OWASP Argon2id recommendation: m=19456, t=2, p=1.
      time: 2                        # Raise memoryKiB first. 19456/2, 12288/3, 9216/4
      parallelism: 1                 # and 7168/5 are treated as equivalent; 7168 is the floor.
    loginRatePerMinute: 10           # Per source address, applied BEFORE the hash runs.

    # Unattended installs only, and commented out because this example runs in
    # proxy mode: setting them under any mode but local is a startup refusal
    # naming both modes, not a silently ignored key. The password is read from a
    # file or an environment variable, never written here, and a change is
    # forced on first sign-in because the value persists in the manifest.
    #
    # initialAdminUsername: admin
    # initialAdminPasswordFile: /run/secrets/initial-admin-password
    # initialAdminPasswordEnv: SEARCHCODE_INITIAL_ADMIN_PASSWORD
                                   # one or the other, never both

  # The browser session cookie. One setting, and it is a SECURITY DOWNGRADE.
  #
  # The cookie is __Host- prefixed and Secure, which a browser enforces by
  # throwing it away entirely over plain http on anything but localhost. So a
  # UI exposed on a LAN address over plain http authenticates correctly, sets
  # the cookie correctly, and bounces the person straight back to the sign-in
  # form for ever — which is what happened to a real install The
  # instance now detects that and explains it, on the page and in the log.
  #
  # The supported answers, in order:
  #   1. Evaluate on http://localhost:8081. localhost IS a secure context, so
  #      this needs no configuration at all.
  #   2. Put TLS in front of the UI listener, and set mcp.oauth.uiUrl to the
  #      https URL browsers use. That also silences the startup warning.
  #   3. Only for a throwaway trial on a private network, the setting below.
  session:
    # WHAT IT COSTS: the session id travels in CLEAR TEXT on every request.
    # Anybody on the network path — any other machine on that LAN, anything
    # spanning the switch, any hop between — can take a session and read every
    # repository its owner can read. There is no partial version of this.
    #
    # It drops the __Host- prefix and the Secure attribute, and RENAMES the
    # cookie to searchcode_session_insecure, so that turning it back off cannot
    # leave a session id that arrived in clear text being accepted as though it
    # had arrived over TLS.
    #
    # An instance running it says so on stderr at every start, in `searchcode
    # doctor` and in `searchcode config validate` — and on neither status
    # endpoint, which report how this instance authenticates and never how
    # well. There is no way to make it quiet, and that is deliberate.
    # Restart-only.
    insecureCookiesOverPlainHttp: false   # leave it false anywhere that outlives a trial

# ---------------------------------------------------------------------------
# authz — allow-only, no deny rules, order-independent
#
# A principal gets the union of every binding that matches them. There is
# deliberately no deny rule and no ordering: both make "who can see this?"
# answerable only by simulating the whole file in your head.
# ---------------------------------------------------------------------------
authz:
  defaultAccess: deny              # fail closed
  anonymousRole: viewer            # auth.mode: open ONLY. Defaults to viewer, never admin.
  adminGroups:   [platform-admins] # instance-scoped, not repo-scoped
  auditorGroups: [security-eng]    # instance-scoped: view_audit_log, view_analytics

  # People by email address, for an identity provider that asserts no groups
  # (Google Workspace) or for a break-glass second admin. auth.mode: proxy only;
  # matched against X-Forwarded-Email, ASCII case-insensitive, never a wildcard.
  # An address that is later reassigned keeps what was granted to it by name, so
  # removing the entry is part of offboarding.
  adminUsers:   [ops.lead@example.com]     # exactly what adminGroups grants
  auditorUsers: [compliance@example.com]   # exactly what auditorGroups grants

  roles:
    # Custom roles draw from the closed repo-scoped set: search, view_file,
    # view_history, view_blame, use_api, use_mcp. view_audit_log and
    # view_analytics are instance-scoped and come from auditorGroups instead.
    releng: [search, view_file, view_history, use_api]

  bindings:
    - name: "Engineers read platform and open source"
      groups:     ["engineering"]  # "*" means any authenticated principal
      repoGroups: ["platform", "open-source"]   # globs allowed on this side only
      role: viewer

    - name: "Payments engagement team"
      users:                       # groups, users, or both; a match on either grants
        - a.person@example.com
        - b.person@example.com
      repoGroups: ["payments"]
      role: viewer

# ---------------------------------------------------------------------------
# repositoryGroups — the unit authorisation is granted over
#
# Membership is always explicit, never globbed: a repository names its groups.
# ---------------------------------------------------------------------------
repositoryGroups:
  - name: payments
    description: "Payments platform — restricted"
  - name: platform
  - name: open-source

# ---------------------------------------------------------------------------
# credentials — references only; no secret is ever inline
# ---------------------------------------------------------------------------
credentials:
  - name: github-ssh
    type: ssh                      # ssh | https
    privateKeyFile: /run/secrets/id_ed25519
    knownHostsFile: /etc/searchcode/known_hosts

  - name: gitlab-https
    type: https
    username: searchcode-bot       # not a secret, so it is inline
    passwordEnv: GITLAB_TOKEN      # https only: the variable holding the password or token
    # passwordFile: /run/secrets/gitlab-token
                                   # the same value out of a file instead. One or the other

# ---------------------------------------------------------------------------
# repositories — usually lives in conf.d/, generated by `searchcode discover`
#
# See the include block at the bottom of this file: keeping the generated
# repository list in conf.d/ is what stops a twenty-line policy change drowning
# in a two-thousand-line regenerated diff.
# ---------------------------------------------------------------------------
repositories:
  - url: https://github.com/acme/billing
    groups: [payments]             # at least one, or config validate fails.
                                   # An unclassified repository is an error, not a warning.
    labels: [tier1, pci]           # categorisation and facets only — a label grants nothing
    credential: github-ssh
    fullHistory: true              # default. false costs blame, history and coupling.
    # depth: 1                     # only with fullHistory: false — the escape hatch for a
                                   # fifteen-year monorepo with binary assets in its history
    # refreshInterval: 15m0s       # overrides indexing.refreshInterval for this repository
    # exclude: ["!vendor"]         # layered over indexing.exclude AND the built-in set;
                                   # a leading ! un-excludes, for the one repository whose
                                   # vendored tree IS the interesting code

# ---------------------------------------------------------------------------
# directorySources — plain directories, the sharpest security boundary here
# ---------------------------------------------------------------------------
directorySources:
  allowedRoots: [/mnt/depot]       # hard allowlist; required if any source is declared.
                                   # Symlinks are resolved BEFORE the check, containment is
                                   # component-wise, and every path here is absolute.
  denyPaths: [/mnt/depot/.creds]   # always refused, even inside an allowedRoots entry.
                                   # instance.dataDir, this file's own directory and
                                   # /run/secrets are denied implicitly whether listed or not.
  sources:
    - path: /mnt/depot/cobol       # must resolve inside an allowedRoots entry
      groups: [platform]
      unitDetection: subdirectories  # single | subdirectories | marker. Default marker.
                                     # This decides whether a mount of 2,000 projects is one
                                     # repository or 2,000, so it is a metering input:
                                     # `single` is refused where markers are found below the
                                     # source root, because it would hide the other 1,999.
      # markerFiles: [BUILD]           # layered over .git, go.mod, pom.xml, package.json, Cargo.toml
      maxDepth: 4                    # bounds the marker search, so a deep tree does not turn
                                     # unit detection into a full walk
      changeDetection: mtime         # mtime | hash | always. Use hash when the export tooling
                                     # rewrites every mtime on every run, which mainframe and
                                     # some Perforce export scripts do — it costs a full content
                                     # read per scan. Getting this wrong presents as "the index
                                     # is stale" or "the box is pinned at 100% CPU".
      followSymlinks: false          # a symlink escaping a root is refused either way
      # exclude: ["**/*.iso"]          # layered over indexing.exclude and the built-in set,
                                     # exactly as a repository's is

# ---------------------------------------------------------------------------
# discovery — read by the CLI only. The server never calls a code host.
# ---------------------------------------------------------------------------
discovery:
  provider: github                 # github | gitlab
  baseUrl: https://github.example.com/api/v3   # GHES/self-managed; omit for the public host

# ---------------------------------------------------------------------------
# connections — the ONE block that can make this process reach a code host.
#
# Absent, this process connects to nothing at all. Present, it connects to the
# hosts named here and to nothing else: one host per connection, declared, with
# a cross-host redirect refused rather than followed, and the vendor's own
# domains refused in every mode and on every tier.
#
# Enumeration itself arrives with the GitHub and GitLab providers; until then a
# connection declared here reports a gap saying so rather than an empty estate.
# See docs/operations/connections.md.
# ---------------------------------------------------------------------------
connections:
  keyFile: /run/secrets/connections.key
                                   # required ONLY to create a connection from the UI or from
                                   # `searchcode setup`, which seal the pasted token with it
                                   # before storing it in subjects.db. Generate one with
                                   # `openssl rand -base64 32`. Use keyFile OR keyEnv, never
                                   # both. With neither, the connect screen renders a refusal
                                   # naming these two settings instead of a form that would
                                   # fail on submit — and connections declared below under
                                   # `sources` keep working, because their token is already a
                                   # reference and was never sealed. Changing it needs a
                                   # restart — swapping it under a running instance strands
                                   # every token already sealed with the old one
  # keyEnv: SEARCHCODE_CONNECTIONS_KEY
                                   # the same key out of an environment variable instead, for
                                   # a deployment whose secrets arrive that way. Use keyFile
                                   # OR keyEnv, never both
  egress: any                      # any | internal-only. internal-only REFUSES a connection
                                   # naming github.com, gitlab.com and the other public hosts,
                                   # at config load — the switch a firewalled install points a
                                   # security review at instead of a policy. Needs a restart.
  sources:
    - name: acme-github            # identity: state hangs off it, so renaming is a delete plus
                                   # a create rather than a rename
      type: github                 # github | gitlab
      url: https://github.com      # ONE host. A GitHub Enterprise Server or self-managed GitLab
                                   # host goes here instead, and nothing leaves your network
      token:                       # a reference, NEVER a value. Metadata-only scope LISTS,
                                   # and cannot CLONE -- see `credential` below:
        env: ACME_GITHUB_TOKEN     # fine-grained PAT "Metadata: read", or GitLab read_api
        # file: /run/secrets/acme-github-token
                                   # the same reference out of a file instead. One or the
                                   # other; an inline literal is refused at load, and the
                                   # refusal never echoes the value
      include:                     # required — there is no "everything this token can see" mode
        orgs: [acme, acme-labs]    # GitHub organisations, or GitLab group paths (which nest)
        users: [ben]               # other accounts, or GitLab personal namespaces
        self: false                # the token's OWN account, including its private repositories.
                                   # Needs no name, so `self: true` alone is a whole connection
      credential: gitlab-https     # the credential a DISCOVERED repository is CLONED with, by
                                   # name, from `credentials:` above. A second reference and not
                                   # the token: that one LISTS repositories and a metadata-only
                                   # scope cannot clone one, so a private estate with nothing
                                   # here has every clone refused by the host. Omit for a public
                                   # organisation, which is what an anonymous clone is for; a
                                   # private repository discovered with nothing here is reported
                                   # as a gap on the connection rather than left to fail one
                                   # clone at a time
      exclude: ["*-archive"]       # globs over owner/name, applied last so they always win
      skipArchived: true
      skipForks: true
      groups: [engineering]        # repositoryGroups every discovered repository belongs to,
                                   # and no others. Listed, never a pattern: a rule mapping
                                   # "*-infra" onto a group would mean a RENAME at the code
                                   # host changed who can read a repository. Two audiences
                                   # means two connections; one repository out of its
                                   # connection's groups is an entry under `repositories:`
                                   # naming it, which wins. No groups here indexes an estate
                                   # visible to nobody — not an error, and said so on /admin
      syncInterval: 1h0m0s         # how often to re-enumerate

# ---------------------------------------------------------------------------
# indexing — the clone and refresh pool
# ---------------------------------------------------------------------------
indexing:
  cloneConcurrency: 4              # counting semaphore
  window: "22:00-06:00"            # optional; only *starts* new work inside it
  refreshInterval: 1h0m0s          # jittered per repository, so 2,000 repos do not fetch together
  maxRepoSizeBytes: 5368709120     # checked during clone, not after
  minFreeDiskBytes: 1073741824     # ingestion pauses below this and resumes on its own; serving is untouched
  retryBackoff: 1m0s               # after a failed clone or fetch; doubles per consecutive failure
  retryBackoffMax: 1h0m0s          # the ceiling, so an unreachable host is retried on a fixed cadence
  metricsPerRepo: true             # set false on very large estates (metric cardinality)
  exclude: ["examples/**"]         # ADDED TO the built-in set — node_modules, vendor, target,
                                   # dist, .venv, Pods and the rest are excluded whether or not
                                   # this key is here. "!node_modules" takes one back estate-wide.
                                   # Every exclusion is counted and reported, never silent: yours
                                   # under files_skipped reason="excluded", the built-in set's
                                   # under reason="excluded_default"

# ---------------------------------------------------------------------------
# analysis — deliberately separate from indexing
#
# Three worker pools with three resource profiles: cloning is network-bound,
# phase A is IO-bound, phase B is CPU-bound. One shared concurrency number would
# be wrong for at least two of them.
#
# Phase B is where your code is SCANNED FOR CREDENTIALS -- betterleaks, with a
# rule set vendored into this binary, making no network call of any kind. There
# is no setting for it here or anywhere else, which is the point: see the
# section titled "What scans your code, and whether it phones home" in
# AGENT-INSTALL.md for what runs, what is not scanned, and the two tests that
# hold the no-egress guarantee.
# ---------------------------------------------------------------------------
analysis:
  inventoryConcurrency: 8          # phase A (line counts, languages); outranks every phase B
  deepConcurrency: 4               # phase B (security, quality, technology)
  fullResyncInterval: 168h0m0s
  perFileTimeout: 10s              # watchdog; a file that exceeds it is reported as a gap
  maxEdgesPerRepo: 500000          # on breach: keep counts, drop edges, report a gap
  niceValue: 10
  skipMinified: true               # refuse a >5,000-byte line BEFORE parsing rather than
                                   # letting it burn the whole perFileTimeout. Counted and
                                   # reported as reason "minified", never silent.

  # The commit walk hotspots and change coupling are computed from. It runs
  # after each index, never per request: `git log` over deep history cannot be
  # afforded on the path of a query at estate scale.
  history:
    churnDays: 90                  # hotspot window; 0 ranks by complexity alone and says so.
                                   # A caller's own days parameter is capped at 365; this
                                   # default is not, because how far back an estate is worth
                                   # reading is yours to decide.
    maxAgeDays: 0                  # 0 walks all history, bounded by maxCommits alone
    maxCommits: 5000               # cap; the truncation is stated in the response, never silent
    timeout: 30s                   # overrun keeps what was read and says the answer is partial
    bulkCommitFiles: 50            # commits above this are excluded from coupling and the
                                   # exclusion is stated — one reformat commit otherwise
                                   # couples every file to every other
    minSupport: 2                  # a pair sharing one commit is a coincidence
    minConfidence: 0.3

# ---------------------------------------------------------------------------
# search
# ---------------------------------------------------------------------------
search:
  timeout: 30s                     # covers queue time PLUS execution, not execution alone
  concurrency: 4                   # a semaphore that serialises; no queue limit, no rejection

  # Ranking. Operator configuration only — there is no per-query override on the
  # API, the MCP tools or the UI, and an unknown parameter there is refused
  # rather than ignored. This block hot reloads, so a retune is a file edit and
  # NOT a restart; every value below has a neutral setting, so it is exactly
  # reversible.
  #
  # These defaults were measured against a pinned public corpus. Three of the
  # four candidates tried were REJECTED on the numbers, and the tables saying
  # which and why are in docs/ranking-benchmark.md. Your corpus is not that
  # corpus — run the benchmark before changing any of them.
  ranking:
    ranker: bm25                   # bm25 | tfidf | simple | structural
    declarationBoost: 1.6          # a file DECLARING a term beats one using it; 1 disables
    commentWeight: 1.0             # matches in comments; 1 disables. 0.4 measured WORSE here
    stringWeight: 1.0              # matches in string literals; 1 disables
    docPenalty: 0.0                # demote prose, scaled by term count; 0 disables
    minTermMatchRatio: 1.0         # 1 is AND. Below 1 changes WHAT MATCHES, not what ranks
    relaxFromTerms: 3              # the term count at which minTermMatchRatio starts to apply

  # How declarations are resolved. `parsed` serves the outline and
  # jump-to-definition from what a tree-sitter grammar found during the analysis
  # pass — `exact`, with a real kind — and falls back to the line-start heuristic
  # for any language this build carries no grammar for. Ten ship: C, C#, C++, Go,
  # Java, JavaScript, Python, Rust, Swift, TypeScript.
  #
  # It governs the QUERY only. The analysis pass parses either way, so switching
  # to `heuristic` and back is a reload rather than a re-analysis of the estate —
  # which is what makes it a usable way back from a grammar defect.
  symbols:
    backend: parsed                # parsed | heuristic
    crossRepoCeiling: 1200         # how many candidate FILES an ESTATE-WIDE definitions or
                                   # references lookup answers before it DECLINES and says so,
                                   # with the count and this ceiling. It bounds the ANSWER and
                                   # not the search: a name matched in four thousand files
                                   # cannot be resolved to one answer by matching it, and
                                   # fifty rows out of four thousand look like an answer
                                   # without being one. Raising it will NOT make navigation
                                   # faster — the latency is the search, and search.index is
                                   # the knob for that. There is no unlimited.

  # The per-repository candidate index. A pure SPEED substitution and never a
  # correctness one: it narrows, and cs still opens and verifies every candidate
  # it returns, so a defect in here presents as slowness rather than as a wrong
  # answer.
  #
  # A repository below minFiles, or whose index is missing or still building, is
  # brute forced. That is a permanent steady state rather than a migration
  # window at all — an unindexed repository is normal, is not a gap, and is not
  # unhealthy. Nothing is written to disk, so an interrupted build leaves
  # nothing behind and there is no format, no version and no repair step.
  index:
    backend: caisson               # cs | caisson. cs brute forces everything. caisson consults
                                   # the index where one exists. Reloadable, and switching it
                                   # off KEEPS what was already built, so reverting a revert
                                   # does not cost a re-warm.
    minFiles: 1                    # searchable files a repository needs before it gets an
                                   # index at all. One means every repository holding anything
                                   # gets one. The per-repository argument for a high threshold
                                   # is sound and its AGGREGATE is not: brute force costs about
                                   # 2.5ms per repository before it matches anything, so a few
                                   # hundred small repositories cost most of a second to SKIP,
                                   # and they are also the cheapest ones to index. On a measured
                                   # 358-repository estate a query matching one file went from
                                   # 887ms to 5ms. RAM is the reason to raise it: 32KB per
                                   # repository whatever its size, 24.7MB for that estate.
                                   # Re-evaluated on every refresh AND on every config reload
    bloomSize: 8192                # 64-bit words per block of 64 documents, which fixes memory
                                   # at bloomSize/8 bytes per document — 1024 at this default.
                                   # THIS is the number that governs an estate's RAM. Changing
                                   # it rebuilds every index. Raised from 4096 on measurement:
                                   # false positives come almost entirely from the ~1% of files
                                   # whose filter is over 90% full, and doubling empties that
                                   # tail — an absent literal went from 72 candidate reads to 9,
                                   # and a two-word query whose words never co-occur from 272 to
                                   # 33. Costs 24.7MB extra on a 358-repository estate and no
                                   # latency. Lower it only if RAM is the binding constraint;
                                   # 16384 was measured and declined. docs/verification/bloom-sweep.md
    buildConcurrency: 2            # repositories indexed at once in the background.
                                   # Deliberately below search.concurrency: warming must never
                                   # compete with serving, and the only cost of warming slowly
                                   # is that it finishes later

# ---------------------------------------------------------------------------
# api
# ---------------------------------------------------------------------------
api:
  # A bare hostname (raw.example.com) or a URL (https://raw.example.com); both
  # are accepted, and the port is ignored when a request's Host is checked
  # against it. The UI's "view raw" links NAME this origin once it is set — with
  # the scheme you wrote, or with the scheme of the page the reader is on if you
  # wrote none — because a relative link resolves to the UI origin, which is the
  # one origin raw content is deliberately not served from.
  rawContentOrigin: ""             # empty by default; set for a separate raw-content origin

  prettyJson: true                 # indent JSON RESPONSE BODIES, so a person running curl on a
                                   # locked-down box with no jq can read one. HTTP RESPONSES
                                   # ONLY. Application logs and audit records stay one object
                                   # per line whatever this says, which is a contract with
                                   # every line-oriented consumer. MCP tool results are
                                   # unaffected either way

  # Per-(user, client) agent credentials. What bounds one is its issuer's groups
  # — frozen at issue — and its TTL. There is deliberately no `scope` field: the
  # MCP tools are thin wrappers over these same endpoints, so a key restricted
  # to one surface could do everything through the other.
  apiKeys:
    ttl:                           # min <= default <= max
      min: 1h0m0s
      default: 720h0m0s            # 30 days
      max: 2160h0m0s               # 90 days, and deliberately low. A static key survives an
                                   # identity-provider disable until it is revoked or expires,
                                   # so a high ceiling makes the manual offboarding step
                                   # load-bearing. Raise it deliberately
    requireName: true              # refuse a key created without a client name. An unnamed key
                                   # is unauditable, and the name is what lets the audit log
                                   # say WHICH agent acted
    allowNoExpiry: false           # a non-expiring credential is a finding in any audit
    rateLimit:
      requestsPerMinute: 5000      # per credential. 0 is unlimited. This number was measured
                                   # rather than picked - one legitimate agent session costs
                                   # ~900 requests in a minute, so a lower ceiling stalls the
                                   # work the product exists for The anomaly
                                   # detector below is what catches a compromised key
      anomalyMultiplier: 10        # alert when a credential exceeds this multiple of its OWN
                                   # rolling baseline. A key that suddenly enumerates the whole
                                   # estate looks nothing like itself, where a global threshold
                                   # has to be set for the busiest key and never fires for the
                                   # rest. 0 disables it
      window: 1m0s                 # accounting window, and the anomaly detector's sampling
                                   # interval. 1m is what makes requestsPerMinute mean what it
                                   # says

# ---------------------------------------------------------------------------
# mcp — the agent listener
#
# mcp.auth.mode is NOT auth.mode. This one decides which credential TYPES the
# agent listener accepts; the top-level one decides how a HUMAN proves who they
# are. Each rejects the other's vocabulary, so a value in the wrong block is an
# error rather than a config whose meaning depends on indentation.
# ---------------------------------------------------------------------------
mcp:
  auth:
    mode: both                     # oauth | apikey | both
    tokenTtl: 8h0m0s               # how long an OAuth access token minted for an MCP client is
                                   # valid. A refresh never passes the sign-in proxy, so in
                                   # proxy mode it is refused once the proxy has not vouched
                                   # for the person in a browser for seven days.
                                   # Refresh tokens survive a restart; access tokens do not

  # The two ceilings, and the asymmetry between them is the design.
  maxBytesPerCall: 100000          # a HARD ceiling, and the one that actually protects. It
                                   # bounds the WHOLE result as it is serialised — the text
                                   # block and the typed structuredContent mirror together,
                                   # not the text alone. A findings call once returned
                                   # 72 MB against this number.
                                   # Truncation is stated in the text the model reads, never
                                   # only in a structured field a client may drop.
                                   # When it bites, the MIRROR goes first: it repeats the
                                   # answer the text already carries, so dropping it loses
                                   # nothing, and it is replaced by a short document saying so
                                   # rather than byte-truncated into one that will not parse.
                                   # Two consequences worth knowing before you tune this. The
                                   # two halves are the same answer twice, so a response much
                                   # past half of this ceiling ships without its mirror. And
                                   # the answer itself is only clipped after that has not been
                                   # enough
  maxResultsPerCall: 25            # a DEFAULT a caller raises, and not a cap — bounded at the
                                   # top by the API's own hard maximum of 1000 rows a call,
                                   # which an MCP call inherits because a tool call IS the REST
                                   # call. A LOW result limit would make this worse than cs at
                                   # the discovery task the product exists for; the maximum
                                   # exists because max_results with nothing behind it answered
                                   # one GET with 87.9 MB. A request above it is
                                   # CLAMPED rather than refused, and the response carries the
                                   # true total plus — when the clamp actually withheld rows —
                                   # a complete_results gap naming what you asked for, what you
                                   # got and the offset that reaches the rest. It is not
                                   # configurable, being a payload bound on the shipped API
                                   # rather than a per-instance policy

  serverName: searchcode-private   # the KEY the /account page renders its ready-to-paste client
                                   # configuration under, and the name in the `claude mcp add`
                                   # line beside it. It is a name LOCAL TO THE READER'S CLIENT
                                   # FILE and must be unique within it: JSON has no merge, so
                                   # pasting over an entry that already carries this name
                                   # replaces it with no warning. The default deliberately does
                                   # not collide with `searchcode`, which is what
                                   # searchcode.com publishes for the HOSTED product — an
                                   # evaluator who tried that first had their working
                                   # integration deleted by the block this page told them to
                                   # paste. Set it to whatever you call this instance
                                   # internally. Letters, digits, dot, dash and underscore, up
                                   # to 64 characters. Restart-only

  # Only needed when the addresses an MCP client sees differ from the ones this
  # process binds, which is every deployment behind a proxy or an ingress. Both
  # default to the address the request arrived on — right for a direct
  # connection and wrong behind anything that rewrites a host. The scheme is
  # read from the connection and NEVER from X-Forwarded-Proto, which is why
  # these exist at all.
  #
  # Two values because they are two listeners: the protected resource and the
  # token endpoint are on the API and MCP listener, and /authorize is on the UI
  # listener, where the browser session lives.
  #
  # uiUrl doubles as the declaration that a browser reaches the UI over https,
  # which is what silences the plain-http session-cookie warning (see
  # auth.session above).
  oauth:
    publicUrl: ""                  # e.g. https://searchcode.example.com — becomes the OAuth
                                   # issuer and the protected-resource identifier, and a
                                   # presented token is checked against it
    uiUrl: ""                      # e.g. https://searchcode.example.com — where a BROWSER
                                   # reaches the UI listener. Defaults to publicUrl

# ---------------------------------------------------------------------------
# serviceAccounts — the only place a non-human credential is declared
# ---------------------------------------------------------------------------
serviceAccounts:
  - name: platform-dashboard
    groups: [platform]             # REPOSITORY groups, named directly. NOT identity groups:
                                   # every other groups: in this file is an identity group
                                   # matched through authz.bindings, and this one is not.
                                   # Read LIVE from this file on every request,
                                   # unlike a human key's frozen groups, so revoking here
                                   # is immediate
    role: viewer                   # and THIS is the identity concept, which is why the
                                   # two are easy to confuse
    tokenFile: /run/secrets/searchcode-dashboard-token   # a reference, never a value
    # tokenEnv: SEARCHCODE_DASHBOARD_TOKEN   # the same token out of a variable instead
    # There is no ttl: and no rateLimit: here. Both were accepted, validated and
    # documented until 2026-09-10 and read by nothing, so both were
    # removed rather than left inert -- and a file that still sets one is refused
    # at startup with the reason. Nothing issues a session for a service account,
    # so ttl had no mechanism behind it; the ceiling in force is the instance-wide
    # api.apiKeys.rateLimit, which every credential is accounted against.

# ---------------------------------------------------------------------------
# advisories — the offline advisory data
#
# There is no URL here and there will not be one. The database ships INSIDE the
# binary and is never fetched: a newer one arrives by pulling a newer image, or
# by side-loading a signed bundle from a path this block names. That is what
# keeps "this process makes no outbound connection unless you configure one"
# true, and an automatic refresh would be a fourth thing that can open one — the
# only one an operator never asked for and could not turn off.
#
# A version this database cannot resolve is reported as UNKNOWN and never as
# "not vulnerable": an empty result that actually means "we could not determine
# the version" tells somebody they are safe when nobody has checked.
# ---------------------------------------------------------------------------
# WHO IS DOING THE WORK.
#
# The default names NOBODY. What ships unconditionally is the shape of the
# distribution — how many distinct people the work on a file is spread across,
# and what share the largest took — because "this file improved across nine
# contributors" and "across one" are different facts about the CODE and the risk
# it carries. The subject is always the file.
analytics:
  concentrationFloor: 3            # how many distinct contributors a file needs before the exact
                                   # count and the top share are reported, rather than the band
                                   # "few". 0 uses this default.
                                   #
                                   # SET IT TO YOUR TEAM SIZE'S SENSE OF ANONYMOUS. "One
                                   # contributor" identifies somebody in a team of four whether or
                                   # not a name is printed, and there is no value this product can
                                   # pick for you — which is why it is a setting and not a
                                   # constant. Below the floor you still get "few": an absent
                                   # answer is indistinguishable from an unmeasured one, and
                                   # silence would read as "nothing to worry about".
  attributeAuthors: false          # report WHICH PEOPLE did the work, not just how concentrated
                                   # it was. OFF, and turning it on is a decision only you can
                                   # make. Works-council rules in Germany and France prohibit
                                   # developer monitoring, and this product will not make that
                                   # choice on your behalf by defaulting it on.
                                   #
                                   # It is the value `per_user_breakdown` reports on
                                   # /api/v1/usage. That field used to read "always false"; it is
                                   # now a live read of this switch, so what your works council
                                   # sees is the fact rather than a promise.
                                   #
                                   # Reloadable in BOTH directions, deliberately: if you decide
                                   # this afternoon that you should not be reporting names, you
                                   # can stop now rather than scheduling a restart.

  attributeUsage: false            # show a per-person USAGE table on the admin page: names and
                                   # record counts over the last seven days, read from this
                                   # instance's own audit records. OFF.
                                   #
                                   # A SEPARATE decision from attributeAuthors above, because the
                                   # two are about different people. That one is who WROTE the
                                   # code, out of commit history that already names them. This is
                                   # who USES searchcode. A works council may permit one and
                                   # refuse the other, so neither implies the other.
                                   #
                                   # NAMES AND COUNTS ONLY. No query text, no repository, no
                                   # action -- nothing about what anybody looked at. And it is on
                                   # the admin page ALONE: deliberately not on /api/v1/* and not
                                   # an MCP tool, because an agent that can enumerate who uses an
                                   # instance has nothing useful to do with the answer.
                                   #
                                   # Needs `audit.output: file`. On stdout or syslog the records
                                   # have left the process and there is nothing to read back; the
                                   # panel says so rather than rendering an empty table.
                                   #
                                   # Reloadable in both directions, for the reason above.

advisories:
  bundle: ""                       # a signed bundle, for a site that wants a fresher database
                                   # than the image carries. Empty — the default — uses the one
                                   # compiled into the binary. A bundle that fails signature
                                   # verification leaves the instance with NO database rather
                                   # than falling back to the compiled-in one, because an
                                   # operator who named a bundle believes they are running it.
                                   # Restart-only: swapping the database under a running
                                   # instance would change what a security page reports
                                   # halfway through somebody reading it
  warnIfOlderThanDays: 30          # warn on /api/v1/status, in the admin UI, in metrics AND
                                   # in the body of every vulnerability response once a
                                   # SIDE-LOADED bundle is older than this — the last because
                                   # somebody acting on a finding is looking at the answer, not
                                   # at a dashboard. 0 turns that sentence off.
                                   #
                                   # IT GOVERNS THE SENTENCE AND NOTHING ELSE.
                                   # `advisory_database.stale` — the field on /api/v1/status
                                   # that searchcode_advisory_database_stale exports and that
                                   # your alert rule reads — has a FIXED definition you cannot
                                   # change here. It is true when there is no database, when
                                   # the database is the seed compiled into the binary AT ANY
                                   # AGE, or when a side-loaded bundle is more than 30 days
                                   # old, and `stale_reason` says which. Until 2026-09-15
                                   # setting this to 0 flipped `stale` to false, so turning
                                   # off a message silenced the alarm and nothing said so.
                                   #
                                   # NOTE WHAT THAT MEANS ON A DEFAULT INSTALL: no bundle
                                   # means the compiled-in seed, the seed is stale by
                                   # definition, so the gauge reads 1 and every vulnerability
                                   # response says so until you side-load a bundle. That is
                                   # the honest answer — the seed is a handful of advisories,
                                   # one per ecosystem, and an empty result from it is not
                                   # evidence that anything is safe

# ---------------------------------------------------------------------------
# branding — unconditional, on every tier including the free one
# ---------------------------------------------------------------------------
branding:
  productName: "Acme Code Search"
  # Asset paths are confined to THIS file's directory, and may be written
  # relative to it. A path outside, or inside instance.dataDir or /run/secrets,
  # is refused. Each file is read once at load and served from memory, and its
  # type is sniffed rather than taken from the extension — raster images only,
  # because an SVG carries scripts and serving one from this origin is stored
  # XSS against every user of the instance.
  logoLight: brand/logo-light.png
  logoDark: brand/logo-dark.png
  favicon: brand/favicon.ico
  primaryColour: "#2563eb"         # hex only — this is a CSS injection surface
  hideSearchcodeBranding: false    # honoured on every tier, including the free one
  supportUrl: https://acme.example.com/support
  footerHtml: ""                   # sanitised to an allowlist — stored XSS surface

# ---------------------------------------------------------------------------
# observability
# ---------------------------------------------------------------------------
observability:
  # /metrics gets its OWN listener, not a route on server.listen. Repository
  # names appear in metric labels and are frequently sensitive, so the endpoint
  # has to be reachable by Prometheus and by nothing else — and a port is the
  # only boundary a NetworkPolicy can express.
  metrics:
    enabled: true
    listen: "0.0.0.0:9090"         # scrape this; do not expose it publicly
  tracing:
    enabled: false                 # the ONLY outbound connection this process ever makes
    endpoint: ""                   # your own OTLP collector. Never a vendor endpoint:
                                   # searchcode never phones home, on any tier.
    sampleRate: 1.0                # fraction of requests traced, 0.0-1.0. Only meaningful
                                   # once enabled is true.
  # Go's profiler, mounted under /debug/pprof on the METRICS listener above.
  #
  # READ THE ESCALATION BEFORE TURNING THIS ON. Anything that can scrape
  # /metrics can then dump the heap, and a heap dump of this process contains
  # SOURCE CODE from your indexed estate — search reads file contents into
  # memory. So while this is true, metrics access and source-code access are the
  # same access, and whoever wrote the rule granting the metrics port has
  # granted more than they think.
  #
  # Turn it on to collect a profile, then turn it off. It is restart-only, so
  # both moves are a deployment and neither is a quiet file edit.
  # docs/operations/profiling.md is the whole story.
  pprof:
    enabled: false                 # off, and this is the value to leave it at

# ---------------------------------------------------------------------------
# include — hand-authored policy here, generated volume over there
#
# Globs are relative to this file's directory. Merge semantics: lists append,
# maps merge, and a scalar in a later file wins. conf.d/*.yaml is included
# automatically when that directory exists, so this key is only needed to point
# somewhere else.
# ---------------------------------------------------------------------------
include: [conf.d/*.yaml]

Generated from the documentation that ships with searchcode enterprise. Questions: ben@boyter.org