How to Run Label Studio in a Cloud Sandbox on a Private URL
Updated October 27, 2026 .md
Short answer: Run the heartexlabs/label-studio Docker image on 127.0.0.1:8080 with a data volume, host the port privately with --localhost, and set CSRF_TRUSTED_ORIGINS to the hosted URL or every login fails with a 403. Use a personal access token for the API. It used about 210 MiB of RAM on a small sandbox.
What we ran#
Everything below ran on production on October 27, 2026: Label Studio 1.23.0 from heartexlabs/label-studio:latest (1.03 GB) on a small sandbox (2 vCPU, 4 GB). Timings are single runs.
| Step | Result |
|---|---|
docker pull |
16 s |
docker run until /health answers |
11.8 s |
| Container memory, idle with one project | 208 to 211 MiB |
Login form POST without CSRF_TRUSTED_ORIGINS |
403 CSRF verification failed |
| Same POST with it | 302 to / |
API call with the legacy Token header |
401, legacy tokens disabled |
| Create project, import 4 tasks, label 2, export twice (laptop, through the hosted URL) | 2.2 s for the whole script |
GET /api/projects/1 from a laptop |
224 to 240 ms round trip |
sbx stop --wait, then sbx resume |
2.2 s stop, 1.4 s resume, memoryRestored: true |
Reboot until the hosted URL answers /health again |
24 s |
small is enough for text labeling with a few people. If you import large images, audio or video, size the disk and memory for your data, not for Label Studio.
1. Host the port first, so you know the URL#
Label Studio needs its own public URL in its settings, so create the sandbox and host port 8080 before you start the container:
sbx new --name label-studio --type small --no-env
id=sb_fz9bqe5yvu # the id that sbx new printed
sbx host $id 8080 --localhost --title "Label Studio"https://coastal-light-cedar-8080.box.sandboxer.com?_token=<token>A route with nothing behind it yet just fails until the app starts. --localhost is there because the next step binds Docker to 127.0.0.1. Every sandbox has a public IPv6 address, and a plain -p 8080:8080 listens on every address. The sandbox firewall blocked such a port in our October 27, 2026 check, but with 127.0.0.1:8080 the private hosted URL is the only way in whatever the firewall does.
2. Start Label Studio with the proxy settings#
Label Studio's Docker install gives the image and the /label-studio/data volume. Keep the admin password out of shell history and logs by generating it into an env file inside the sandbox:
sbx exec $id "umask 077; printf 'LABEL_STUDIO_USERNAME=admin@example.com\nLABEL_STUDIO_PASSWORD=%s\nLABEL_STUDIO_USER_TOKEN=%s\nLABEL_STUDIO_DISABLE_SIGNUP_WITHOUT_LINK=true\n' \
\$(openssl rand -hex 16) \$(openssl rand -hex 20) > ~/ls.env"
B=https://coastal-light-cedar-8080.box.sandboxer.com # hosted URL without ?_token=
sbx exec $id --timeout 600 "docker run -d --name label-studio --restart unless-stopped \
-p 127.0.0.1:8080:8080 -v lsdata:/label-studio/data --env-file ~/ls.env \
-e LABEL_STUDIO_HOST=$B -e CSRF_TRUSTED_ORIGINS=$B \
heartexlabs/label-studio:latest && \
until curl -sf localhost:8080/health >/dev/null; do sleep 0.5; done; ss -ltn | grep 8080"LISTEN 0 4096 127.0.0.1:8080 0.0.0.0:*LABEL_STUDIO_USERNAME and LABEL_STUDIO_PASSWORD create the first user on the first start. LABEL_STUDIO_DISABLE_SIGNUP_WITHOUT_LINK=true stops anyone with the URL from signing up on their own. The LABEL_STUDIO_USER_TOKEN line turned out to be useless in 1.23 (see step 4). The environment variables are listed in Label Studio's start options.
3. Why logins fail with a 403 without CSRF_TRUSTED_ORIGINS#
The proxy terminates HTTPS and forwards plain HTTP to the container. Your browser sends Origin: https://…box.sandboxer.com with the login form, and Django, which Label Studio is built on, rejects a POST whose origin it doesn't trust. We ran the same browser-style login (GET the form, POST it with its csrftoken) against three container configs:
| Settings | Login POST |
|---|---|
| Neither variable | 403, CSRF verification failed |
LABEL_STUDIO_HOST only |
403 |
LABEL_STUDIO_HOST and CSRF_TRUSTED_ORIGINS |
302 to / |
The container log names the cause:
[django.security.csrf::log_response::253] [WARNING] Forbidden (Origin checking failed - https://coastal-light-cedar-8080.box.sandboxer.com does not match any trusted origins.): /user/login/CSRF_TRUSTED_ORIGINS is the one that fixes it. Keep LABEL_STUDIO_HOST too, because Label Studio uses it to build its own links. Both take the full origin with https:// and no trailing path. If you rename the sandbox's subdomain, update both and recreate the container.
Recreating the container is safe: the project from step 5 was still there after two docker rm -f and docker run cycles, because the SQLite database and uploads live in the lsdata volume (label_studio.sqlite3, media/, export/).
4. Get an API token that works#
Open the full ?_token= URL in a browser, sign in as admin@example.com, and the UI works. How the URL token and the 30-day proxy cookie work is in Give a sandbox port an HTTPS URL.
For scripts, the old Authorization: Token … header no longer works on a new 1.23 install:
{"status_code":401,"version":"1.23.0","detail":"Authentication token no longer valid: legacy token authentication has been disabled for this organization"}Use a personal access token instead. Create one in the UI from your account settings, or from a logged-in session with POST /api/token/, which we did. It is a refresh token: trade it for a short-lived access token at /api/token/refresh, then send Authorization: Bearer …. Our check of GET /api/projects with the bearer token returned 200.
5. Create a project, import tasks and export from the API#
This script runs on your laptop. It needs ./jar, the proxy cookie from visiting the private URL once (curl -s -c jar -o /dev/null "<url with ?_token=>"), and ./ls_refresh, the personal access token, in a mode-600 file. It never prints either. The tasks are opening lines of four public-domain novels, labeled for tone. See Label Studio's API reference for every endpoint.
{"title": "Opening lines",
"label_config": "<View><Text name=\"text\" value=\"$text\"/><Choices name=\"tone\" toName=\"text\" choice=\"single\"><Choice value=\"Ironic\"/><Choice value=\"Somber\"/><Choice value=\"Plain\"/></Choices></View>"}[
{"text": "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.", "source": "Pride and Prejudice (1813)"},
{"text": "It was the best of times, it was the worst of times.", "source": "A Tale of Two Cities (1859)"},
{"text": "Call me Ishmael.", "source": "Moby-Dick (1851)"},
{"text": "Happy families are all alike; every unhappy family is unhappy in its own way.", "source": "Anna Karenina, tr. Constance Garnett (1901)"}
]#!/bin/bash
set -euo pipefail
B=https://coastal-light-cedar-8080.box.sandboxer.com
J=./jar
ACCESS=$(curl -s -b $J -H 'Content-Type: application/json' -X POST $B/api/token/refresh \
-d "{\"refresh\": \"$(cat ls_refresh)\"}" | jq -r .access)
H="Authorization: Bearer $ACCESS"
P=$(curl -s -b $J -H "$H" -H 'Content-Type: application/json' -X POST $B/api/projects -d @project.json | jq .id)
echo "project $P"
curl -s -b $J -H "$H" -H 'Content-Type: application/json' -X POST $B/api/projects/$P/import -d @tasks.json \
| jq -c '{task_count, duration}'
# label two tasks the way an annotator would
for pair in "1 Ironic" "2 Somber"; do
set -- $pair
T=$(curl -s -b $J -H "$H" "$B/api/tasks?project=$P" | jq ".tasks[$(( $1 - 1 ))].id")
curl -s -b $J -H "$H" -H 'Content-Type: application/json' -X POST $B/api/tasks/$T/annotations \
-d "{\"result\": [{\"from_name\": \"tone\", \"to_name\": \"text\", \"type\": \"choices\", \"value\": {\"choices\": [\"$2\"]}}]}" \
| jq -c '{task, id, result: [.result[].value.choices[]]}'
done
curl -s -b $J -H "$H" "$B/api/projects/$P/export?exportType=JSON_MIN" | jq -c '.[]'
curl -s -b $J -H "$H" "$B/api/projects/$P/export?exportType=CSV" | head -5project 1
{"task_count":4,"duration":0.018482446670532227}
{"task":1,"id":1,"result":["Ironic"]}
{"task":2,"id":2,"result":["Somber"]}
{"text":"It is a truth universally acknowledged, …","source":"Pride and Prejudice (1813)","id":1,"tone":"Ironic","annotator":1,"annotation_id":1,"created_at":"2026-10-27T10:44:02.655659Z",…}
{"text":"It was the best of times, it was the worst of times.","source":"A Tale of Two Cities (1859)","id":2,"tone":"Somber","annotator":1,"annotation_id":2,…}
"annotation_id","annotator","created_at","id","lead_time","source","text","tone","updated_at"
1,"1","2026-10-27T10:44:02.655659Z",1,"","Pride and Prejudice (1813)","It is a truth universally acknowledged, …","Ironic",…
2,"1","2026-10-27T10:44:03.159117Z",2,"","A Tale of Two Cities (1859)","It was the best of times, it was the worst of times.","Somber",…Three things to notice:
- Extra task fields come through.
sourceisn't used by the labeling config, but it is stored with the task and appears in both exports. - The export holds labeled tasks only. Four tasks were imported and two came back, the two with annotations. See Label Studio's export docs for the other formats and options.
- The proxy cookie and the bearer token do different jobs. The cookie gets the request past the private URL; the bearer token logs in to Label Studio. Without the cookie the proxy answers
401before Label Studio sees anything.
The whole script took 2.2 seconds. A single GET /api/projects/1 took 224 to 240 ms from our laptop, most of it the network path to the sandbox.
6. What survives a stop and resume#
sbx stop $id --wait
sbx resume $id
sbx exec $id "docker inspect -f '{{.State.StartedAt}} restarts={{.RestartCount}}' label-studio; curl -s localhost:8080/health"2026-10-27T10:44:33.311079916Z restarts=0
{"status": "UP"}The stop took 2.2 seconds and the resume 1.4 seconds, with memoryRestored: true. The container's start time was from before the stop, so nothing restarted. The export returned the same two labels, and the browser session cookie from step 3 still opened /projects/ with 200.
A resume boots from disk instead when no machine with the same CPU type is free (see snapshots). We checked that path by rebooting the sandbox: --restart unless-stopped started Label Studio again, the hosted URL answered /health 24 seconds after the reboot command, and the project still had 4 tasks and 2 annotations. Hosted routes and their tokens survive both, so shared links keep working.
An idle sandbox pauses itself after idleStopMinutes (30 on our test sandbox). Annotators' requests to the hosted URL count as activity. See Idle auto-pause and wake on use.
7. Clean up#
sbx delete $id --yesA small sandbox costs $0.018/hour while it runs and nothing while it is stopped. Export your annotations before you delete it: the volume goes with the sandbox.
FAQ#
Why does the Label Studio login page say "CSRF verification failed"?#
Label Studio is behind an HTTPS proxy and doesn't trust the browser's origin. Set CSRF_TRUSTED_ORIGINS to the hosted URL, for example https://coastal-light-cedar-8080.box.sandboxer.com, and recreate the container. LABEL_STUDIO_HOST alone did not fix it in our test.
Why does my API token return 401 "legacy token authentication has been disabled"?#
New Label Studio installs turn off the old Token header. Create a personal access token, trade it at /api/token/refresh for an access token, and send Authorization: Bearer <access token>.
Can annotators use the URL without a Sandboxer account?#
Yes. Send them the full private URL with ?_token=; the first visit sets a cookie, and Label Studio's own login comes next. With LABEL_STUDIO_DISABLE_SIGNUP_WITHOUT_LINK=true, they need an invite link from Label Studio to create an account. Treat the private URL like a password.
Where is my labeling data stored?#
In the lsdata Docker volume on the sandbox disk: a SQLite database plus media/ for uploads. It survives container recreation, stop and resume, and reboots. It does not survive sbx delete.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.