Air-gapped install
searchcode is designed for this case rather than adapted to it. The process never phones home — no telemetry, no licence check-in, no analytics, no update ping, on any tier, for any reason — and beyond that it makes no outbound connection unless you configure one. Three things can: a code-host connection, OTLP tracing to your own collector, and a remote syslog audit sink. An air-gapped install configures none of them, and then nothing is dialled at all.
Read ../security/trust.md#egress-precisely for exactly which connections exist and which process makes each of them. This page is the install.
What you carry across the gap
| Artefact | Where it comes from |
|---|---|
| The container image, or the tarball | The release |
| Your licence file | Issued to you. It is verified offline against a compiled-in public key. |
| Your repositories | Whatever you already use to move code |
That is the whole list. There is no model to download, no GPU to provision, no vulnerability feed to subscribe to, and no vendor endpoint to allowlist.
Mirror the image
On a connected host, with the version you were given — the tag is a real
version, never latest, because a node replacement would otherwise silently move
the deployment to a different product build:
docker pull ghcr.io/boyter/searchcode:0.1.0
docker save ghcr.io/boyter/searchcode:0.1.0 -o searchcode-0.1.0.tar
If you would rather not depend on a registry at all, docker build from the
source checkout produces the same image — that is what deploy/docker-compose.yml
does, and it is the path a clean clone with no registry access takes.
Carry the tar across, then on the internal host:
docker load -i searchcode-0.1.0.tar
docker tag ghcr.io/boyter/searchcode:0.1.0 registry.internal/searchcode:0.1.0
docker push registry.internal/searchcode:0.1.0
Then point the chart at your registry:
image:
repository: registry.internal/searchcode
tag: "0.1.0"
imagePullSecrets:
- name: registry-internal
The image is signed with cosign, keylessly. Verify it on the connected host, before you mirror it — that is the point at which verification is possible at all:
cosign verify ghcr.io/boyter/searchcode:0.1.0 \
--certificate-identity-regexp '<the signing identity for your release> ' \
--certificate-oidc-issuer-regexp '<its issuer>'
deploy/release/release.sh prints the exact identity and issuer to pass for the
release it just signed. Ask for them with the release if you do not have them;
verifying with .* on both proves the image is signed and nothing about who
signed it.
The release also carries sbom.json (CycloneDX). It is generated from
go version -m against the built binary, so it lists what the linker actually
included rather than the whole module graph.
Or skip containers entirely. The tarball is a single static binary plus systemd units; see Install on a VM with systemd. On an air-gapped VM that is often less work than standing up a registry mirror.
The licence
kubectl -n searchcode create secret generic searchcode-license \
--from-file=license.key=/path/to/license.key
license:
existingSecret: searchcode-license
key: license.key
Or, for the tarball, put the file somewhere the service user can read and set
license.file in searchcode.yaml.
Verification is offline. The licence is a signed payload checked against a public key compiled into the binary. Nothing is contacted, at install or ever, and there is no activation step, no seat check-in and no phone-home renewal.
Three things about licensing that matter more in an air-gapped install than anywhere else:
- Nothing here can kill the process. An absent, tampered, expired or
unknown-version licence is community mode — logged loudly, with the reason on
/api/v1/status. It is not a crash and not a lockout. - No feature is licence-gated on any tier. Tiers differ in repository count
and user count and in nothing else. There is no capability array in the payload
and no
Has(feature)in the product. - Past grace pauses indexing and nothing else. Search, the API, MCP, audit and above all authorisation keep working.
searchcode license show reports what this instance decided and why.
Getting code in
The server never calls a code host — that is searchcode discover, a CLI you
run. In an air-gapped estate you have three options, in increasing order of
distance from the network:
1. An internal git host. Nothing special: declare the repositories and credentials in config as usual. This is the common case; "air-gapped" almost always means "no internet", not "no network".
2. A mirror populated by your own tooling. Point repositories[].url at
local paths or an internal mirror. The refresh loop is git fetch against
whatever URL you gave it.
3. A directory source. A plain path this process can read — a tarball someone dropped, a Perforce export, an NFS mount populated by a CronJob. No git at all.
directorySources:
allowedRoots: ["/mnt/depot"]
sources:
- path: /mnt/depot/cobol
groups: [mainframe]
unitDetection: marker
allowedRoots is a hard allowlist and every source path must resolve inside it.
instance.dataDir, the configuration file's own directory and /run/secrets are
denied implicitly whether or not you list them.
A directory source has no commits, so blame, history, hotspots, change coupling and commit-SHA provenance are reported as coverage gaps rather than as empty results. Read Source capability tiers before choosing this option — the difference matters, and it is stated up front rather than discovered.
Proving nothing egresses
Do not take the claim on trust; the product ships the proof in the form your security team already reads.
The generated NetworkPolicy. networkPolicy.enabled is on by default and the
policy is derived from your configuration: the egress ports come from the
repository URLs you declared and from the OTLP endpoint if you enabled tracing,
and nothing else is permitted.
helm template searchcode ./deploy/helm/searchcode --values my-values.yaml \
--show-only templates/networkpolicy.yaml
Render it against two different values files and the egress rules differ. That is the check worth running: a block-everything policy would pass a naive test.
Kubernetes NetworkPolicy matches CIDRs and not hostnames, so tighten it with
networkPolicy.egressCIDRs to name your git host's addresses. With that list
empty, each derived port is allowed to any destination and the rendered policy
says so in a comment naming the hosts it was derived from.
Run it with no network at all. With a directory source and a pre-seeded data volume, the instance serves with the network removed entirely:
docker run --network none -v /path/to/data:/var/lib/searchcode \
-v /path/to/config:/etc/searchcode:ro registry.internal/searchcode:0.1.0
Search, the file view, the API, MCP and the API reference all answer. Ingestion reports that it cannot reach the code host — as a stated gap, not as an empty result.
The API reference works offline too. /api/v1/docs is rendered by the binary
from its own route definitions: no CDN, no vendored viewer bundle, no script tag
at all. A reference that needs the internet is a blank page here, which is why
there is not one.
Egress you might still want to permit
| Connection | Made by | Needed? |
|---|---|---|
git clone / git fetch to your host |
the git binary, in a subprocess |
Yes, unless you use directory sources only |
| Image pull | your container runtime | Once, at deploy |
| SSO to your identity provider | your auth proxy | Only in auth.mode: proxy, and it is your proxy's connection, not ours |
| OTLP spans to your collector | searchcode | Only if you turn it on. Off by default. |
| Audit records to a remote syslog | searchcode | Only if audit.output: syslog names a remote address. Off by default. |
| Enumeration of a code host inside your network | searchcode | Only if you declare a connections.sources entry. Absent by default. |
The last three are the whole list, and each exists only because you configured
it. A connection is the newest of them and the one worth a sentence here: it
names exactly one host, the client is pinned to it, and a redirect anywhere
else is refused — so on an air-gapped network a connection to your own GitHub
Enterprise Server or self-managed GitLab reaches that host and nothing else.
Set connections.egress: internal-only and that becomes enforced rather
than merely true: a connection naming github.com or gitlab.com then fails at
config load, naming the host and the setting, and the generated NetworkPolicy
carries no public destination.
observability.tracing.enabled, .endpoint and connections.egress are
restart-only, deliberately: opening one of the process's outbound
connections is not a change a file edit should make a second later without the
deployment noticing.
Private CA
An internal git host with a private CA, or a TLS-inspecting egress proxy, needs
its bundle mounted. This is the single most common air-gapped first-hour
failure, and it presents as x509: certificate signed by unknown authority on
the first clone — a message that says nothing about the cause.
caBundle:
existingConfigMap: corporate-ca
key: ca-bundle.crt
mounts it and points GIT_SSL_CAINFO and SSL_CERT_FILE at it. There is no
fix available from inside the container without this: the root filesystem is
read only, so appending to the system trust store is not an option.
searchcode doctor recognises this failure shape by name and says which values
entry fixes it.
Upgrading
Mirror the new image or carry the new tarball. There is no migration step and no
online component; state in the data directory is forward-compatible. Keep the
old codes.json — see Log codes,
which is the one artefact that is genuinely per-build.