Skip to content

Sandboxes and queries

A sandbox is a private, throwaway database restored from a backup or from any second in the window. It is the default restore destination, because looking never risks production.

tenax restore team-a/app --at 2026-10-07T09:41:27Z --ttl 6h --wait
tenax sandbox list
tenax sandbox show <sandbox>

What a sandbox is

  • A database pod of the target's engine and version (spec.sandbox.image on the target), in Tenax's sandbox namespace, with generated credentials and a default-deny NetworkPolicy.
  • A TTL (sandbox.defaultTTL, at most sandbox.maxTTL, both per policy and chart-wide). When it expires, Tenax deletes the sandbox and its storage. tenax sandbox extend <sandbox> --by 24h buys more time; tenax sandbox delete <sandbox> ends it early.
  • Owned by whoever asked for it; every action on it is audited.

Querying

The console's Query page and tenax query run SQL through Tenax's query proxy, read-only by default:

tenax query <sandbox> -e "select count(*) from orders where created_at > now() - interval '1 day'"
tenax query <sandbox> -e "select * from coupons" -o csv > coupons.csv

Each query has a 30-second limit and returns at most 1,000 rows; --write allows writes (sandboxes only).

In the editor, Ctrl+Enter (Cmd+Enter on a Mac) runs the query, or only the selected text when there is a selection. Completion offers keywords, functions, tables and columns: typing opens it after two characters, after alias. it lists that table's columns, after schema. its tables, and Ctrl+Space opens it anywhere. Tab or Enter picks a suggestion, Esc closes it, and Tab indents otherwise.

The console speaks each engine's SQL (PostgreSQL and MySQL today); results show values as the database prints them.

Sizing

Each sandbox is a database pod in its own namespace. What it gets, from the first place that sets it:

  1. the restore: the CPU, Memory, Disk and Storage class fields on the Restore page (the greyed values are what it gets if you leave them empty), tenax restore … --cpu 2 --memory 8Gi --size 50Gi --storage-class local-path, or spec.destination.sandbox of a Restore;
  2. the target: spec.sandbox.cpu, memory, sizeLimit, storageClass of the BackupTarget;
  3. the installation: the chart's sandbox.resources;
  4. built in: 100m CPU, 256Mi memory requested with a 1Gi limit, and a disk of twice the backup's data plus 512Mi (at least 1Gi) on the node (emptyDir, removed with the sandbox).
Setting Means
CPU CPU request; no CPU limit, so a restore can use idle cores
Memory memory request and limit
Disk data volume size; on the node's disk unless a storage class is set
Storage class the data goes on a PVC of this class instead, for restores bigger than node disks allow

The chart's sandbox.limits sets ceilings for CPU, memory and disk. The console and the API refuse a request above them, and a restore whose size comes out above them (a target's setting, or the automatic size of a large backup) fails with the reason instead of filling a disk halfway through. A running sandbox keeps its size: restore again to change it.

Connecting from the cluster

The sandbox's host, port and credentials are on its page (Reveal credentials, audited) and in tenax sandbox show. Pods in other namespaces need the label tenax.run/sandbox-client: <sandbox>; the sandbox's network policy refuses everything else.

Connecting from outside the cluster

How sandboxes are reached from a laptop is the installation's choice (expose.provider, below). In every case:

  • Open external access with Open external access on the sandbox's page, tenax sandbox expose <sandbox>, or open external access when restoring (tenax restore … --expose); it needs the operator role.
  • Connect with tenax sandbox connect <sandbox>, which prints a connection string for any database client, or copy it from the sandbox's page.
  • Close it with Close external access or tenax sandbox unexpose <sandbox>. It also closes when the sandbox expires or is deleted.
expose.provider What tenax sandbox connect does The client needs
none (default) Forwards a local port to the sandbox through your kubeconfig, until Ctrl+C A kubeconfig allowed to port-forward in tenax-sbx-<sandbox>
service Prints the sandbox's own address (postgres://…@sbx-7f3a.db.example.com:5432/app?sslmode=require) Nothing: any database client, over TLS
cloudflare Opens cloudflared access tcp on a local port after an Access login, until Ctrl+C cloudflared and an allowed email

Postgres sandboxes serve TLS with a certificate of their own (self-signed: clients use sslmode=require, which encrypts without checking the issuer); MySQL sandboxes use the certificate MySQL generates.

