Ports, names and what sits in front of each listener

Read this before you write a DNS record or an Ingress. It applies to every install method: Docker, systemd, Helm and the agent guide all produce the same three listeners, and the naming decision is the same in all four.

The short answer: three ports, two names, and one of the three should not be reachable from outside your network at all.

The three listeners

Listener Default Config key Who reaches it What they present
API and MCP 0.0.0.0:8080 server.listen Agents and API clients A searchcode-issued API key or OAuth token
UI 0.0.0.0:8081 server.uiListen People, in a browser A session cookie, or your proxy's identity headers
Metrics 0.0.0.0:9090 observability.metrics.listen Prometheus Nothing. It is unauthenticated.

All three bind every interface by default, because a default of 127.0.0.1 presents as "metrics do not work in Kubernetes". Deciding what is exposed is your network's job, and this page is about making that decision once.

Why three, and not one with three paths

Each listener accepts exactly one credential family, and the three families cannot coexist on one socket:

So the boundary has to be somewhere a network control can see it. A NetworkPolicy, a firewall rule and a reverse proxy all act on ports. None of them acts on paths. A single hostname that routes /api/v1/* and /v1/mcp past the proxy is a skip-auth route, and that is GHSA-7x63-xv5r-3p2x in a config file nobody reviews.

The full reasoning is in the authentication article.

Metrics needs no name

/metrics carries repository names in its labels, and it authenticates nobody. Treat scrape access as equivalent to knowing your estate's shape.

Do not put it behind the auth proxy to "secure" it. Prometheus cannot complete an OIDC flow, and the scrape will fail in a way that looks like the exporter is down.

The two names

searchcode.example.com       -> UI listener      (8081), behind your auth proxy
searchcode-api.example.com   -> API and MCP      (8080), straight through

One certificate each, both on 443, and the auth boundary is visible in DNS rather than buried in a port number. In Kubernetes only the first gets an Ingress: the chart has no values key that would put one in front of the API listener, deliberately.

Also supported: one hostname, two ports

https://searchcode.example.com        -> UI listener   (443, behind the proxy)
https://searchcode.example.com:8443   -> API and MCP   (straight through)

One certificate covers both. The cost is a non-443 port, which corporate egress filtering and some CI runners will block.

Not supported: one hostname, two paths

There is no configuration that produces this and it is not an omission. See above.

What the names then have to be written into

If you use MCP with OAuth, the product cannot work out these two values for itself: it can see only the sockets it bound, not what is in front of them.

mcp:
  oauth:
    publicUrl: https://searchcode-api.example.com   # the AGENT's address, never proxied
    uiUrl:     https://searchcode.example.com       # the BROWSER's address, behind your proxy

They are different hosts on purpose, and the asymmetry is the two-port rule restated as two URLs.

Set them before you switch to auth.mode: proxy. Without uiUrl, discovery publishes the UI listener's own address, the client goes straight there, and the UI listener refuses it with the proxy shared secret is missing or wrong — which names neither the setting nor the cause. The instance warns on stderr at startup and searchcode doctor reports it. Both are restart-only.

What must pass through untouched

Whatever fronts the API listener must not authenticate, and must pass Authorization through unmodified. A load balancer, a TLS terminator or a plain proxy_pass is fine. An auth proxy is not.

The UI listener is the opposite: in auth.mode: proxy it must be reached only through your proxy, and searchcode decides whether to trust identity headers from the direct peer address, so auth.trustedProxyCIDRs has to name the proxy and the UI listener must not be reachable around it.

Headless: the API and MCP with no UI

If nobody will use searchcode in a browser, for example because agents, CI or another platform's front end are the only callers, turn the UI listener off:

server:
  uiListen: off
mcp:
  auth:
    mode: apikey
serviceAccounts:
  - name: platform
    role: viewer
    groups: [platform]
    tokenFile: /etc/searchcode/secrets/platform-token

With uiListen: off no UI socket is bound at all. The instance has two ports, the API and MCP listener and metrics, and one name. Startup prints searchcode ui: off (server.uiListen) where the UI address would be. An empty uiListen is still an error: off has to be written down.

What still works: every /api/v1 endpoint, /v1/mcp, the reference at /api/v1/docs, health, metrics, the audit stream and searchcode doctor. Agents authenticate with service accounts, or with API keys issued before the UI was turned off.

What does not:

Turning the UI off or back on needs a restart. In Helm, set ui.enabled: false: the chart then renders no UI container port, Service, Ingress or NetworkPolicy rule, so a reviewer can confirm from the manifest that there is no UI.

Per install method

UI API and MCP Metrics
Docker compose publishes 8081:8081 compose publishes 8080:8080 compose publishes 9090:9090 for convenience; scope it
systemd oauth2-proxy upstreams = ["http://127.0.0.1:8081/"] exposed directly firewall it
Helm -ui Service, ingress.enabled: true -api Service, exposed directly -metrics Service plus a NetworkPolicy

Checklist

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