Kubernetes Deployment
This guide covers deploying Surface Security on Kubernetes for Tier 3+ deployments (5,000+ endpoints). For smaller deployments, see Docker Compose Deployment.
Pilot Deployment (Helm)
The pilot profile deploys the full Surface Security platform -- application and data infrastructure -- with a single Helm command on any managed Kubernetes cluster (AKS, EKS, GKE). No external database provisioning required.
Use cases: Sales demos, prospect evaluations, internal dev/QA environments.
Scale: Up to 1,000 browser extensions on a single-node cluster.
Resource budget: ~700m CPU, ~1.7Gi memory -- fits on a 2-vCPU / 4GB node (Azure Standard_B2s, GCP e2-medium, AWS t3.medium).
What Gets Deployed
| Component | Type | Details |
|---|---|---|
| PostgreSQL 18.1 | StatefulSet (10Gi PVC) | Transactional database |
| ClickHouse 25.12.4 | StatefulSet (20Gi PVC) | Analytics / event storage |
| Redpanda v25.3.3 | StatefulSet (5Gi PVC) | Event streaming (single-node) |
| MinIO | StatefulSet (5Gi PVC) | Object storage (screenshots) |
| API + Ingestion + Frontend | Deployments (1 replica each) | Application services |
| Schema Init Job | Helm hook (post-install) | Creates PG tables, CH tables, Redpanda topics |
Pilot Quick Start
# 1. Add dependency repos
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo add redpanda https://charts.redpanda.com
helm repo update
# 2. Build the API image with the public key embedded
docker build \
--build-arg BUILD_VERSION="1.0.0" \
--build-arg SURFACE_PUBLIC_KEY='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAXM8vGHbUWln922FdjF2IOcJwRtYJut5VPD6hdSmTPL8=
-----END PUBLIC KEY-----' \
-t ghcr.io/your-org/surfacesec/api:1.0.0 \
-f backend/Dockerfile backend
docker push ghcr.io/your-org/surfacesec/api:1.0.0
# 3. Create namespace and license secret
kubectl create namespace surfacesec
kubectl create secret generic surfacesec-surface-license -n surfacesec \
--from-literal=license-key="<your-license-key>"
# 4. Build chart dependencies
cd infra/helm/surfacesec
helm dependency build
# 5. Install (set surfacePublicKey for runtime fallback)
helm install surfacesec ./infra/helm/surfacesec \
-n surfacesec \
-f infra/helm/surfacesec/values-pilot.yaml \
--set image.registry=ghcr.io/your-org/surfacesec \
--set image.tag=1.0.0
# 6. Wait for pods to be ready (~2-3 minutes)
kubectl get pods -n surfacesec -w
# 7. Access the dashboard
kubectl port-forward svc/surfacesec-frontend 3000:80 -n surfacesec
# Open http://localhost:3000
Key Differences: Pilot vs Production
| Feature | Pilot | Production (Tier 3+) |
|---|---|---|
| Infrastructure | Embedded StatefulSets | External managed databases |
| Replicas | 1 each | HPA-scaled |
| Search | PostgreSQL pg_trgm | OpenSearch |
| Observability | Disabled | Grafana + Tempo + OTel |
| HA / PDB | Disabled | Enabled |
| Ingress | Disabled (port-forward) | Configured |
Production Deployment
Prerequisites
- A Kubernetes cluster (1.27+) with
kubectlconfigured - A valid Surface Security license key (see Licensing)
- Container images built and pushed to your registry
- TLS certificates for your domain
Step 1: Create the Namespace
kubectl create namespace surfacesec
Step 2: Create Database Secrets
# PostgreSQL credentials
kubectl create secret generic surfacesec-db-credentials \
--namespace=surfacesec \
--from-literal=host="your-postgres-host" \
--from-literal=database="surfacesec" \
--from-literal=username="surfacesec" \
--from-literal=password="YOUR_SECURE_PG_PASSWORD"
# ClickHouse credentials
kubectl create secret generic surfacesec-clickhouse-credentials \
--namespace=surfacesec \
--from-literal=username="surfacesec" \
--from-literal=password="YOUR_SECURE_CH_PASSWORD"
# MinIO credentials
kubectl create secret generic surfacesec-minio-credentials \
--namespace=surfacesec \
--from-literal=endpoint="your-minio-host:9000" \
--from-literal=access-key="your-minio-access-key" \
--from-literal=secret-key="YOUR_SECURE_MINIO_SECRET"
# TLS certificate
kubectl create secret tls surfacesec-tls \
--namespace=surfacesec \
--cert=path/to/tls.crt \
--key=path/to/tls.key
Step 3: Create the License Secret
# Online deployment (with portal connectivity)
kubectl create secret generic surfacesec-surface-license \
--namespace=surfacesec \
--from-literal=license-key="YOUR_SIGNED_LICENSE_KEY" \
--from-literal=api-token="YOUR_PORTAL_API_TOKEN"
# Offline deployment
kubectl create secret generic surfacesec-surface-license \
--namespace=surfacesec \
--from-literal=license-key="YOUR_SIGNED_LICENSE_KEY"
Step 4: Build and Push Container Images
The API image must be built with the Surface Security Ed25519 public key embedded. This key is compiled into the Go binary via linker flags so that license signatures are verified locally -- no network call required.
# Build the API image with the public key embedded
docker build \
--build-arg BUILD_VERSION="1.0.0" \
--build-arg SURFACE_PUBLIC_KEY='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAXM8vGHbUWln922FdjF2IOcJwRtYJut5VPD6hdSmTPL8=
-----END PUBLIC KEY-----' \
-t your-registry.example.com/surfacesec/api:1.0.0 \
-f backend/Dockerfile backend
# Push to your container registry
docker push your-registry.example.com/surfacesec/api:1.0.0
Why embed the key at build time? The embedded key takes priority over any environment variable, preventing operators from substituting a different key to forge license signatures. The key is public (not a secret), so it is safe to include in build scripts and CI pipelines.
Additionally, configure the public key in the ConfigMap so the API can use it as a fallback at runtime. Set it via Helm values:
# values.yaml or values override
config:
surfacePublicKey: |
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAXM8vGHbUWln922FdjF2IOcJwRtYJut5VPD6hdSmTPL8=
-----END PUBLIC KEY-----
Or for raw kubectl deployments, add it to the ConfigMap:
kubectl create configmap surfacesec-config \
--namespace=surfacesec \
--from-literal=surface-public-key='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAXM8vGHbUWln922FdjF2IOcJwRtYJut5VPD6hdSmTPL8=
-----END PUBLIC KEY-----'
The API deployment reads this from the ConfigMap via the SURFACE_PUBLIC_KEY environment variable (already configured in the deployment manifests).
Step 5: Install with Helm
# Choose the sizing profile for your scale (see Helm Sizing Profiles below)
helm install surfacesec ./infra/helm/surfacesec \
-n surfacesec \
-f infra/helm/surfacesec/values-small.yaml \
--set image.registry=your-registry.example.com/surfacesec \
--set image.tag=1.0.0 \
--set ingress.host=surfacesec.yourdomain.com
This deploys all application components (API, Ingestion, Frontend, Envoy gateway) with:
- Deployments with rolling updates (zero downtime)
- PodDisruptionBudgets for each component
- HorizontalPodAutoscalers for API and Ingestion
- Envoy gateway for extension mTLS and rate limiting
- Network policies (default-deny with explicit allow rules)
Step 7: Configure Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: surfacesec-ingress
namespace: surfacesec
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
spec:
ingressClassName: nginx
tls:
- hosts:
- surfacesec.yourdomain.com
secretName: surfacesec-tls
rules:
- host: surfacesec.yourdomain.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: surfacesec-api
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: surfacesec-frontend
port:
number: 80
Envoy Gateway
The Helm chart automatically deploys an Envoy gateway as a LoadBalancer service for extension traffic. This is separate from the NGINX Ingress used for the admin dashboard.
Traffic routing:
- NGINX Ingress (
surfacesec-ingress) -- Admin dashboard, authentication, SCIM, OAuth - Envoy LoadBalancer (
surfacesec-envoy) -- Extension API (/api/v1/ext), enrollment, rate limiting
The Envoy service gets an external IP/hostname from your cloud provider's load balancer:
kubectl get svc surfacesec-envoy -n surfacesec
Configure your browser extensions to point to the Envoy endpoint. For on-premises deployments, configure DNS to point to the Envoy LoadBalancer IP.
To customize the Envoy service type or annotations (e.g., for AWS NLB or Azure Internal LB):
envoy:
service:
type: LoadBalancer
annotations:
# AWS NLB example:
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
# Azure internal LB example:
# service.beta.kubernetes.io/azure-load-balancer-internal: "true"
ClickHouse TLS
For ClickHouse Cloud or any ClickHouse instance requiring TLS (port 9440), enable TLS in your values:
clickhouse:
tlsEnabled: true
config:
# Use port 9440 instead of 9000 for TLS connections
clickhouseHosts: "your-clickhouse-host:9440"
Step 8: Verify the Deployment
# All pods healthy
kubectl get pods -n surfacesec
# License verified
kubectl logs -n surfacesec -l component=api --tail=5 | grep "license verified"
# Health endpoints
kubectl port-forward -n surfacesec svc/surfacesec-api 8080:8080 &
curl http://localhost:8080/health
curl http://localhost:8080/ready
# HPA configured
kubectl get hpa -n surfacesec
Helm Sizing Profiles
| Profile | File | Extensions | API Replicas | Notes |
|---|---|---|---|---|
| Pilot | values-pilot.yaml | Up to 1,000 | 1 | Embedded infra, single-node |
| Small | values-small.yaml | 5,000-10,000 | 3 | External infra |
| Medium | values-medium.yaml | 10,000-25,000 | 5 | External infra |
| Large | values-large.yaml | 25,000-60,000 | 10 (HPA: 10-20) | External infra, HPAs |
Production Install (Helm)
helm install surfacesec ./infra/helm/surfacesec \
-n surfacesec \
--set image.registry=ghcr.io/your-org/surfacesec \
--set image.tag=1.0.0
Pre-create secrets pointing to your external PostgreSQL, ClickHouse, Redpanda, and MinIO instances.
Upgrading (Helm)
When upgrading, rebuild the API image with the new version and your public key:
# 1. Build and push the new API image
docker build \
--build-arg BUILD_VERSION="1.1.0" \
--build-arg SURFACE_PUBLIC_KEY='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAXM8vGHbUWln922FdjF2IOcJwRtYJut5VPD6hdSmTPL8=
-----END PUBLIC KEY-----' \
-t your-registry.example.com/surfacesec/api:1.1.0 \
-f backend/Dockerfile backend
docker push your-registry.example.com/surfacesec/api:1.1.0
# 2. Upgrade the Helm release
helm upgrade surfacesec ./infra/helm/surfacesec \
-n surfacesec \
-f infra/helm/surfacesec/values-pilot.yaml \
--set image.tag=1.1.0
The init Job runs as a post-upgrade hook with idempotent SQL, so upgrades are safe to repeat.
Uninstalling (Helm)
helm uninstall surfacesec -n surfacesec
PVCs are not deleted automatically. Remove them manually if desired:
kubectl delete pvc -n surfacesec --all
Database Schema Setup (Production)
The pilot profile automatically runs schema migrations via a Helm hook. For production deployments with external databases, apply schemas manually before the first install:
PostgreSQL:
# Apply the schema to your external PostgreSQL instance
psql -h your-pg-host -U surfacesec -d surfacesec -f infra/helm/surfacesec/files/pilot-pg-schema.sql
ClickHouse:
# Create the database and apply the schema
clickhouse-client -h your-ch-host -u surfacesec --password YOUR_PASSWORD \
--query "CREATE DATABASE IF NOT EXISTS surfacesec"
clickhouse-client -h your-ch-host -u surfacesec --password YOUR_PASSWORD \
-d surfacesec < infra/helm/surfacesec/files/pilot-ch-schema.sql
Redpanda/Kafka Topics:
Topics are auto-created by the ingestion service on first connection. No manual setup needed.
Cloud Cost Estimates
If deploying in a customer's existing cloud environment instead of on-premises hardware, use managed Kubernetes services. All prices are approximate (US East regions, Linux, as of February 2026).
AWS (EKS)
10,000 Extensions:
| Component | Configuration | On-Demand/mo | With 1-yr RI |
|---|---|---|---|
| EKS control plane | 1 cluster | $73 | $73 |
| API nodes | 3x m7i.xlarge | $441 | $270 |
| Ingestion nodes | 3x m7i.xlarge | $441 | $270 |
| RDS PostgreSQL | db.r7g.xlarge Multi-AZ | ~$538 | ~$415 |
| ClickHouse (self-hosted) | 2x r7i.xlarge + EBS | ~$466 | ~$316 |
| S3 + misc | Screenshots, backups, LB | ~$50 | ~$50 |
| Total | ~$2,458/mo | ~$1,672/mo |
60,000 Extensions:
| Component | Configuration | On-Demand/mo | With 1-yr RI |
|---|---|---|---|
| Total | ~$6,035/mo | ~$3,934/mo |
Azure (AKS)
10,000 Extensions:
| Component | Configuration | PAYG/mo | With 1-yr Reserved |
|---|---|---|---|
| AKS control plane (Standard) | 1 cluster | $73 | $73 |
| API nodes | 3x D4ds v5 | $420 | $264 |
| Ingestion nodes | 3x D4ds v5 | $420 | $264 |
| PostgreSQL Flexible Server | D4ds v5 + HA | ~$512 | ~$392 |
| ClickHouse (self-hosted) | 2x E4ds v5 + SSD | ~$489 | ~$339 |
| Blob + misc | Screenshots, backups, LB | ~$50 | ~$50 |
| Total | ~$2,393/mo | ~$1,655/mo |
60,000 Extensions:
| Component | Configuration | PAYG/mo | With 1-yr Reserved |
|---|---|---|---|
| Total | ~$5,749/mo | ~$3,912/mo |
Cloud vs. On-Premises Cost Comparison
| Scale | On-Prem (one-time) | On-Prem 2yr TCO* | AWS EKS 2yr (1-yr RI) | Azure AKS 2yr (1-yr RI) |
|---|---|---|---|---|
| 10k extensions | ~$3,900 | ~$5,800 | ~$40,100 | ~$39,700 |
| 60k extensions | ~$14,500 | ~$18,400 | ~$94,400 | ~$93,900 |
On-prem TCO includes hardware purchase + estimated $80/mo electricity + $50/mo network/ISP for a 2-year period.
When to choose cloud: When your organization already has an AWS/Azure footprint with negotiated enterprise discounts, when you need multi-region DR, when you lack on-site facilities, or when OpEx is preferred over CapEx. On-prem is 5--7x cheaper over 2 years for this workload but requires physical infrastructure management.
Scaling Reference
| Extensions | API Pods | Ingestion Pods | PG Total Conns | CH Total Conns |
|---|---|---|---|---|
| 1,000 | 3 | 3 | 150 | 90 |
| 5,000 | 5 | 5 | 250 | 150 |
| 10,000 | 10 | 8 | 500 | 240 |
| 25,000 | 15 | 12 | 750 | 360 |
| 50,000 | 20 | 15 | 1,000 | 450 |
Health Checks
Surface Security provides comprehensive health check endpoints:
# Liveness check (is the process alive?)
curl http://localhost:8080/health
# Response: {"status": "healthy"}
# Readiness check (can the service handle requests?)
curl http://localhost:8080/ready
# Response: {"status": "healthy", "ready": true}
Kubernetes probes are pre-configured in the deployment manifests with appropriate thresholds for startup, liveness, and readiness.