Code-host connections

A connection is a named, declared intent to enumerate repositories from one code host. It is the only thing in searchcode.yaml that can cause the searchcode process to connect to a code host, and this page is what you are agreeing to when you enable one.

What ships today. All of it. The block below is validated, its egress rules are enforced, and the Helm chart derives its NetworkPolicy from it; both providers enumerate — GitHub, for github.com and GitHub Enterprise Server, and GitLab, for a self-managed instance and for gitlab.com; and the scheduler re-enumerates each connection on its own interval, hands what it finds to the one ingestion scheduler, and retires what has gone.

You do not have to write this file at all. An instance with no repositories offers a connect screen at /, and searchcode setup does the same from a terminal — see Without editing this file. searchcode discover is still supported and answers a different question; the table at the end of this page says which you want.

What it costs, stated plainly

Before connections existed, the answer to a security questionnaire was: the searchcode process makes no outbound connection at all unless you enable tracing. A connection is an outbound connection made by the server, so that sentence does not survive it, and pretending otherwise would be worse than losing it.

The replacement:

The searchcode process makes no outbound connection unless you configure one. Three things can configure one: a code-host connection, which you create and which names exactly one host; OTLP tracing, which is off by default; and a remote syslog audit sink, which is off by default. It never connects to the vendor under any circumstances, on any tier.

That is still checkable rather than merely stated:

On-premise loses nothing

A connection to a code host inside your own network is not internet egress. GitHub Enterprise Server, self-managed GitLab and Bitbucket Data Center live behind the same firewall as this product, so the socket goes to gitlab.internal.example.com:443 and never leaves.

Your code host What the process connects to Your questionnaire answer
GHES, self-managed GitLab, Bitbucket DC one host inside your network no connection leaves your network, ever — unchanged from every prior release
github.com, gitlab.com one host on the internet one declared host, which you configured; never the vendor

connections.egress: internal-only makes the first row enforceable rather than merely true. Set it, and a connection naming a public code host fails at configuration load, naming the host and the setting; the Helm chart refuses to render at all, before the manifest reaches your GitOps repository. It is a switch you can point a security review at instead of a policy somebody has to read.

connections:
  egress: internal-only

What it refuses: github.com, gitlab.com, bitbucket.org, dev.azure.com, codeberg.org, git.sr.ht, gitea.com, sourceforge.net and their subdomains, plus any URL naming a globally routable IP address. What it cannot do is prove that an arbitrary hostname is inside your network — gitlab.example.com and gitlab.internal.example.com look identical to a string matcher, and resolving the name would itself be an outbound connection. The generated NetworkPolicy is what bounds the rest, which is why it is generated.

Without editing this file

There are two paths to the same record, and neither needs an editor.

The browser. An instance with no repositories renders a connect screen at / instead of a search box over an empty corpus; on an instance that already has some, it is at /connect. Fill in the host, the token and what to enumerate, and the first sync's progress appears on the same screen. It needs the administer permission, so it is a member of a group named in authz.adminGroups — and it is deliberately not on /api/v1/* and not an MCP tool, because an agent that can create a connection can point this instance at a host of somebody else's choosing.

The terminal, for a headless install:

searchcode setup --config /etc/searchcode/searchcode.yaml

It prompts for the host, the token, the targets, the repositoryGroups what it discovers will join and the clone credential, prints the scopes to grant and where to create a token, checks the token against the host before writing anything, and stores the connection. Every prompt has a flag, so the same thing is expressible in a provisioning script — searchcode setup --help lists them. --groups is the one that is required: without it the connection would be visible to nobody, and the command refuses rather than storing one. There is deliberately no --token: a secret on a command line is in the shell history and visible in ps to every user on the host, so it is --token-file, --token-env, or the prompt.

One thing to set up first: connections.keyFile

A token typed into a form has to be kept somewhere. It is not written into this file — that would put a live credential in the artefact you commit — and it is not held in memory only, because a connection that silently stops syncing on the next restart is worse than one that never started. It goes into subjects.db, encrypted.

A key stored beside the ciphertext is encoding rather than encryption, so the key comes from your configuration and an instance with no key configured cannot create a connection from the browser or from setup at all. That refusal is deliberate: it means your deployment made a decision about key custody rather than inheriting one from us.

connections:
  keyFile: /run/secrets/connections.key    # or keyEnv: SEARCHCODE_CONNECTIONS_KEY

Generate one with openssl rand -base64 32. Mount it the way you mount every other secret. Four things follow from it:

Declaring one

You can still write it here, and on an air-gapped instance whose configuration is managed in git you probably should — this is the only path where the credential reference is reviewable in a pull request.

