Skip to main content

Air-Gapped Deployment Guide

Complete guide for deploying Surface Security in environments with no internet access.

Overview

An air-gapped deployment runs entirely offline. All container images, Helm charts, and configuration must be transferred to the target environment via secure media. Once deployed, Surface Security operates fully without external connectivity -- credential reuse detection, policy enforcement, and alerting all work offline.

What works offline:

  • Credential fingerprinting and reuse detection (HMAC-based, fully local)
  • Policy enforcement (warn/block)
  • Phishing detection (domain analysis, perceptual hashing)
  • Alerting and alert management
  • Admin dashboard and reporting
  • SIEM log forwarding (to internal destinations)

What requires connectivity (disabled in air-gapped mode):

  • HIBP breach database lookups
  • Portal heartbeat and license refresh
  • Automatic update checks
  • URLScan.io / VirusTotal domain reputation
  • Verification key refresh from Portal

Important: Update bundles are verified using Ed25519 threshold signatures -- at least 2 of the approved keyholders must sign each release. In connected deployments, the API fetches the current keyholder public keys from the Portal automatically. In air-gapped environments, you must embed these keys into the binary at build time (see Step 1b below). Without embedded keys, the API cannot verify update signatures and will reject all bundles.

Prerequisites

RequirementDetails
Connected staging machineFor pulling images and preparing transfer media
Air-gapped targetKubernetes cluster (1.27+) or Docker Compose host
Local container registryHarbor, Docker Registry, or similar (K8s only)
Transfer mechanismUSB drive, DVD, or approved file transfer system
License keyObtained from Surface Security sales (offline-validated)
cosign (optional)For verifying image signatures before transfer

Step 1: Image Preparation (Connected Machine)

Pull All Required Images

VERSION=1.0.0

# Application images
IMAGES=(
"ghcr.io/<owner>/surfacesec-api:${VERSION}"
"ghcr.io/<owner>/surfacesec-ingestion:${VERSION}"
"ghcr.io/<owner>/surfacesec-preflight:${VERSION}"
"ghcr.io/<owner>/surfacesec-frontend:${VERSION}"
)

# Infrastructure images (for pilot/embedded deployments)
INFRA_IMAGES=(
"postgres:18.1-alpine"
"clickhouse/clickhouse-server:25.12.4"
"docker.redpanda.com/redpandadata/redpanda:v25.3.3"
"minio/minio:latest"
"bitnami/kubectl:1.30.2"
)

for img in "${IMAGES[@]}" "${INFRA_IMAGES[@]}"; do
docker pull "${img}"
done

Verify Image Signatures

for img in "${IMAGES[@]}"; do
cosign verify \
--certificate-identity-regexp=".*" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
"${img}"
done

Download SBOMs for Compliance

for img in api ingestion preflight frontend; do
cosign download sbom "ghcr.io/<owner>/surfacesec-${img}:${VERSION}" \
> "sbom-${img}-${VERSION}.cdx.json"
done

Save Images to Tarball

docker save "${IMAGES[@]}" "${INFRA_IMAGES[@]}" \
| gzip > "surfacesec-all-${VERSION}.tar.gz"

# Generate checksum
sha256sum "surfacesec-all-${VERSION}.tar.gz" > "surfacesec-all-${VERSION}.sha256"

Step 1b: Seed Verification Keys (Connected Machine)

Update bundles require threshold signature verification (2-of-N keyholders). In connected deployments, the API fetches keyholder public keys from the Portal on first heartbeat (TOFU). Air-gapped deployments have no Portal access, so the keys must be embedded into the binary at build time.

Fetch Current Verification Keys

On a connected machine with Portal access, fetch the current keyholder public keys:

curl -sf \
-H "Authorization: Bearer ${PORTAL_SERVICE_TOKEN}" \
"https://portal.surface-security.com/api/v1/signing/verification-keys" \
| jq '{
bundle_signers: .bundle_signers,
bundle_threshold: .bundle_threshold,
signature_service_key: .signature_service_key,
license_signing_key: .license_signing_key,
algorithm: .algorithm
}' > default_verification_keys.json

Verify the Keys

Confirm the file contains real signer entries with PEM-encoded Ed25519 public keys:

jq '.bundle_signers[] | {signer_id, name}' default_verification_keys.json
# Expected: at least 3 signers (2-of-3 threshold)

jq -r '.bundle_threshold' default_verification_keys.json
# Expected: 2

Embed into Build

Replace the placeholder file and rebuild the API binary:

cp default_verification_keys.json \
backend/internal/portal/default_verification_keys.json

cd backend && go build -o surfacesec-api ./cmd/api

The //go:embed directive in embedded_keys.go bakes this file into the binary at compile time. On first boot, the API calls SeedDefaults() to load these keys into the verification key store. This allows the air-gapped deployment to verify update bundle signatures without any Portal connectivity.

