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.imageon the target), in Tenax's sandbox namespace, with generated credentials and a default-deny NetworkPolicy. - A TTL (
sandbox.defaultTTL, at mostsandbox.maxTTL, both per policy and chart-wide). When it expires, Tenax deletes the sandbox and its storage.tenax sandbox extend <sandbox> --by 24hbuys 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:
- 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, orspec.destination.sandboxof a Restore; - the target:
spec.sandbox.cpu,memory,sizeLimit,storageClassof the BackupTarget; - the installation: the chart's
sandbox.resources; - 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 theoperatorrole. - 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.
- 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
-
Store it:
-
Configure the chart:
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.