Skip to main content

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

ComponentTypeDetails
PostgreSQL 18.1StatefulSet (10Gi PVC)Transactional database
ClickHouse 25.12.4StatefulSet (20Gi PVC)Analytics / event storage
Redpanda v25.3.3StatefulSet (5Gi PVC)Event streaming (single-node)
MinIOStatefulSet (5Gi PVC)Object storage (screenshots)
API + Ingestion + FrontendDeployments (1 replica each)Application services
Schema Init JobHelm 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

FeaturePilotProduction (Tier 3+)
InfrastructureEmbedded StatefulSetsExternal managed databases
Replicas1 eachHPA-scaled
SearchPostgreSQL pg_trgmOpenSearch
ObservabilityDisabledGrafana + Tempo + OTel
HA / PDBDisabledEnabled
IngressDisabled (port-forward)Configured

Production Deployment

Prerequisites

  • A Kubernetes cluster (1.27+) with kubectl configured
  • 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

ProfileFileExtensionsAPI ReplicasNotes
Pilotvalues-pilot.yamlUp to 1,0001Embedded infra, single-node
Smallvalues-small.yaml5,000-10,0003External infra
Mediumvalues-medium.yaml10,000-25,0005External infra
Largevalues-large.yaml25,000-60,00010 (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:

ComponentConfigurationOn-Demand/moWith 1-yr RI
EKS control plane1 cluster$73$73
API nodes3x m7i.xlarge$441$270
Ingestion nodes3x m7i.xlarge$441$270
RDS PostgreSQLdb.r7g.xlarge Multi-AZ~$538~$415
ClickHouse (self-hosted)2x r7i.xlarge + EBS~$466~$316
S3 + miscScreenshots, backups, LB~$50~$50
Total~$2,458/mo~$1,672/mo

60,000 Extensions:

ComponentConfigurationOn-Demand/moWith 1-yr RI
Total~$6,035/mo~$3,934/mo

Azure (AKS)

10,000 Extensions:

ComponentConfigurationPAYG/moWith 1-yr Reserved
AKS control plane (Standard)1 cluster$73$73
API nodes3x D4ds v5$420$264
Ingestion nodes3x D4ds v5$420$264
PostgreSQL Flexible ServerD4ds v5 + HA~$512~$392
ClickHouse (self-hosted)2x E4ds v5 + SSD~$489~$339
Blob + miscScreenshots, backups, LB~$50~$50
Total~$2,393/mo~$1,655/mo

60,000 Extensions:

ComponentConfigurationPAYG/moWith 1-yr Reserved
Total~$5,749/mo~$3,912/mo

Cloud vs. On-Premises Cost Comparison

ScaleOn-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

ExtensionsAPI PodsIngestion PodsPG Total ConnsCH Total Conns
1,0003315090
5,00055250150
10,000108500240
25,0001512750360
50,00020151,000450

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.