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:
- An agent presents a credential searchcode issued. An auth proxy did not issue it and cannot validate it, so a proxy in front of 8080 refuses every agent call.
- A person presents a browser session. That is exactly what a proxy is for.
- Prometheus presents nothing at all. It cannot sign in to your IdP, and it does not hold an API key.
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.
- Reachable by Prometheus and by nothing else.
- No DNS record, no Ingress, no certificate.
- In Kubernetes that is the
-metricsService plus a NetworkPolicy; on a VM it is a firewall rule, or bind it to a private interface withobservability.metrics.listen.
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
Recommended: two hostnames
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
publicUrlis where an MCP client registers, exchanges its code and calls tools. It is the API listener, and it becomes the OAuth issuer, so a token is checked against it.uiUrlis where the client sends the person to approve the grant. The consent page lives on the UI listener, because that is where a session exists.
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:
- MCP OAuth. Its authorization page is on the UI listener, so
mcp.auth.mode: oauthis refused at startup, andbothworks asapikey. - Creating API keys and connections in the browser. Declare service
accounts and connections in
searchcode.yamlinstead. - Signing in. In
auth.mode: localnobody can sign in, andauth.mode: proxyhas no effect, because both govern the UI listener only.
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
- [ ] Two DNS names, or one name and two ports.
- [ ] The auth proxy points at the UI port and cannot reach the API port.
- [ ] Nothing authenticating sits in front of the API port.
- [ ]
Authorizationreaches the API listener unmodified. - [ ] The metrics port has no DNS record and is restricted to Prometheus.
- [ ]
mcp.oauth.publicUrlandmcp.oauth.uiUrlare set, and match the names above. - [ ]
auth.trustedProxyCIDRsnames the proxy, and only the proxy.