connections:
  egress: any                    # any | internal-only. Restart to change.
  sources:
    - name: acme-github          # identity; see below
      type: github               # github | gitlab
      url: https://github.com    # ONE host. Never derived from a response.
      token:
        env: ACME_GITHUB_TOKEN   # a reference, never a value
      include:                   # required
        orgs: [acme, acme-labs]
        users: [ben]
        self: false
      exclude: ["*-archive"]
      skipArchived: true
      skipForks: true
      groups: [engineering]
      syncInterval: 1h

Four properties of that block are load-bearing.

token is a secret reference, never a literal. There is no field to paste a token into. Write {env: NAME} or {file: /path/to/secret}; a relative file path resolves against the directory holding searchcode.yaml, and the file is re-read on every configuration load, so a rotated Kubernetes Secret needs no image change. An inline value is refused at load, naming the field — and the refusal deliberately does not repeat the value, because an error message carrying a token puts it in the startup log, the journal and any support bundle. If you wrote one, rotate it: it has been in a file.

name is identity. Per-connection state hangs off it, so renaming a connection is a delete plus a create rather than a rename. That is stated rather than silently migrated.

include is required. There is no "everything this token can see" mode. The blast radius of a mis-scoped personal access token is the whole reason: a connection that enumerated whatever the credential could reach would turn one over-broad token into an estate nobody chose. self: true — the token's own account — is the one target that needs no name, and it is a complete configuration on its own.

The block reloads; a token change does not. Everything you edit day to day — which organisations, what to exclude, how often — takes effect without a restart, and so does adding or removing a whole connection. What is rejected on reload, naming the connection, is changing which secret an existing connection points at; connections.egress is rejected the same way. Both need a restart. Rotating the value behind an unchanged reference is likewise not picked up until a restart, and that is worth knowing: the configuration watcher compares the configuration files, and a mounted secret is not one of them.

The token, and what it is not

A connection contributes repositories, never permissions. The token is read-only and metadata-only. Nothing in this product reads, writes or mirrors a code host's access-control lists: authorisation stays declarative and local, in authz.bindings, evaluated against the groups your identity provider asserts.

That is a deliberate refusal rather than a missing feature. Mirroring host ACLs would make your code host the source of truth for who can see what, which turns a GitHub outage into an access-control outage — and it needs per-user OAuth tokens this product never asks for.

Host Scope to grant Notes
github.com / GHES, fine-grained PAT Metadata: read Enough to enumerate. Contents: read is for the clone, which is a separate credential.
github.com / GHES, classic PAT repo for private repositories; nothing for public
GitLab (any), personal, group or project token read_api Enough to enumerate — and, on the CE release this was measured against (2026-09-16), enough to clone as well. read_repository is for the clone; see the GitLab section below for the bound on that.

Enumeration and cloning are separate concerns and use separate credentials. A connection's token is for the API; git remotes authenticate through credentials: (see the configuration reference). Name the one a discovered repository is cloned with on the connection itself:

    - name: acme-github
      token: {env: ACME_GITHUB_TOKEN}   # LISTS repositories
      credential: internal-https        # CLONES them; names a `credentials:` entry

credential: is a name and never a secret, exactly as repositories[].credential is, and an undeclared name fails at config load. Omit it for a public organisation, where an anonymous clone is correct. A connection that omits it and then discovers private repositories reports one gap on /api/v1/status, on the admin page and on /connect, naming itself and what to add — rather than leaving one git authentication failure per repository, which reads as a token-scope problem and sends you back to the code host. Widening the connection's own token does not help: a GitHub metadata-only scope enumerates and cannot clone. (GitLab differs, and the difference is measured rather than assumed — see the GitLab section below.) Where the two are the same value, that is your choice rather than this product's assumption.

Prefer a group or project access token on GitLab, and a token owned by a service account rather than a person on GitHub. The failure mode otherwise is indexing silently stopping three months after onboarding, when somebody's account is deprovisioned.

GitLab, and what is different about it

A self-managed instance is the primary case, not a variant. It lives behind the same firewall as this product, so the socket never leaves your network and connections.egress: internal-only permits it while refusing gitlab.com. Three differences from GitHub are worth knowing before you write the block, and none of them is cosmetic.

There is no path-prefix rule. /api/v4 is the API path on gitlab.com and on every self-managed install alike, so write the host and nothing else: url: https://gitlab.internal.example.com. (GitHub needs a rule because github.com's API is served from api.github.com and GHES serves it under /api/v3; GitLab has no equivalent, and there is nothing here to get wrong.)

