Public sandbox#
A demo instance anyone can try, with limits, that is deleted and rebuilt from nothing every 3 hours. It is an ordinary
workspace (see creating-workspaces.md) created with the profile sandbox: the profile
(deploy/profiles/sandbox.env) is a block of variables appended to the workspace's .env.<slug>. Nothing else
distinguishes it: the product reads the variables, the hardening overlay docker-compose.sandbox.yml is activated by
COMPOSE_FILE in that same file, so every command on the workspace (up, down, remove-workspace.sh) uses it.
This is an internal tool, not a supported feature for third parties: whoever wants one follows the procedure below.
What visitors get#
- One shared demo account. Its e-mail and password are public and the banner on every screen shows them (
Demo login: ...), so a visitor who lands on the login page knows what to type; put them on the page that links to the sandbox too. - The editor, the DAM, websites, pages, snippets, themes, the API Gateway towards the allowed hosts.
- Demo content, loaded again at every reset: a website "Northlight (demo)" with three pages (Home, Pricing, About), three snippets (navigation bar, announcement bar, footer) and the two kits that ship with the distribution ("Landing basics" and "Page sections", read-only: Duplicate or Edit a copy to change them). The kits are seeded by the core like on any installation; the website, pages and snippets by
scripts/sandbox/seed-demo.mjsat the end ofreset.sh(it runs with the Node of the core-api image, because the host's Node can be too old, and a failure only leaves the sandbox empty). The content is generated with the editor's own Element class byscripts/sandbox/demo/generate.mjs(outputs committed:demo/content.jsonandpackages/core/seed-data/kits/*.json): change the generator, runnode scripts/sandbox/demo/generate.mjs, commit the outputs. - A banner on every screen with the time of the next reset.
The profile: every limit is a variable#
Variable (in .env.<slug>) |
Default of the profile | Effect |
|---|---|---|
SANDBOX_MODE |
1 |
Switches the restrictions on; without it the same stack is a normal workspace |
ASSET_MAX_FILE_BYTES |
5242880 (5 MB) |
Per-file upload cap, answers 413 (usable outside the sandbox too; default there 50 MB) |
ASSET_MAX_COUNT |
50 |
Maximum number of assets; a new asset or _edited copy over it answers 409, overwriting a path is allowed |
ASSET_MAX_INPUT_PIXELS |
16000000 |
Image dimensions |
SANDBOX_ALLOW_HOSTS |
dummyjson.com |
The only host names outbound HTTP may reach (exact match, comma-separated); empty = none |
PUBLIC_RATE_LIMIT_PER_MINUTE, PUBLIC_SEARCH_..., PUBLIC_QUERY_... |
60 / 30 / 30 | Public API rate limits |
WS_MAX_BODY |
6M |
nginx request body cap (a 5 MB file plus multipart overhead) |
WS_EDGE_LIMITS |
10 requests/s per address with a burst of 40, 20 connections, 60 s timeouts | nginx directives rendered into the workspace's locations include |
SANDBOX_NO_NEW_PRIVILEGES |
true |
Container flag; see "snap Docker" below |
COMPOSE_FILE |
docker-compose.yml:docker-compose.sandbox.yml |
Activates the hardening overlay |
SANDBOX_RESET_AT |
set by reset.sh |
Time shown in the banner |
Change a value at creation with --set KEY=VALUE (repeatable), or edit .env.<slug> and recreate the containers
(docker compose -p ws-sandbox --env-file .env.sandbox up -d). The hardening itself (read-only root file system for the Node
services, no capabilities, memory/CPU/process limits) is in docker-compose.sandbox.yml and is not a variable.
Features that are off (answer 403 "disabled in the public sandbox")#
AI (/ai/*), mail (/mail-*, and sending itself), object events (/object-event-*: they call outside the stack), the Figma import, the migration sources, the CDN mirror settings,
PWA generation, regenerating a website's gateway key, the security and single sign-on settings and the data subject tools. Outbound HTTP through safeFetch is refused for every host that is not
in SANDBOX_ALLOW_HOSTS, checked before any DNS lookup and on every redirect hop; a listed host still goes through the
normal SSRF checks (no private or loopback addresses). So a visitor can create gateway endpoints and routes, but only towards
the allowed hosts: Cloudinary, Brevo and CDN mirroring stay off. The path list lives in packages/core/src/sandbox.ts.
Users and roles are read-only for the demo account (it can list them, not create, change or delete them, reset passwords or
change its own MFA, or enrol it in two-factor at sign-in), so visitors cannot lock each other out.
Procedure#
Needs a host with Docker Compose 2.24 or newer, a DNS name pointing to it, and nothing else of value on it.
- Clone the repository and enter it. The reset timer expects
/opt/whoobe_project; anywhere else, editWorkingDirectoryandExecStartin the service file. - First start, by hand:
SANDBOX_HOST=sandbox.example.com ./scripts/sandbox/reset.sh(SANDBOX_SCHEME=httpor--scheme httpon a LAN without TLS). It removes a previous sandbox with its volumes, provisions the workspacesandboxwith--profile sandbox, waits for/healthto report the sandbox block, and printssandbox is up. Options, all environment variables:SANDBOX_SCHEME(httpon a LAN, defaulthttps),SANDBOX_INDEX(ports are derived from it: check they are free withss -ltn),SANDBOX_DEMO_EMAIL,SANDBOX_DEMO_PASSWORD,SANDBOX_INTERVAL_HOURS, and the overridesSANDBOX_NO_NEW_PRIVILEGES,SANDBOX_ALLOW_HOSTS,ASSET_MAX_FILE_BYTES,ASSET_MAX_COUNT,ASSET_MAX_INPUT_PIXELS. - TLS on a public host:
scripts/enable-tls.sh sandbox --email <you>as for any workspace, once. The proxy then also serves port 443: if another service owns it on that host, use a dedicated host for the sandbox. With nginx on the host (the host-nginx path of creating-workspaces.md) link the vhosts once:sudo ln -sf $PWD/deploy/nginx/00-sandbox-zones.conf /etc/nginx/sites-enabled/00-sandbox-zones(the rate-limit zones belong to thehttpcontext), thensandboxandsandbox-sslthe same way, andsudo nginx -t && sudo systemctl reload nginx. Every reset removes the workspace, which deletes its two vhost files, and provisions it again, which renders the HTTP one;reset.shthen callsenable-tls.sh --reuse-cert(no request to Let's Encrypt, the certificate stays indeploy/certbot/conf) so the HTTPS vhosts are back at the same paths and the symlinks keep pointing at them. Between the removal and the end of the build the symlinks point at missing files: do not reload or restart the host nginx by hand in that window (a dangling link insites-enabledmakesnginx -tfail for every site), and renew the certificate with the weekly job, not during a reset. - Check it from outside:
scripts/sandbox/verify.sh https://<host> <demo e-mail> <demo password>(limits, disabled features, read-only users, public settings; it deletes the test assets it uploads). - Reset every 3 hours: copy
scripts/sandbox/whoobe-sandbox.env.exampleto/etc/whoobe-sandbox.env(mode 600) and fill in the variables of step 2 (a misspelled name is silently ignored); copyscripts/sandbox/systemd/whoobe-sandbox-reset.serviceand.timerto/etc/systemd/system/. If the clone is not in/opt/whoobe_project, or the user that owns it is not root, install the drop-inscripts/sandbox/systemd/override.conf.exampleas/etc/systemd/system/whoobe-sandbox-reset.service.d/override.conf(user and paths; running as the repository's owner keeps the generated files from belonging to root, and that user must be in thedockergroup). Thensystemctl daemon-reload,systemctl start whoobe-sandbox-reset.service(a first reset, which deletes the sandbox's data) andsystemctl enable --now whoobe-sandbox-reset.timer. Check withsystemctl list-timers 'whoobe*'andjournalctl -u whoobe-sandbox-reset -n 40. The service is stopped after 40 minutes and tried again after one: a dependency download that stalls does not leave the sandbox off until the next cycle.reset.shtakes a lock (a second reset exits at once), stops thedocker compose ... upof a killed earlier reset, and on a stop signal terminates everything it started: a snap-installed Docker client is not stopped with its systemd service and would otherwise stay behind waiting, blocking the next attempt. - Host firewall: in, only 80 and 443. Out, keep 443 and DNS open: the allowed API (
dummyjson.com) is behind a CDN whose addresses change, so the application list (SANDBOX_ALLOW_HOSTS) is the control, not an address filter. Not exercised yet on a public host.
To stop it: ./scripts/remove-workspace.sh sandbox --yes (deletes the data, as every reset does).
Who tried it: the visit log#
The sandbox writes its own anonymous visit log, /var/log/nginx/sandbox-access.log on a host with nginx (not the host's shared
access.log, where every site is mixed and the host name is missing). deploy/profiles/sandbox.nginx-http.conf defines the format
and WS_EDGE_LIMITS in deploy/profiles/sandbox.env turns it on for the sandbox's vhosts. What it holds, one line per request:
time, the address cut to its network (IPv4 /24, IPv6 /48: 81.10.20.0), method, the path without the query string, status,
time taken, referrer, browser. Static files and /health are not written. The host's logrotate keeps 14 days (daily, compressed).
Nobody is identified: tell visitors so on the page that links to the sandbox.
To leave your own visits out (or a monitor's), put the addresses in SANDBOX_UNTRACKED_IPS in /etc/whoobe-sandbox.env, space or
comma separated, CIDR ranges allowed (203.0.113.7, 2001:db8::/48). The next reset writes them into the geo list of
deploy/nginx/00-sandbox-zones.conf (an entry that is not an address is left out with a warning); then sudo nginx -t && sudo systemctl reload nginx once outside the reset. Requests from those addresses still reach the sandbox; only the log skips them. Requests from
ClaudeBot are not written either (they were about half of the log: robots.txt and the sitemap); other robots are written and
counted apart by visits.sh. Lines written
before stay in the log until logrotate drops them (14 days).
Read it with scripts/sandbox/visits.sh [days] (on the host, or ssh <host> 'cd /opt/whoobe/whoobe_cms && scripts/sandbox/visits.sh 7';
the user needs the adm group to read the log): requests, visitors (distinct network + browser, robots left out), logins
(successful sign-ins of the demo account), active (signed in and changed something), robot requests, where visitors came from
(referrer hosts) and the most requested pages. The figures are an order of magnitude, not a head count: people behind one address with
the same browser count once, one person on mobile data who changes network counts more than once.
The first time (a sandbox set up before the visit log existed): the next reset rewrites the profile files and the vhosts together;
then sudo nginx -t && sudo systemctl reload nginx once outside the reset (never during it, see step 3). With the containerised
proxy instead of host nginx the file is inside that container's /var/log/nginx.
How the reset works#
scripts/sandbox/reset.sh first builds the images with the running sandbox's env file (the slow part, minutes when the code
or the build context changed, seconds when everything is cached; the sandbox keeps serving meanwhile), then removes the workspace
with all its volumes (database and object store), provisions it again with new
random secrets and the demo account, and waits until /health reports the sandbox block. If anything fails the stack is
removed and the script exits non-zero: the sandbox stays off, it never keeps serving the previous cycle. Expect
under a minute of downtime (stop, start, migrations), whatever the build costs; the very first start, and a reset after a profile
change that alters a build argument, still build inside the provisioning. Because that second build happens after the workspace was
removed, the sandbox is off for the whole of it: after new code reaches the host, the first reset can leave it off for several
minutes (on 2026-10-20, on a 4-CPU arm64 host with a load of 8 to 12, the two builds together took more than 15 minutes and the
retry brought it back). That is why the service waits up to 40 minutes. The banner shows the next reset time.
Disk. Every reset builds again, and the build cache of the Docker daemon grows by gigabytes: on 2026-10-21 the host held 21.5 GB of it
on a 45 GB disk, and 20 GB again a day after a manual prune, because a cold deploy plus the 3-hourly builds leave only recent layers
(an age filter such as until=48h frees nothing then). Once the sandbox is up, reset.sh runs
docker builder prune -f --reserved-space 4GB --max-used-space 8GB: the cache is capped at 8 GB and the 4 GB most recently used stay,
so the next build is warm. These are the Docker 29 flags (--keep-storage no longer exists). The daemon is shared with the other
workspaces of the host, so their cache is capped too; images, volumes and containers are not touched. A failure of the prune is only reported.
After a deploy, start the reset yourself right away (systemctl start whoobe-sandbox-reset.service) instead of waiting for the
timer, and do it when nobody is expected: the first run after a code change is the slow one, the next ones use the cache.
Known limits of this design#
no-new-privilegesis on by default. With a snap-installed Docker (AppArmor) it makes images such as Postgres fail withexec ... docker-entrypoint.sh: operation not permitted; on such a host passSANDBOX_NO_NEW_PRIVILEGES=false(in/etc/whoobe-sandbox.env), and prefer the Docker Engine packages for a public host.- All visitors share one workspace: they see each other's content. Content is public for up to 3 hours, so moderate the page that advertises the sandbox accordingly and watch the host's bandwidth.
- Visitors can make the server call the allowed API from its address; the rate limits bound it, they do not remove it.
- The egress firewall and TLS have not been exercised yet; do it when the sandbox goes on a public host.
Last updated