# How to Run Label Studio in a Cloud Sandbox on a Private URL

> 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.

Source: https://sandboxer.com/guides/run-label-studio-in-a-sandbox. Updated 2026-10-27.

**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:

```bash
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"
```

```text
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](https://labelstud.io/guide/install#Install-with-Docker) 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:

```bash
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"
```

```text
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](https://labelstud.io/guide/start).

## 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:

```text
[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](/guides/give-a-sandbox-port-an-https-url).

For scripts, the old `Authorization: Token …` header no longer works on a new 1.23 install:

```text
{"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](https://labelstud.io/guide/access_tokens) 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](https://api.labelstud.io/) for every endpoint.

```json
{"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>"}
```

```json
[
 {"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)"}
]
```

```bash
#!/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 -5
```

```text
project 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.** `source` isn'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](https://labelstud.io/guide/export) 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 `401` before 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

```bash
sbx stop $id --wait
sbx resume $id
sbx exec $id "docker inspect -f '{{.State.StartedAt}} restarts={{.RestartCount}}' label-studio; curl -s localhost:8080/health"
```

```text
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](/docs/snapshots#resume)). 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](/guides/idle-auto-pause-and-wake-on-use).

## 7. Clean up

```bash
sbx delete $id --yes
```

A `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

- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Run Metabase in a cloud sandbox](/guides/run-metabase-in-a-sandbox)
- [What can reach a sandbox and what it can reach](/guides/what-can-reach-a-sandbox-and-what-it-can-reach)
- [Hosting](/docs/hosting)