Groups nest, and a group is a path. acme/platform/infra/tooling is ONE entry under include.orgs, and naming a group enumerates the whole tree beneath it. That is the useful behaviour — a platform team's estate is almost always nested, and a tool that read only the top level would silently miss most of it — but it cuts the other way on a large instance: a top-level group can be far more than you expected. Name the subgroup you mean, or bound it with exclude globs, and check the count on /api/v1/status after the first sync.

A user is a personal namespace, with its own endpoint. Projects live under a group or a user, and include.users: [ben] is how you reach the second; self: true is the account that issued the token and needs no name.

One token may be enough, which is the opposite of GitHub and is the one thing here that carries a version bound. On the GitLab CE release this was measured against — 2026-09-16, over HTTP, on a private project, as a non-administrator (a Reporter on the group, to rule out an admin bypass) — a read_api token enumerated and cloned, while a read_repository token cloned and was refused 403 on the enumeration. So on GitLab the token that fails fails at the LISTING end, which is the opposite end of the pipeline from GitHub's. It is one CE release: not gitlab.com, not an older self-managed install, and GitLab has changed which scopes git-over-HTTP accepts before — so if clones are refused, put a read_repository token in that entry, which is always sufficient and never wrong.

"One token" means one SCOPE, not one reference — a private estate still needs the credentials: entry. These are two different sentences and running them together is the reason this paragraph exists. What was measured is a read_api token sitting inside a credentials entry, presented as the clone password; a connection with no credential: presents nothing, so the clone goes out anonymous and every private project is refused however the listing token is scoped. The estate looks the same either way — private repositories missing — and the two have different fixes, so searchcode doctor names which one you have.

connections:
  egress: internal-only          # your instance is inside your network
  sources:
    - name: acme-gitlab
      type: gitlab
      url: https://gitlab.internal.example.com   # no /api/v4 needed
      token:
        env: ACME_GITLAB_TOKEN
      credential: acme-gitlab-clone   # CLONES them; omit only for a public estate
      include:
        orgs: [acme/platform]    # one path; its subgroups come with it
        users: [ben]
      exclude: ["acme/platform/sandbox/*"]
      groups: [engineering]
What you see What it means What to do
"presented a TLS certificate this process does not trust" The one that will actually happen. Your instance uses an internal CA, and the image ships only the public roots. x509: certificate signed by unknown authority on its own says none of that. Mount the CA and point SSL_CERT_FILE at it — the chart's caBundle values entry mounts the bundle and sets both SSL_CERT_FILE and GIT_SSL_CAINFO, and the second is what the clone needs. searchcode doctor diagnoses the identical shape for a git remote.
"refused the token: it is invalid, revoked or expired" 401. Issue a new token with read_api — see the token-type note below.
"the token is missing the read_api scope" 403. The token is valid and not permitted to enumerate. Grant read_api, and only that — not GitLab's read-write API scope, and no administrator scope.
"has no group acme/platform, as typed" 404. Check the spelling — a nested group is the whole path, not the last segment — and then check the token, because on GitLab a private group is indistinguishable from a missing one to a token that cannot see it. Both are a 404.
"did not answer as a GitLab API" A 404 that is not GitLab's own JSON error. Either the instance predates GitLab 9.0, where /api/v4 arrived (/api/v3 was removed in 11.0 and is not supported), or something in front of it is answering. Check what serves https://your-host/api/v4/version.
"could not be reached … HTTPS_PROXY=…" A dial failure, with the proxy environment this process actually saw. If the instance is internal it almost certainly belongs in NO_PROXY; if the message says no proxy environment is set and the host is only reachable through one, that is the finding.
"rate limited this token after N repositories" A limit — and self-managed instances are often configured much tighter than gitlab.com. Nothing. The connection is not finished, not broken, and it does not retry into the limit.

Prefer a group or project access token. A personal access token dies with the person's account, and the failure mode is the quiet one: indexing stops three months after onboarding, when somebody is deprovisioned, and the estate just gradually goes stale. A group access token on the group you are indexing survives that, and it is scoped to exactly what the connection names.

Connections or searchcode discover?

Both are supported and they answer different questions.

Connection searchcode discover
Who enumerates the server, on a schedule a CLI you run, in CI
Where the repository list lives in the server a conf.d/ fragment in git
Review none by default a pull request, if that is where you point it
Outbound connection the server makes one, to your declared host the CLI makes one; the server never does

An estate whose repository membership is reviewed in a pull request should keep using the CLI. An estate where somebody has to remember to re-run it is the case connections exist for.

Who can see what a connection finds

A repository's audience is declared, never inferred. That is the property the whole authorisation model rests on, and it is worth stating the consequence before the mechanism: nothing about a repository's name, its owner, its path or its topics affects who can read it.

There are two ways to say what a connection's repositories are visible to, and you choose per connection.