Key Rotation

If the Portal rotates keyholder keys, you must rebuild the binary with the updated default_verification_keys.json and redeploy. Key rotations require threshold signatures from the current keyholders (2-of-N), so they cannot be spoofed. However, in an air-gapped environment the API has no way to receive rotation updates automatically -- a rebuild and redeployment is the only path.

Security note: The verification key store enforces key pinning. Once keys are seeded, the VerificationKeyStore rejects unsigned updates and requires threshold-signed rotations with a strictly increasing version counter. This prevents downgrade and replay attacks even in offline environments.

Step 2: Helm Chart Preparation (Connected Machine)

# Package the Helm chart
helm package infra/helm/surfacesec
# Output: surfacesec-1.0.0.tgz

# Copy the air-gapped values file
cp infra/helm/surfacesec/values-airgapped.yaml .

Step 3: Transfer to Air-Gapped Environment

Transfer the following files:

FilePurpose
surfacesec-all-X.Y.Z.tar.gzAll container images (with embedded verification keys)
surfacesec-all-X.Y.Z.sha256Image tarball checksum
surfacesec-X.Y.Z.tgzHelm chart
values-airgapped.yamlAir-gapped values override
default_verification_keys.jsonKeyholder public keys (if building from source)
sbom-*.cdx.jsonSBOMs for compliance records
Release notesChangelog and upgrade instructions

Step 4: Load Images (Air-Gapped Machine)

Verify Tarball Integrity

sha256sum -c surfacesec-all-${VERSION}.sha256

Load into Docker

docker load < surfacesec-all-${VERSION}.tar.gz

Push to Local Registry (Kubernetes)

REGISTRY=registry.internal # Your local registry

# Application images
for img in api ingestion preflight frontend; do
docker tag "ghcr.io/<owner>/surfacesec-${img}:${VERSION}" \
"${REGISTRY}/surfacesec/${img}:${VERSION}"
docker push "${REGISTRY}/surfacesec/${img}:${VERSION}"
done

# Infrastructure images
docker tag postgres:18.1-alpine "${REGISTRY}/postgres:18.1-alpine"
docker push "${REGISTRY}/postgres:18.1-alpine"

docker tag clickhouse/clickhouse-server:25.12.4 "${REGISTRY}/clickhouse/clickhouse-server:25.12.4"
docker push "${REGISTRY}/clickhouse/clickhouse-server:25.12.4"

docker tag docker.redpanda.com/redpandadata/redpanda:v25.3.3 "${REGISTRY}/redpanda:v25.3.3"
docker push "${REGISTRY}/redpanda:v25.3.3"

docker tag minio/minio:latest "${REGISTRY}/minio/minio:latest"
docker push "${REGISTRY}/minio/minio:latest"

docker tag bitnami/kubectl:1.30.2 "${REGISTRY}/bitnami/kubectl:1.30.2"
docker push "${REGISTRY}/bitnami/kubectl:1.30.2"

Step 5: Kubernetes Deployment

Create Namespace and Secrets

kubectl create namespace surfacesec

# License secret (required)
kubectl create secret generic surfacesec-surface-license \
--namespace surfacesec \
--from-literal=license-key="<your-license-key>"

# Database credentials (if using external databases)
kubectl create secret generic surfacesec-db-credentials \
--namespace surfacesec \
--from-literal=host=postgresql.internal \
--from-literal=database=surfacesec \
--from-literal=username=surfacesec \
--from-literal=password="<db-password>"

Configure Air-Gapped Values

Edit values-airgapped.yaml for your environment:

image:
registry: registry.internal/surfacesec # Your local registry
tag: "1.0.0"
pullPolicy: IfNotPresent

config:
surfacePortalUrl: "" # Disable Portal connectivity
heartbeatInterval: "0"

networkPolicies:
enabled: true
airGapped: true # Block all external egress

# For pilot deployments (embedded databases)
pilot:
enabled: true
postgresql:
image: registry.internal/postgres:18.1-alpine
clickhouse:
image: registry.internal/clickhouse/clickhouse-server:25.12.4
redpanda:
image: registry.internal/redpanda:v25.3.3
minio:
image: registry.internal/minio/minio:latest

ingress:
enabled: true
host: surfacesec.internal # Your internal hostname
tls:
enabled: true
secretName: surfacesec-tls # Pre-created TLS secret

Install

helm install surfacesec ./surfacesec-1.0.0.tgz \
-f values-airgapped.yaml \
--namespace surfacesec \
--wait --timeout 10m

Verify Deployment

# Check all pods are running
kubectl get pods -n surfacesec

# Check API health
kubectl exec deployment/surfacesec-api -n surfacesec -- \
wget -qO- http://localhost:8080/health

# Check readiness (database connectivity)
kubectl exec deployment/surfacesec-api -n surfacesec -- \
wget -qO- http://localhost:8080/ready

