On-Premises Deployment
This guide covers deploying Surface Security on your own hardware using lightweight Kubernetes (K3s) and the Helm pilot profile. Ideal for small businesses with up to 1,000 endpoints that want full control of their data without cloud dependencies.
For cloud-managed Kubernetes, see AWS EKS or Azure AKS. For non-Kubernetes deployments, see Docker Compose.
Prerequisites
Hardware
A single Linux server with:
| Resource | Minimum | Recommended |
|---|---|---|
| CPU | 2 vCPU | 4 vCPU |
| RAM | 4 GB | 8 GB |
| Storage | 60 GB SSD/NVMe | 120 GB NVMe |
| Network | 1 GbE | 2.5 GbE |
See Hardware Sizing for specific device recommendations and pricing.
Software
- Linux server (Ubuntu 22.04+, RHEL 9+, or Debian 12+)
- SSH access with root/sudo privileges
helmv3.12+ installed on your workstation- A Surface Security license key (see Licensing) -- entered during the setup wizard, not needed for installation
Network
- A DNS record pointing to your server (e.g.,
surfacesec.yourcompany.com) - Ports 443 (HTTPS) and 6443 (K8s API, optional -- only if managing remotely) open
- TLS certificate for your domain (or use Let's Encrypt via cert-manager)
- Outbound HTTPS to
portal.surface-security.comfor license validation, updates, and signature distribution (not required for air-gapped deployments)
Step 1: Install K3s
K3s is a lightweight, production-ready Kubernetes distribution from Rancher. It includes an ingress controller (Traefik), a default StorageClass (local-path), CoreDNS, and a built-in load balancer -- everything needed to run Surface Security on a single node.
SSH into your server and run:
curl -sfL https://get.k3s.io | sh -
Verify the installation:
# K3s bundles kubectl
sudo k3s kubectl get nodes
# NAME STATUS ROLES AGE VERSION
# your-host Ready control-plane,master 30s v1.31.x+k3s1
Configure kubectl access
To use kubectl and helm without sudo:
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config
export KUBECONFIG=~/.kube/config
# Add to your shell profile
echo 'export KUBECONFIG=~/.kube/config' >> ~/.bashrc
(Optional) Remote access from your workstation
To manage the cluster from another machine, copy the kubeconfig and replace the server address:
# On the server
sudo cat /etc/rancher/k3s/k3s.yaml
# On your workstation, save the output to ~/.kube/config
# Then replace 127.0.0.1 with your server's IP/hostname:
sed -i 's/127.0.0.1/your-server-ip/' ~/.kube/config
Alternatives to K3s
| Distribution | Install | Notes |
|---|---|---|
| K3s (recommended) | curl -sfL https://get.k3s.io | sh - | Lightweight, includes Traefik + local-path storage |
| MicroK8s | snap install microk8s --classic | Ubuntu-native, enable add-ons: microk8s enable dns storage ingress |
| kubeadm | docs.kubernetes.io | Full Kubernetes, more setup required |
Whichever you choose, ensure your cluster has:
- A default StorageClass (for PVCs)
- An ingress controller (Traefik, nginx, etc.)
- CoreDNS or equivalent for service discovery
Step 2: Install with Helm
kubectl create namespace surfacesec
cd infra/helm/surfacesec
# Pull subchart dependencies (PostgreSQL, ClickHouse, Redpanda, MinIO)
helm dependency update .
# Install using the pilot profile
helm install surfacesec . \
--namespace surfacesec \
--values values-pilot.yaml \
--set ingress.enabled=true \
--set ingress.host=surfacesec.yourcompany.com
Registry credentials are included in the distribution bundle you received. All images will pull successfully on first install.
K3s uses Traefik by default. The chart's network policies default to allowing traffic from
ingress-nginx. For K3s, override the ingress controller selector:--set networkPolicies.ingressControllerSelector."app\.kubernetes\.io/name"=traefik \--set ingress.className=traefik
For air-gapped environments, see Air-Gapped Deployment.
This deploys the full stack in-cluster:
| Component | Type | Storage |
|---|---|---|
| PostgreSQL | StatefulSet | 10 Gi PVC |
| ClickHouse | StatefulSet | 20 Gi PVC |
| Redpanda | StatefulSet | 5 Gi PVC |
| MinIO | StatefulSet | 5 Gi PVC |
| API | Deployment (1 replica) | -- |
| Ingestion | Deployment (1 replica, 4 workers) | -- |
| Frontend | Deployment (1 replica) | -- |
Total resource footprint: ~700m CPU, ~1.7 Gi memory, ~40 Gi disk.
Step 3: Configure TLS
Option A: cert-manager with Let's Encrypt (recommended)
# Install cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.17.2/cert-manager.yaml
# Create a ClusterIssuer for Let's Encrypt
cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@yourcompany.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: traefik
EOF
Then add the annotation to your Helm values:
helm upgrade surfacesec . \
--namespace surfacesec \
--values values-pilot.yaml \
--set ingress.annotations."cert-manager\.io/cluster-issuer"=letsencrypt-prod \
--set ingress.tls.enabled=true
Option B: Bring your own certificate
kubectl create secret tls surfacesec-tls \
--namespace surfacesec \
--cert=path/to/tls.crt \
--key=path/to/tls.key
For the Envoy mTLS endpoint (extension traffic), create a separate TLS secret:
kubectl create secret tls surfacesec-envoy-tls \
--namespace surfacesec \
--cert=path/to/envoy-tls.crt \
--key=path/to/envoy-tls.key
Step 4: Verify the deployment
# Check all pods are Running
kubectl get pods -n surfacesec
# Check services
kubectl get svc -n surfacesec
# Test health endpoints
kubectl port-forward svc/surfacesec-api 8080:8080 -n surfacesec &
curl http://localhost:8080/health # {"status": "healthy"}
curl http://localhost:8080/ready # {"status": "healthy", "ready": true}
# Check ingress
kubectl get ingress -n surfacesec
Step 5: Complete the setup wizard
Open https://surfacesec.yourcompany.com in your browser and complete the setup wizard. The wizard will:
- Create the admin account and tenant
- Enter your license key and activate it with the portal
- Configure registry access (pulls credentials from the portal -- pods will transition from
ImagePullBackOfftoRunning) - Retrieve the portal API token (enables heartbeat, automatic updates, and signature distribution)
No demo data is pre-loaded -- data populates once browser extensions are deployed.
Operations
Backups
The embedded databases have no built-in backup. Set up regular backups to avoid data loss:
# PostgreSQL -- daily dump
kubectl exec -n surfacesec sts/surfacesec-pilot-postgresql -- \
pg_dump -U surfacesec surfacesec > backup-$(date +%Y%m%d).sql
# ClickHouse -- daily backup
kubectl exec -n surfacesec sts/surfacesec-pilot-clickhouse -- \
clickhouse-client --query "BACKUP DATABASE default TO Disk('backups', 'backup-$(date +%Y%m%d)')"
Consider automating these with a CronJob or using volume snapshots if your StorageClass supports them. Store backups off-server (NAS, S3, etc.).
Updates
Updates are managed automatically through the Surface Security portal. No manual intervention is required for routine upgrades.
How it works:
- The API service sends a heartbeat to the portal at a regular interval (configurable, default 5 minutes)
- The portal responds with any pending update bundles
- The platform updater runs preflight checks (database compatibility, disk space, node capacity, license validity, schema migration safety)
- If all checks pass, the updater pulls the new container images and performs a rolling deployment
- Postflight checks verify pod readiness, API health, ingestion pipeline connectivity, and run smoke queries
- If any postflight check fails, the system automatically rolls back to the previous version
All update bundles are threshold-signed (2-of-3 keyholders using YubiKeys) and verified locally before applying. Bundles contain embedded OCI image tars, so deployments never pull from an external registry after the initial install -- images are extracted from the signed bundle and pushed to the deployment's internal registry. Even a fully compromised portal cannot forge an update, since the portal only relays bundles and never holds signing keys.
Update modes:
| Mode | Description | Configuration |
|---|---|---|
| Automatic | Portal pushes updates, applied after preflight passes | AUTO_UPDATE=true (default) |
| Manual | Admin reviews and approves updates in the dashboard | AUTO_UPDATE=false, then approve in Settings > Updates |
| Air-Gapped | Upload signed bundles offline via admin dashboard | See Air-Gapped Deployment |
To switch to manual approval mode:
helm upgrade surfacesec . \
--namespace surfacesec \
--values values-pilot.yaml \
--set config.autoUpdate=false
With manual mode, updates appear in the admin dashboard under Settings > Updates. Review the release notes and preflight results, then click Apply Update.
For full details on the update pipeline, deployment strategies, and rollback behavior, see Update System.
Monitoring
K3s does not include a monitoring stack. For basic observability:
# Pod resource usage
kubectl top pods -n surfacesec
# Logs
kubectl logs -n surfacesec -l component=api --tail=100
kubectl logs -n surfacesec -l component=ingestion --tail=100
For full observability (Grafana, Tempo, OTel), configure the OTEL exporter in Helm values:
config:
otelExporterEndpoint: "http://otel-collector.monitoring:4317"
Uninstalling
helm uninstall surfacesec -n surfacesec
PVCs are not deleted automatically. Remove them manually if desired:
kubectl delete pvc -n surfacesec --all
kubectl delete namespace surfacesec
Scaling beyond 1,000 endpoints
When you outgrow the pilot profile, transition to the small profile with external managed databases:
- Provision standalone PostgreSQL and ClickHouse instances (separate server or managed service)
- Switch to
values-small.yamlwhich enables HPA, multiple replicas, and OpenSearch - Create secrets pointing to your external database instances
- Upgrade:
helm upgrade surfacesec . --values values-small.yaml
See the Scaling Reference in the Kubernetes guide for pod and connection counts by endpoint tier.