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:

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.

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