Step 6: Docker Compose Deployment (Single Node)

For single-node deployments without Kubernetes:

# Create environment file
cat > .env <<EOF
SURFACESEC_VERSION=1.0.0
SURFACE_PORTAL_URL=
LICENSE_KEY=<your-license-key>
EXTENSION_SIGNING_KEY=$(openssl rand -base64 32)
EOF

# Start services
docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file .env up -d

# Verify
curl -sf http://localhost:8090/health

Step 7: Certificate Management

TLS Bootstrap

For Kubernetes deployments with tlsBootstrap.enabled: true, a self-signed certificate is automatically generated on first install. To use your own certificate:

kubectl create secret tls surfacesec-envoy-tls \
--namespace surfacesec \
--cert=tls.crt \
--key=tls.key

Device mTLS Certificates

The backend generates device certificates during enrollment. For the CA certificate:

  1. Navigate to Settings > General > Certificates in the admin dashboard
  2. Upload your organization's CA certificate (PEM format)

Step 8: Browser Extension Deployment

Build the Extension

On a connected machine:

cd extension
npm install
npm run build # Chrome
npm run build:edge # Edge

Deploy via Group Policy or Intune

Configure managed storage for the extension:

{
"tenantId": "<your-tenant-id>",
"apiEndpoint": "https://surfacesec.internal",
"enrollmentEndpoint": "https://surfacesec.internal/api/v1/enroll",
"autoEnroll": true,
"features": {
"telemetryEnabled": true,
"phishingDetection": true,
"clipboardProtection": true
}
}

For Chrome managed storage deployment, push this configuration via:

  • Windows: Group Policy (HKLM\SOFTWARE\Policies\Google\Chrome\3rdparty\extensions\<extension-id>\policy)
  • Intune: Chrome ADMX templates or custom OMA-URI
  • macOS: Managed preferences profile

Step 9: DNS and Proxy Configuration

Internal DNS

Configure DNS to resolve the dashboard hostname:

surfacesec.internal -> <cluster-ingress-ip>

Forward Proxy (Optional)

If controlled internet access is needed (e.g., for HIBP lookups through an approved proxy):

# In values-airgapped.yaml
networkPolicies:
airGapped: true
externalEgress:
proxyCIDR: "10.0.0.100/32" # Your proxy server

Operational Procedures

Upgrades

  1. Pull and verify new images on a connected machine
  2. Check for keyholder key rotations -- if the Portal has rotated verification keys since your last build, re-run Step 1b to fetch the latest keys and rebuild the binary
  3. Transfer image tarball to air-gapped environment
  4. Load images into local registry
  5. Upgrade via Helm:
    helm upgrade surfacesec ./surfacesec-X.Y.Z.tgz \
    -f values-airgapped.yaml \
    --namespace surfacesec

License Renewal

Air-gapped licenses are validated offline using the Ed25519 public key embedded in the binary. To renew:

  1. Contact Surface Security sales for a new license key
  2. Update the license secret:
    kubectl create secret generic surfacesec-surface-license \
    --namespace surfacesec \
    --from-literal=license-key="<new-key>" \
    --dry-run=client -o yaml | kubectl apply -f -
  3. Restart the API:
    kubectl rollout restart deployment/surfacesec-api -n surfacesec

Backup

# PostgreSQL backup
kubectl exec surfacesec-postgresql-0 -n surfacesec -- \
pg_dump -U surfacesec surfacesec | gzip > backup-pg-$(date +%Y%m%d).sql.gz

Monitoring

Grafana, Tempo, and the OTel Collector run locally within the cluster. Access Grafana:

kubectl port-forward svc/grafana -n surfacesec 3001:3000
# Open http://localhost:3001

HIBP Breach Detection

HIBP is unavailable in air-gapped mode. The backend silently skips breach checks without error. Credential reuse detection (HMAC-based) works fully offline.

Deployment Checklist

  • Verification keys fetched from Portal and embedded into binary (Step 1b)
  • All container images loaded into local registry
  • Image checksums verified after transfer
  • Helm chart transferred and accessible
  • License secret created in target namespace
  • Database credentials secrets created (or pilot mode enabled)
  • TLS certificate configured (auto-generated or custom)
  • values-airgapped.yaml customized for environment
  • Helm install completed successfully
  • All pods in Running/Ready state
  • API /health returns 200
  • API /ready returns 200 (database connectivity confirmed)
  • Admin dashboard accessible at configured hostname
  • Admin login successful
  • Update bundle signature verification tested (upload a signed bundle)
  • Network policies active (no unexpected external egress)
  • Browser extension built and deployment package prepared
  • Extension managed storage configuration pushed via GPO/Intune
  • Extension enrollment successful (test with one device)
  • DNS resolution working for dashboard hostname
  • Backup procedures documented and tested
  • Upgrade/rollback procedures reviewed with operations team