Skip to main content

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:

ResourceMinimumRecommended
CPU2 vCPU4 vCPU
RAM4 GB8 GB
Storage60 GB SSD/NVMe120 GB NVMe
Network1 GbE2.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
  • helm v3.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.com for 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

DistributionInstallNotes
K3s (recommended)curl -sfL https://get.k3s.io | sh -Lightweight, includes Traefik + local-path storage
MicroK8ssnap install microk8s --classicUbuntu-native, enable add-ons: microk8s enable dns storage ingress
kubeadmdocs.kubernetes.ioFull 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:

ComponentTypeStorage
PostgreSQLStatefulSet10 Gi PVC
ClickHouseStatefulSet20 Gi PVC
RedpandaStatefulSet5 Gi PVC
MinIOStatefulSet5 Gi PVC
APIDeployment (1 replica)--
IngestionDeployment (1 replica, 4 workers)--
FrontendDeployment (1 replica)--

Total resource footprint: ~700m CPU, ~1.7 Gi memory, ~40 Gi disk.

Step 3: Configure TLS

# 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:

  1. Create the admin account and tenant
  2. Enter your license key and activate it with the portal
  3. Configure registry access (pulls credentials from the portal -- pods will transition from ImagePullBackOff to Running)
  4. 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:

  1. The API service sends a heartbeat to the portal at a regular interval (configurable, default 5 minutes)
  2. The portal responds with any pending update bundles
  3. The platform updater runs preflight checks (database compatibility, disk space, node capacity, license validity, schema migration safety)
  4. If all checks pass, the updater pulls the new container images and performs a rolling deployment
  5. Postflight checks verify pod readiness, API health, ingestion pipeline connectivity, and run smoke queries
  6. 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:

ModeDescriptionConfiguration
AutomaticPortal pushes updates, applied after preflight passesAUTO_UPDATE=true (default)
ManualAdmin reviews and approves updates in the dashboardAUTO_UPDATE=false, then approve in Settings > Updates
Air-GappedUpload signed bundles offline via admin dashboardSee 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:

  1. Provision standalone PostgreSQL and ClickHouse instances (separate server or managed service)
  2. Switch to values-small.yaml which enables HPA, multiple replicas, and OpenSearch
  3. Create secrets pointing to your external database instances
  4. Upgrade: helm upgrade surfacesec . --values values-small.yaml

See the Scaling Reference in the Kubernetes guide for pod and connection counts by endpoint tier.