Tutorial 14 — Self-Host On-Prem (Air-Gapped / Customer Datacenter)
> Status: real, runnable path. The Helm chart at deploy/helm/buttrbase-backend-rust/, the values file at deploy/helm/buttrbase-backend-rust/values-on-prem.yaml, and the Compose bundle at deploy/compose/onprem.compose.yaml are all committed. The commands below run against them as-is. The licensing and BYOK cross-links point at existing reference docs — there is nothing forward-looking here.
This tutorial extends 07-deploy-with-helm.md. If you have not read tutorial 07, read it first — it describes the chart footprint (Deployment, Service, ConfigMap), the prerequisite Kubernetes setup, and the verify-the-deployment-mode round-trip that also applies here. This tutorial layers the on-prem values file, image mirroring for air-gapped environments, the license key, and the BYOK encryption key on top of that substrate.
1. What on-prem means here
DeploymentMode::OnPrem (in src/deployment.rs) describes a single-tenant install that the customer operates entirely within their own infrastructure. Three properties follow from that:
- No outbound connection to ButtrBase.
networkMode: air-gappedmeans the backend does not phone home, does not pull updates, and does not validate the license against an external server. The signed-JWT license is verified locally against the root public key baked into the binary. Seedocs/licensing.mdfor the full trust chain — this tutorial does not restate it. - Customer owns the data plane. Storage goes to
local-fs(a path on a PersistentVolume or a bind-mount), not to S3 or GCS. Backups go tofile:///var/backups/buttrbase. No ButtrBase-managed object storage is involved. - Billing is
manual-invoice.providers.ownership: manual-invoicemeans provisioning is handled by ButtrBase via the land-and-expand motion documented indocs/onprem-land-and-expand.md, not by a cloud marketplace. The packaging and installer bundle path is indocs/packaging-onprem.md. - A license key file (the signed leaf JWT) issued by ButtrBase for this deployment. License keys are issued by ButtrBase's license server and delivered as part of the installer bundle — see
docs/licensing.mdfor the trust chain anddocs/onprem-self-serve.mdfor how issuance is triggered. You cannot self-issue: the minting path is compile-time absent from on-prem binaries. - A BYOK encryption key the customer generates and holds. This is the
BUTTRBASE_MASTER_KEYthat guards sensitive columns at rest (ChaCha20-Poly1305, 256-bit). Seedocs/encryption-at-rest.mdfor the construction and key-rotation runbook. The customer generates and controls this key; ButtrBase never sees it. - An internal container registry reachable from the cluster, or a method to load the image via
docker save/docker load(for true air-gap). See section 3 below.
The IP allowlist (192.168.0.0/16 in the values file) restricts which source IPs the backend accepts traffic from. The value shown is a representative private-range default; replace it with the actual CIDR(s) for the customer's internal network.
2. Prerequisites
Everything in tutorial 07, section 2 applies, plus three on-prem-specific items:
3. Image mirroring for air-gapped environments
Air-gapped clusters cannot pull from ghcr.io. You must get the image inside the customer's trust boundary before installing the chart.
Option A — push to an internal registry
On a machine that can reach both ghcr.io and the internal registry:
IMAGE_TAG=0.1.0
INTERNAL_REGISTRY=registry.acme.localdocker pull ghcr.io/buttrbase/buttrbase-backend-rust:${IMAGE_TAG}
docker tag ghcr.io/buttrbase/buttrbase-backend-rust:${IMAGE_TAG} \
${INTERNAL_REGISTRY}/buttrbase/buttrbase-backend-rust:${IMAGE_TAG}
docker push ${INTERNAL_REGISTRY}/buttrbase/buttrbase-backend-rust:${IMAGE_TAG}
Then set app.image.repository and app.image.tag in your install command to point at the internal registry (see section 5).
Option B — save/load for true air-gap
On the internet-connected machine:
docker pull ghcr.io/buttrbase/buttrbase-backend-rust:${IMAGE_TAG}
docker save ghcr.io/buttrbase/buttrbase-backend-rust:${IMAGE_TAG} \
| gzip > buttrbase-backend-rust-${IMAGE_TAG}.tar.gz
Transfer the archive to the air-gapped environment (USB, secure file share, etc.), then on the target host:
docker load < buttrbase-backend-rust-${IMAGE_TAG}.tar.gz
For Kubernetes, either push from the target host into an in-cluster registry, or configure each node's image cache directly — that step depends on your cluster tooling (containerd, crio, etc.) and is outside the chart's scope.
4. Create the Secret and mount the license key
The chart reads three secret values from a Kubernetes Secret (same shape as tutorial 07). On-prem adds the license key and the BYOK master key to that same Secret:
kubectl create namespace buttrbaseGenerate the BYOK encryption key — the customer holds this; do not send it to ButtrBase.
MASTER_KEY=$(openssl rand -base64 32)kubectl -n buttrbase create secret generic buttrbase-backend-rust \
--from-literal=database-url='postgres://buttrbase:…@db.acme.local:5432/buttrbase' \
--from-literal=secret-key="$(openssl rand -hex 32)" \
--from-literal=encryption-key="$(openssl rand -hex 32)" \
--from-literal=master-key="${MASTER_KEY}" \
--from-file=license-key=/path/to/license.jwt
> The encryption-key (32-byte hex) seeds the chart's standard secret ref; the master-key (base64, 32 bytes) is the BYOK key read by BUTTRBASE_MASTER_KEY — see docs/encryption-at-rest.md. Back up both before continuing. Losing the master-key means losing every encrypted column value in the database. Losing the encryption-key has the same consequence for the values it guards. Neither can be recovered from ButtrBase.
The license key mounts as a file rather than an env var because it is a full JWT string that may be several kilobytes:
Confirm the secret was created correctly:
kubectl -n buttrbase get secret buttrbase-backend-rust -o jsonpath='{.data}' | jq 'keys'
5. Install via Helm (Kubernetes path)
The on-prem values file is at deploy/helm/buttrbase-backend-rust/values-on-prem.yaml. Its key fields:
| Field | Value | What it does |
|-------|-------|-------------|
| topology.mode | on-prem | Sets BUTTRBASE_RUST_DEPLOYMENT_MODE |
| topology.networkMode | air-gapped | Disables all outbound callbacks |
| topology.storageBackend | local-fs | Routes storage to the PersistentVolume path |
| topology.storageBucket | /var/lib/buttrbase/storage | The bind path inside the container |
| providers.ownership | manual-invoice | No marketplace billing integration |
| providers.backupTarget | file:///var/backups/buttrbase | Local backup target |
| diagnostics.enabled | true | Enables the diagnostics/restore surface |
| policy.ipAllowlistCidrs | 192.168.0.0/16 | Restricts inbound by source IP |
Before installing, the local-fs backend requires a PersistentVolume (or a storage class that provisions one) that the Deployment can bind. Create one appropriate for the customer's storage infrastructure — the chart does not provision storage, it binds to what exists.
Dry-run to confirm the rendered ConfigMap:
helm template buttrbase ./deploy/helm/buttrbase-backend-rust \
-f ./deploy/helm/buttrbase-backend-rust/values-on-prem.yaml \
--set app.image.repository=registry.acme.local/buttrbase/buttrbase-backend-rust \
--set app.image.tag=0.1.0
Confirm BUTTRBASE_RUST_DEPLOYMENT_MODE: "on-prem", BUTTRBASE_RUST_NETWORK_MODE: "air-gapped", and BUTTRBASE_RUST_STORAGE_BACKEND: "local-fs" in the rendered ConfigMap output. Then install:
helm upgrade --install buttrbase ./deploy/helm/buttrbase-backend-rust \
-n buttrbase \
-f ./deploy/helm/buttrbase-backend-rust/values-on-prem.yaml \
--set app.image.repository=registry.acme.local/buttrbase/buttrbase-backend-rust \
--set app.image.tag=0.1.0 \
--set topology.tenantSlug=acme \
--set policy.ipAllowlistCidrs[0]="10.0.0.0/8"
Override topology.tenantSlug with the customer's actual identifier. Override policy.ipAllowlistCidrs to match the customer's actual internal CIDR; the default 192.168.0.0/16 in the values file is a placeholder.
6. Install via Docker Compose (single-node path)
For small on-prem installs that do not need Kubernetes, deploy/compose/onprem.compose.yaml is the alternative. It runs the Rust backend and a Postgres sidecar together, wired with the same env vars the Helm chart would produce.
Supply secrets via env; do not bake them into the compose file.
export BUTTRBASE_DATABASE_URL='postgres://postgres:postgres@postgres:5432/buttrbase'
export BUTTRBASE_SECRET_KEY=$(openssl rand -hex 32)
export BUTTRBASE_ENCRYPTION_KEY=$(openssl rand -hex 32)
export BUTTRBASE_MASTER_KEY=$(openssl rand -base64 32)
export BUTTRBASE_LICENSE_KEY_PATH=/etc/buttrbase/license.jwtdocker compose -f deploy/compose/onprem.compose.yaml up -d
The compose file declares two named volumes (buttrbase-data, buttrbase-backups) that back BUTTRBASE_RUST_STORAGE_BUCKET (/var/lib/buttrbase/storage) and BUTTRBASE_RUST_BACKUP_TARGET (/var/backups/buttrbase). Both are Docker-managed volumes by default; bind-mount them to specific host paths if the customer's policy requires explicit filesystem placement.
The compose Postgres service (postgres:16) is a convenience for single-node installs. For production single-node deployments with data-durability requirements, point BUTTRBASE_DATABASE_URL at an existing Postgres instance and remove the sidecar from the compose file.
7. Verify
The verification steps from tutorial 07, section 5 apply here, with one adaptation: in an air-gapped environment a LoadBalancer or external Ingress likely does not exist. Port-forward is the typical first-access path:
kubectl -n buttrbase rollout status deploy/buttrbase --timeout=120s
kubectl -n buttrbase port-forward svc/buttrbase 4300:4300 &
curl -fsS http://localhost:4300/health && echo " ok"
For the Docker Compose path, the service binds on the host at port 4300 directly:
curl -fsS http://localhost:4300/health && echo " ok"
Then confirm the deployment mode round-tripped correctly (Kubernetes):
kubectl -n buttrbase exec deploy/buttrbase -- printenv BUTTRBASE_RUST_DEPLOYMENT_MODE
→ on-prem
kubectl -n buttrbase exec deploy/buttrbase -- printenv BUTTRBASE_RUST_NETWORK_MODE
→ air-gapped
The two failures that dominate first installs (from tutorial 07) apply here as well — an unreachable database-url and a malformed encryption-key. On-prem adds a third: a missing or invalid license key causes the backend to refuse startup rather than boot unlicensed. The first ten log lines will say which condition triggered.
8. Backups
The BUTTRBASE_RUST_BACKUP_TARGET (file:///var/backups/buttrbase) is a local path inside the container, mapped to the buttrbase-backups volume. Schedule a process outside the container to snapshot this volume — rsync, tar, or your site's standard backup agent — to durable off-node storage. The backup target is deliberately simple (a path on a volume) so it composes with whatever backup tooling the customer already runs.
The Postgres data lives in the postgres-data volume (Compose path) or in whatever database the customer pointed database-url at (Helm path). Back it up independently with standard Postgres tooling (pg_dump, WAL archiving, etc.). ButtrBase does not manage database backups for on-prem installs.
9. License renewal (air-gap mode)
Because networkMode: air-gapped disables phone-home, license renewal is a manual key-file delivery. ButtrBase emits a new signed leaf before the current one expires (the default step-down and grace window details are in docs/licensing.md). When you receive the new key file:
Kubernetes:
kubectl -n buttrbase create secret generic buttrbase-backend-rust \
--from-file=license-key=/path/to/new-license.jwt \
--dry-run=client -o yaml | kubectl apply -f -
Then cycle the pod to pick it up:
kubectl -n buttrbase rollout restart deploy/buttrbase
Docker Compose: replace the license file at the path the container mounts and restart the service:
docker compose -f deploy/compose/onprem.compose.yaml restart buttrbase-rust
The backend validates the chain on startup (root public key → intermediate → leaf) entirely offline. No outbound connection is needed for validation.
Done
You have a running ButtrBase backend in an air-gapped customer datacenter, with its deployment mode, network isolation, local-fs storage, IP allowlist, and offline license all verified.
07-deploy-with-helm.md is the substrate; this tutorial is values-on-prem.yaml and licensing layered on top of it.15-set-up-federation.md covers federating independent ButtrBase instances. Note: networkMode: air-gapped means federated peering to external instances is disabled by default; the operator must explicitly configure outbound federation trust if the customer's policy allows it. An on-prem instance that does not reach outside its firewall can still federate with other on-prem instances within the same network if the customer provisions the trust relationship.docs/onprem-land-and-expand.md.docs/packaging-onprem.md.