//Self-Host On-Prem

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-gapped means 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. See docs/licensing.md for 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 to file:///var/backups/buttrbase. No ButtrBase-managed object storage is involved.
  • Billing is manual-invoice. providers.ownership: manual-invoice means provisioning is handled by ButtrBase via the land-and-expand motion documented in docs/onprem-land-and-expand.md, not by a cloud marketplace. The packaging and installer bundle path is in docs/packaging-onprem.md.
  • 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:

  • 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.md for the trust chain and docs/onprem-self-serve.md for 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_KEY that guards sensitive columns at rest (ChaCha20-Poly1305, 256-bit). See docs/encryption-at-rest.md for 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.
  • 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.local

    docker 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 buttrbase

    Generate 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.jwt

    docker 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.

  • Where this path came from — 07-deploy-with-helm.md is the substrate; this tutorial is values-on-prem.yaml and licensing layered on top of it.
  • Connect on-prem instances — 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.
  • License and expand — adding more apps to an air-gapped deployment is a new license key delivery (not a redeploy), documented in docs/onprem-land-and-expand.md.
  • Packaging and the installer bundle — if you are distributing this as a single-click installer rather than walking through these steps manually, see docs/packaging-onprem.md.