Reaching sandboxes from outside the cluster (administrators)

Tenax opens nothing by itself: each option uses something your cluster already has.

Option service: a Kubernetes Service per sandbox

Each exposed sandbox gets a Service built from expose.service; whatever your cluster runs for Services gives it an address, and Tenax shows that address. It is the simplest for developers (any database client) and works with any Kubernetes:

Your cluster has Set Developers connect to
A cloud load balancer (AKS, EKS, GKE) type: LoadBalancer, sourceRanges: [<office or VPN CIDRs>] The load balancer's IP
The same, plus external-dns also annotations: {external-dns.alpha.kubernetes.io/hostname: "sbx-{id}.db.example.com"} and hostnamePattern: "sbx-{id}.db.example.com" sbx-<id>.db.example.com
MetalLB (on-premises) type: LoadBalancer An address on your network
The Tailscale operator loadBalancerClass: tailscale The sandbox's tailnet name; nothing is public
Nothing of the above type: NodePort, hostnamePattern: "nodes.example.com" with a matching name for your nodes nodes.example.com:<node port>
expose:
  provider: service
  service:
    type: LoadBalancer
    sourceRanges: ["203.0.113.0/24"]   # who may connect; leave empty only on a private network

Sandboxes hold copies of production data

A public load balancer protects a sandbox only with its random password, TLS and its time limit. Set sourceRanges to the addresses your developers come from, or use a private option (Tailscale, an internal load balancer through your cloud's annotation, MetalLB on a private network).

While a sandbox is exposed, an extra NetworkPolicy admits expose.service.allowFrom (default: everything; the load balancer's sourceRanges does the filtering, since its traffic often arrives from node addresses) on the database port. On k3s's built-in load balancer every exposed sandbox binds the database port on the nodes, so only one Postgres and one MySQL sandbox can be exposed at a time there.

Option cloudflare: a tunnel behind an Access login

Tunnels need one Cloudflare API token, which belongs to the installation, not to a user. It is a Kubernetes Secret in Tenax's namespace, set up once like any other credential (with your GitOps secret tooling, for example SOPS). The console never asks for it, holds it or shows it: it can change DNS and Access applications in the zone.

  1. In Cloudflare, My Profile → API Tokens → Create Token → Custom token with these permissions, for the zone the hostnames live in:
    • Account: Cloudflare Tunnel: Edit
    • Account: Access: Apps and Policies: Edit
    • Zone: DNS: Edit
    • Zone: Zone: Read
  2. Store it:

    kubectl -n tenax-system create secret generic tenax-cloudflare-api --from-file=token=api-token.txt
    
  3. Configure the chart:

    expose:
      provider: cloudflare
      cloudflare:
        zone: example.com
        apiTokenSecret: tenax-cloudflare-api
        hostnamePattern: "sbx-{id}.example.com"
        allowEmails: ["*@example.com"]      # who may connect to an open tunnel
      cloudflared:
        enabled: true
    

Keep the hostnames one level under the zone (sbx-{id}.example.com, not sbx-{id}.db.example.com): Cloudflare's free certificate covers only *.example.com, and the client reaches the hostname over HTTPS. A database port can't be protected by a browser login, so developers run cloudflared, which tenax sandbox connect does for them.

With the token, Tenax finds the account from the zone, creates a tunnel named tenax (expose.cloudflare.tunnelName) or reuses it, and writes the tunnel's connector token into the Secret <release>-tunnel-token, from which the cloudflared pods start. It checks that Secret every 15 minutes, so a token rotated in Cloudflare reaches the Secret by itself (restart the cloudflared pods to pick it up). allowEmails takes addresses and whole domains (*@example.com); allowGroups takes Access group ids.

A tunnel you already manage

Set expose.cloudflare.tunnelID to use an existing remotely-managed tunnel instead of tenax, and expose.cloudflared.tunnelTokenSecret to a Secret (key token) holding its connector token if you would rather provide it yourself; Tenax then leaves that Secret alone. Tenax only adds and removes its own sbx-… rules, so the tunnel's other routes stay as they are. accountID and zoneID can be set to skip the lookups.

The same tunnel can also serve the console itself: add a public hostname for tenax-api to the tunnel (Zero Trust → Networks → Tunnels → tenax → Public hostnames) behind an Access application, and set expose.cloudflared.consoleHostname with auth.provider: cloudflare-access: see Console, CLI and access.