Air-gapped installation
Status: documented. The steps have not been run.
Differently is built to run with no internet access:
- No telemetry. It has no telemetry, update checks or phone-home code paths.
- No third-party dashboard requests. The dashboard is served from the
binary with
Content-Security-Policy: default-src 'self', so the browser loads nothing from third parties. - No outbound calls by default. The only outbound connections the
control plane ever makes go to PostgreSQL, to the backends of its pools
(OpenSandbox servers,
remoteplugins) and to external-provider APIs. Provider pools are disabled by default.
OpenSandbox is a separate product with its own images and configuration. Whether it runs fully offline, and which of its components reach the network, is for its documentation to answer; it has not been checked here.
1. Mirror the images
Section titled “1. Mirror the images”| Image | Needed for |
|---|---|
ghcr.io/different-ai/differently-server:0.1.0 |
Control plane, migrations, preflight |
ghcr.io/different-ai/differently-cli:0.1.0 |
helm test only |
docker.io/library/postgres:17 |
Only with the evaluation-only postgresql.embedded |
docker.io/library/busybox:1.37 |
Only for the kubectl debug checks in these guides |
OpenSandbox’s server, execd and other component images |
Your OpenSandbox installation; see step 3 |
| The sandbox images your pools create sandboxes from | Pulled by OpenSandbox’s runtime, not by Differently |
Copy by digest so the internal tags point at exactly what you reviewed. Use
--all (skopeo) or crane copy so multi-architecture indexes come along:
REG=registry.internal.example.comfor img in differently-server differently-cli; do skopeo copy --all docker://ghcr.io/different-ai/$img:0.1.0 docker://$REG/different-ai/$img:0.1.0doneskopeo inspect docker://$REG/different-ai/differently-server:0.1.0 | jq -r .Digestglobal.imageRegistry replaces only the registry part of each image, so keep
the repository paths (different-ai/differently-server) in the mirror, or
override *.image.repository per component. Pin with *.image.digest in
production.
2. Mirror the chart
Section titled “2. Mirror the chart”helm package deploy/helm/differently # differently-0.2.0.tgzhelm push differently-0.2.0.tgz oci://$REG/charts # or copy the .tgz across the gaphelm show chart oci://$REG/charts/differently --version 0.2.03. Mirror and configure OpenSandbox
Section titled “3. Mirror and configure OpenSandbox”Install OpenSandbox from its own chart or manifests, mirrored the same way,
following its documentation. Point every image it uses at the internal
registry. In the configuration the adapter was checked against
(test/opensandbox/config.toml), those are at least the server image, the
execd image ([runtime] execd_image) and the egress image
([egress] image); a Fast Sandbox installation adds its own components and
templates. Pin the same versions you tested.
Sandboxes are created from an image URI (or a Fast Sandbox template). The
pool’s default image is ubuntu:24.04 unless you set opensandbox.image
(or a Fast Sandbox opensandbox.templateId), or the request names a
template. Mirror those images and use internal URIs,
or creation fails when OpenSandbox’s runtime cannot pull them.
4. Install with no external egress
Section titled “4. Install with no external egress”global: imageRegistry: registry.internal.example.com imagePullSecrets: [{name: internal-registry}]config: store: {driver: postgres} policy: external: never # the default; stated explicitly herepostgresql: external: {existingSecret: differently-database}auth: existingSecret: differently-tokensopensandbox: apiURL: http://opensandbox-server.opensandbox-system.svc/v1 trust: private isolation: container image: registry.internal.example.com/library/ubuntu:24.04 # mirrored sandbox image existingSecret: opensandbox-api-keyproviders: daytona: {enabled: false} freestyle: {enabled: false}networkPolicy: enabled: true server: egress: restricted: true # DNS, the database and OpenSandbox only databaseCIDRs: ["10.20.0.0/24"]helm install dly oci://$REG/charts/differently --version 0.2.0 -n differently -f values-airgap.yamlWith providers disabled, the server NetworkPolicy has no TCP 443 egress rule;
its OpenSandbox rule (networkPolicy.server.egress.opensandbox) allows only
the OpenSandbox namespace (or cidrs) on the server’s port (80 by default).
Even a misconfigured provider pool could not reach the internet from the
server pods, as long as the CNI enforces NetworkPolicy. For defence in depth,
add a namespace-wide default-deny egress policy as well.
Sandboxes are a separate matter. Their egress goes through OpenSandbox’s runtime and the network under it, not through Differently, which does not configure OpenSandbox’s network policy. Close it with OpenSandbox’s own egress settings, NetworkPolicy in OpenSandbox’s namespace, or node and VPC firewalling if sandboxes must not reach the internet.
Verify
Section titled “Verify”# 1. Every image comes from the internal registry (repeat for OpenSandbox's namespace).kubectl -n differently get pods -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{range .spec.initContainers[*]}{.image}{"\n"}{end}{end}' | sort -u
# 2. The server reports no telemetry, no approved external pools, and no external pool at all.curl -fsS -H "Authorization: Bearer $DIFFERENTLY_API_KEY" "$DIFFERENTLY_URL/v1/admin/trust-report" \ | jq '{telemetry, external: .globalPolicy.external, pools: [.pools[] | {name, trust, endpoint, approvedForExternalPlacement}]}'# "telemetry": "none: Differently has no telemetry or phone-home code paths"# every pool has "trust": "private", and the opensandbox endpoint is an internal address
# 3. The server's egress is restricted to DNS, the database and OpenSandbox.kubectl -n differently get networkpolicy dly-differently-server -o yaml | sed -n '/egress:/,$p'
# 4. Everything still works with egress locked down.helm test dly -n differently --logsFor an independent check, watch flows from the server pods with your CNI’s flow logs (for example Cilium Hubble, or VPC flow logs). Expect only DNS, PostgreSQL and the OpenSandbox server’s port (80 by default).
Source: docs/deploy/air-gapped.md