What is stored where
Everything TestVibe owns lives in exactly two places, and on the appliance both of them are on the data disk. There is no third store and nothing goes to a cloud bucket.
The object store on self-hosted
On the cloud, TestVibe uses Azure Blob. Self-hosted swaps that for a small first-party
service — the blob server — that stores objects on a plain
filesystem path. That swap is already wired for you:
TV_STORAGE_PROVIDER=http is set by both shipped deployments and is not
something you configure.
| Deployment | Objects land in | Blob server listens on |
|---|---|---|
| Appliance | /var/lib/testvibe/blob (disk 2) |
0.0.0.0:8090 |
| Docker Compose | /data in the blobserver container, backed by the
testvibe_blobdata volume |
container 8080, published as 8080 |
| Kubernetes | the persistent volume the chart mounts at
TV_BLOBSERVER_ROOT |
the chart's blobserver service |
Objects are keyed
accounts/{account}/projects/{project}/runs/{run}/…, so what belongs to
which workspace and project is legible on disk — useful when you are sizing or
auditing, and the reason per-run and per-project deletion can be exact.
The two object-store URLs
This is the configuration that produces the most confusing symptom in the whole product, because when it is wrong everything else keeps working.
Why there are two
A Playwright trace is downloaded by the user's browser, directly from the object store — not proxied through the application. So the store needs one address for machines inside the deployment and a different one for people outside it.
| Key | Who uses it | Typical value |
|---|---|---|
TV_BLOBSERVER_URL |
The application and the test runner, inside the deployment — artifact uploads and run bundles. | http://127.0.0.1:8090 (appliance)http://blobserver:8080 (compose) |
TV_BLOBSERVER_PUBLIC_URL |
Your users' browsers — traces, videos, screenshots, asset previews. | http://<your-host>:8090 (appliance)http://<your-host>:8080 (compose) |
https://testvibe.yourcompany.com:8090 — on Compose substitute 8080, and
behind a reverse proxy substitute whatever route you published.
Set it correctly
- Appliance: console → Addresses → Artifact URL. On first boot the appliance seeds it from the address DHCP handed out, and the field stays editable — which matters, because an appliance has no OS login and a moved lease would otherwise break every trace link permanently. If the address changed, fix it here.
- Docker Compose: set
TV_BLOBSERVER_PUBLIC_URLin.env. It defaults tohttp://localhost:8080, which works only for someone sitting at the host — change it as soon as anyone else uses the install..envTESTVIBE_PUBLIC_BASE_URL=https://testvibe.example.com TV_BLOBSERVER_PUBLIC_URL=https://artifacts.testvibe.example.com
Pinned in the environment means the console shows both fields locked, which is correct for a config-as-code deployment.
- Behind a reverse proxy: publish a route to the blob server and put that route here. If the app is on HTTPS, the artifact URL must be too — a browser on an HTTPS page will refuse to fetch a plain-HTTP trace.
- Prove it. Run one test, open the run, and open its trace from a normal user's desktop. Nothing else in the product exercises this path, which is exactly why it goes unnoticed until someone needs a trace.
Keys and CORS
The object store is not open. Reads and writes carry short-lived signed tokens, and server-to-server calls carry an admin bearer. Both keys are generated for you.
| Key | What it protects | Rules |
|---|---|---|
TV_BLOBSERVER_SIGNING_KEY |
The signed ?token= URLs the app hands to a browser or to the
runner. |
Must be identical on the web service and the blob server. Rotating it invalidates outstanding links; since they are short-lived, that is a brief inconvenience rather than data loss. |
TV_BLOBSERVER_ADMIN_KEY |
Server-to-server administrative routes on the blob server. | Must be identical on both. Never exposed to a browser. |
TESTVIBE_SECRET_PROTECTION_KEY |
Encryption of stored secrets — connector tokens, SMTP passwords, hook signing secrets, console-stored credentials. | You do not set this one. On Compose and Kubernetes the
app mints its own key into its own database the first time it
needs one, and both the app and the console read it back from there — so
your database backup already contains it. .env.example ships
the variable commented out and the chart's
values.yaml says to leave it empty. If you do set one:
never rotate it. Everything encrypted with the old key
becomes unreadable. |
The appliance generates all three on first boot and stores them on the data disk.
The Compose .env.example ships the two blob keys as placeholders you
must replace before the first start; the protection key is commented out and should
stay that way. On Kubernetes the two blob keys are chart secret values and
secrets.protectionKey is deliberately left empty.
trace.zip from the object store's origin, which is a different origin
from the application. The blob server answers CORS on its public routes so this
works out of the box; if you put a proxy in front of it, do not strip those
headers.
What fills up
Usage is measured per workspace and split into eight domains. In practice one of them dominates and it is always the same one.
| Domain | What it is | Grows |
|---|---|---|
| Runs & traces | Playwright traces, videos, screenshots and the staged run bundle, per run. | Fastest, by far |
| Telemetry | Vitals history and application-error records from watched servers. | Steadily, while watched |
| Load testing | Load-run samples and sampled on-failure screenshots. | Per load run |
| Generation | Generation snapshots and discovery screenshots. | Per generation |
| Assets | The asset library, including visual-regression baselines. | When people upload |
| Assistant chat | Conversation history. | Slowly |
| Test Features | Your features and generated specs — the source of truth. | Slowly. Never deletable. |
| Project notes | Per-project notes. | Negligible |
Where users see it
- Settings → Storage (workspace) — the total, a per-domain breakdown with a meter, a "By project" card, and the retention controls. This panel is overview and policy; it has no delete buttons.
- Settings → Project → Project Storage — where deletion actually happens, scoped to the current project, per domain. Chat and notes require typing the project name to confirm.
- The Dashboard carries the same usage summary.
Numbers are refreshed by an hourly reconcile, and immediately when the panel is opened or an asset is deleted. A figure that looks stale usually is — reopen the panel.
Quota, retention and the reaper
This section is the one to read carefully. Storage quotas are a subscription concept that does not apply to a self-hosted install — but the housekeeping that was built around them is still running, and it does not know that.
What is switched off on self-hosted
The enforcement gates all short-circuit. Nothing is refused because of a quota:
- New runs, generations and load tests are never blocked by storage.
- Visual-regression baselines and PDF-import screenshots are never refused.
- The "Storage full" prompt never appears on a dispatch.
The hourly housekeeping sweep still compares each workspace against a plan quota, and still evicts data when it is over. A self-hosted workspace resolves to the default entitlement — 1 GiB — so a busy install passes it quickly. Once above it, every hour the sweep will:
- delete the oldest day of telemetry, and
- delete the oldest artifact-bearing run's trace, video, screenshots and bundle — the run row and its pass/fail result survive, the evidence does not.
It repeats each hour until the workspace is back under 1 GiB. Nothing warns you, and the run list simply stops offering a trace on older runs. If long trace retention matters to you, raise the entitlement before you accumulate data you cannot afford to lose — see the mitigation below — and tell support@testvibe.com that this is your situation.
Mitigations, best first
- Raise the storage entitlement in the database. This is the
direct fix and it is one additive statement. Back up first, and run it against
the TestVibe database.
psql — raise the entitlement to 500 GB
-- what each workspace currently resolves to SELECT account_id, name, plan_code FROM tv_account; -- raise the entitlement on the plan(s) actually in use UPDATE tv_plan SET storage_bytes_included = 536870912000 WHERE plan_code IN (SELECT DISTINCT plan_code FROM tv_account);
On Docker Compose:
docker compose exec postgres psql -U tvadmin -d testvibe. On Kubernetes, exec into the database pod. On the appliance there is no OS login, so this route is not available to you — use the next two, and contact support. - Set retention deliberately, so you stay under whatever the ceiling is. Settings → Storage → per-domain retention. Runs have no default retention (they are kept until quota pressure — which is precisely what the eviction above is); telemetry defaults to 30 days. Setting an explicit runs retention of, say, 14 days turns an unpredictable eviction into a predictable policy.
- Delete deliberately. Settings → Project → Project Storage, per domain. Deletes are never gated, by design — they are the recovery path.
- Grow the disk. That solves running out of space; it does not stop the eviction above, which is measured against the entitlement and not against free space.
Two more behaviours that surprise people
- The meter and the percentage in Settings → Storage are cosmetic on self-hosted. They are drawn against the same 1 GiB entitlement, so a healthy install can legitimately read "12.4 GB of 1.0 GB · 100%". Nothing is being refused because of it — with the one exception below.
- Asset uploads are the exception, and they are refused. The asset-upload path checks the quota itself rather than going through the exempted gate, so on a self-hosted install over the entitlement, Settings → Assets will refuse to open the file picker and the server will refuse the whole batch with "only N of your 1.0 GB storage quota is free". Raising the entitlement as above is the fix; there is no setting for it.
- Dormant workspaces on the default entitlement have their telemetry purged after 30 days of inactivity. Runs, features and specs are not touched by that sweep — only telemetry.
Reclaiming space
When the disk is genuinely filling, work down this list. Deletes always work, including in the licence read-only state.
- Find out which project. Settings → Storage → the "By project" card. It is almost always one project with a nightly full suite.
- Clear the biggest domain for that project. Settings → Project → Project Storage → the Runs & traces row. This removes the artifacts and the staged bundles; the run rows and their results stay, so history and trends survive.
- Then set a retention policy so you do not do this again. Settings → Storage → the retention field on the same domain.
- Check the free space actually moved.
appliance
df -h /var/lib/testvibe /var/lib/postgresql du -sh /var/lib/testvibe/blob
docker composedocker system df -v | grep testvibe_ docker compose exec blobserver du -sh /data
Backups
- Appliance: the data disk is the backup unit. Snapshot or back up disk 2 and you have the database, every artifact, the secrets, the install ID and the licence. The OS disk holds nothing of yours.
- Compose / Kubernetes: back up the Postgres volume and the blob volume together. Restoring one without the other gives you runs whose traces 404, or orphaned files nothing references.
- Appliance only: keep a copy of
TESTVIBE_SECRET_PROTECTION_KEYwith the backup. On Compose and Kubernetes the key lives inside the database, so your database backup already has it. - Backup and restore has the step-by-step procedure for each shape, including the restore ordering that avoids the mismatch above.
Troubleshooting
| Symptom | Almost always | Fix |
|---|---|---|
| Runs pass but the trace will not open; screenshots are broken images. | TV_BLOBSERVER_PUBLIC_URL points somewhere the browser cannot
reach — the internal name, localhost, or the wrong port. |
Section 02. |
| Traces open for you on the host, not for anyone else. | The public URL is http://localhost:… — correct only from the
host itself. |
Set it to the address your users use. |
| Mixed-content or CORS errors in the browser console on the trace viewer. | The app is on HTTPS and the artifact URL is on plain HTTP, or a proxy is stripping CORS headers. | Serve both over the same scheme; leave the blob server's CORS headers intact. |
| Runs fail with upload errors. | The internal URL or the signing/admin keys disagree between the web service and the blob server. | Section 03 — the keys must be identical on both. |
| Older runs quietly lose their traces. | The over-entitlement eviction described in section 05. | Raise the entitlement and set an explicit retention policy. |
| Asset upload refused with a quota message on a box with plenty of disk. | The asset path is not exempt from the quota check. | Section 05, mitigation 1. |
| "Runs & traces 0 B" while the disk is clearly full. | The reconcile has not run since a bulk change. | Reopen Settings → Storage, which forces a reconcile, or wait an hour. |
Where to go next
- The installation guide — backup and restore, and the full port map.
- Licensing · Sign-in · AI providers · Integrations · Capacity.
- support@testvibe.com — especially if you need long trace retention on an appliance.