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
| Requirement | Details |
|---|---|
| Connected staging machine | For pulling images and preparing transfer media |
| Air-gapped target | Kubernetes cluster (1.27+) or Docker Compose host |
| Local container registry | Harbor, Docker Registry, or similar (K8s only) |
| Transfer mechanism | USB drive, DVD, or approved file transfer system |
| License key | Obtained 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
VerificationKeyStorerejects 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:
| File | Purpose |
|---|---|
surfacesec-all-X.Y.Z.tar.gz | All container images (with embedded verification keys) |
surfacesec-all-X.Y.Z.sha256 | Image tarball checksum |
surfacesec-X.Y.Z.tgz | Helm chart |
values-airgapped.yaml | Air-gapped values override |
default_verification_keys.json | Keyholder public keys (if building from source) |
sbom-*.cdx.json | SBOMs for compliance records |
| Release notes | Changelog 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:
- Navigate to Settings > General > Certificates in the admin dashboard
- 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
- Pull and verify new images on a connected machine
- 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
- Transfer image tarball to air-gapped environment
- Load images into local registry
- 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:
- Contact Surface Security sales for a new license key
- 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 -
- 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.yamlcustomized for environment - Helm install completed successfully
- All pods in Running/Ready state
- API
/healthreturns 200 - API
/readyreturns 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