Model B — groups on the connection (the default)

Every repository a connection discovers joins the groups: declared on that connection, and no others.

repositoryGroups:
  - name: engineering

connections:
  sources:
    - name: acme
      type: github
      url: https://github.com
      token: {env: SEARCHCODE_GITHUB_TOKEN}
      include:
        orgs: [acme]
      groups: [engineering]        # every repository this finds joins this

The audience is still declared — you declared it once, for the set, rather than once per repository. A rename at the code host cannot move a repository between groups, because names were never consulted; a repository leaving the organisation leaves the group by leaving the estate, which is audited.

Two audiences means two connections. The common shape is "the acme org is for everyone, acme-secrets is for the platform team", and it needs no pattern anywhere:

connections:
  sources:
    - name: acme
      include: {orgs: [acme]}
      groups: [engineering]
      # … url, token
    - name: acme-secrets
      include: {orgs: [acme-secrets]}
      groups: [platform]
      # … url, token

A connection is the unit of audience as well as the unit of enumeration, which is why include is explicit and why name is identity.

One repository out of its connection's groups is an explicit entry, by name, and it wins:

repositories:
  - url: https://github.com/acme/payments
    groups: [payments]           # beats the acme connection's [engineering]

Explicit beats generated — the same rule conf.d/ already has. The whole entry wins, not only its groups, so a credential, a depth or an exclude list you wrote there is what runs.

What is refused, and why

There are no per-repository pattern rules. No groupRules, no groupPatterns, no mapping from a topic or a name to a group — and a configuration attempting one fails at load, naming the key.

It is the obvious next request. It is also what makes the model collapse: a rule mapping *-infra onto infra means that renaming a repository at the code host changes who can read it, with no change in this product and nothing in a diff for anybody to review. A glob written where a group name belongs (groups: ["team-*"]) is refused for the same reason. Patterns are allowed on the binding side — authz.bindings[].repoGroups — where every expansion is logged at config load and is therefore reviewable.

A connection with no groups

A connection that declares no groups: indexes repositories visible to nobody. That is not a configuration error — you may legitimately stage a connection before the bindings that grant it exist — but it is never silent, because a connection that is green, has the right repository count and returns nothing from every search sends people to look at their token. Four surfaces say it:

Before a connection's first sync applies, the connect screen shows what it would add: how many repositories, the groups they will join, and which identity groups that grants access to, through which binding. That is the last point at which a mis-scoped token or a mistyped group name is cheap to notice.

Model A — a generated file, reviewed

If the estate should change when a pull request merges rather than when a code host does something, keep using searchcode discover. It writes a conf.d/ fragment listing every repository with its groups explicitly, unclassified ones with an empty list, and applies nothing until that file is merged. config validate fails while any repository is unclassified, so the review happens before deployment rather than becoming a runtime state somebody has to notice.

What a connection tells you when it cannot see everything

A connection reports two different things and it is worth knowing which you are looking at. A failure means the connection is broken and nothing was read: a revoked token, an unreachable host. A gap means it is not finished, or saw only part of what you asked for — and an answer with a gap is never presented as a complete one.

What you see What it means What to do
"refused the token: it is invalid, revoked or expired" 401. The credential is not usable at all. Issue a new token with the scope above and update the token: reference.
"the token is missing a scope" 403 with no rate-limit headers. The token is valid and not permitted to do this. Grant Metadata: read (fine-grained) or repo (classic, private repositories).
"has no organisation acme, as typed" 404. Check the spelling — and then check the token, because a private organisation looks identical to a missing one to a token that cannot see it.
"SAML single sign-on withheld repositories from this token" The organisation enforces SSO and this token is not authorised for it. Authorise the token for that organisation in your GitHub token settings. This is the one nobody guesses: GitHub answers an unauthorised token with a filtered list, not an error, so the organisation appears to have fewer repositories in it — or none.
"returned no repositories at all" The organisation is empty, or invisible to this token. Both are possible and the API cannot tell you which, which is why this is reported rather than written down as zero.
"rate limited this token after N repositories; it resumes at …" A primary or secondary rate limit. Nothing. The connection is not finished, not broken; it keeps what it read and does not hammer the limit, because retrying into a secondary limit extends it.
"presented a TLS certificate this process does not trust" Your network terminates TLS with its own certificate authority. Mount the CA and point SSL_CERT_FILE at it — the chart's caBundle values entry does both. searchcode doctor diagnoses the same shape for git remotes.

searchcode discover prints the same gaps, and refuses to write a fragment when there is one: a file generated from a partial answer deletes every repository the run could not see, and the pull request would carry it to a merge.

Where to look when something is wrong

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