# How to Run a Streamlit App in a Cloud Sandbox With an HTTPS URL

> Install Streamlit in a venv, run it as a systemd service on port 8501, then run sbx host <id> 8501. The private URL's token gates the page and the /_stcore/stream WebSocket. Keep Streamlit's CORS and XSRF protection on: uploads and reruns worked through the proxy with the default settings.

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

**Short answer:** Install Streamlit in a venv, run it as a systemd service on port 8501, then run sbx host <id> 8501. The private URL's token gates the page and the /_stcore/stream WebSocket. Keep Streamlit's CORS and XSRF protection on: uploads and reruns worked through the proxy with the default settings.

## What we ran

Everything below ran on prod on 2026-10-27, on one `small` sandbox (2 vCPU, 4 GB) with Python 3.12.3 and Streamlit 1.66.1. Tokens and cookies are replaced with `<token>` and `<cookie>`. For how hosted URLs, tokens and cookies work in general, see [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url). This page covers what is specific to Streamlit.

| Step | Result |
| - | - |
| `pip install streamlit` in a fresh venv | 18.5 s |
| Service start to `/_stcore/health` answering `ok` | 0.62 s |
| App drawn in headless Chromium through the private URL | 2.0 s from the first request |
| Request or WebSocket without the token | `401` |
| File upload with CORS and XSRF protection left on | Worked |
| Browser tab left open across `sbx stop --wait` and `sbx resume` | Reconnected, `st.session_state` kept |
| Process killed with `kill -9` | systemd restarted it, healthy again in 0.87 s |

## Why Streamlit needs a WebSocket-capable proxy

A Streamlit page is almost empty HTML. The app itself arrives over one WebSocket at `/_stcore/stream`: every widget, every rerun and every chart goes through it. A proxy that serves the HTML but drops the WebSocket upgrade gives you a page that spins forever. Sandboxer's hosting proxy passes WebSockets through ([Hosting](/docs/hosting)), so nothing needs to change in Streamlit.

The other endpoints that matter:

| Path | What it does | Through a private hosted URL |
| - | - | - |
| `/` | HTML shell | `401` without the token, `200` with the cookie |
| `/_stcore/stream` | The app's WebSocket | `401` without the cookie, `101` with it |
| `/_stcore/health` | Returns `ok` when the server is up | `401` without the cookie, `ok` with it |
| `/_stcore/upload_file/…` | `st.file_uploader` uploads, checked against the `_streamlit_xsrf` cookie | Worked from the browser |

## 1. Install Streamlit

The [Streamlit install docs](https://docs.streamlit.io/get-started/installation/command-line) use a virtual environment and `pip install streamlit`. The sandbox already has Python 3.12:

```bash
sbx new --name streamlit --type small
sbx exec sb_emnyl4g91w --timeout 600 "mkdir -p ~/st-app && cd ~/st-app && python3 -m venv .venv && time .venv/bin/pip install -q streamlit; .venv/bin/streamlit version"
```

```text
real	0m18.533s
…
Streamlit, version 1.66.1
```

`sbx exec` stops after 30 seconds by default, so give installs a `--timeout`.

## 2. A test app

This app exercises the parts that break behind a bad proxy: a button that changes `st.session_state` (WebSocket round trips), a cached function, and a file upload (XSRF check). Save it as `~/st-app/app.py` in the sandbox:

```python
import time
import pandas as pd
import streamlit as st

st.title("Hello from a sandbox")

if "clicks" not in st.session_state:
    st.session_state.clicks = 0
if st.button("Click me"):
    st.session_state.clicks += 1
st.write(f"Clicks this session: {st.session_state.clicks}")

@st.cache_data
def slow_square(n: int) -> int:
    time.sleep(2)
    return n * n

t = time.perf_counter()
v = slow_square(12)
st.write(f"slow_square(12) = {v} in {time.perf_counter() - t:.2f} s")

up = st.file_uploader("Upload a CSV", type="csv")
if up is not None:
    df = pd.read_csv(up)
    st.write(f"Uploaded {up.name}: {len(df)} rows, columns {list(df.columns)}")
```

## 3. Run it as a service

Running `streamlit run` from `sbx exec --detach` works, but a systemd unit restarts it if it crashes and starts it again when the sandbox boots from disk:

```bash
sbx exec sb_emnyl4g91w 'sudo tee /etc/systemd/system/streamlit.service >/dev/null <<EOF
[Unit]
Description=Streamlit app
After=network-online.target

[Service]
User=user
WorkingDirectory=/home/user/st-app
ExecStart=/home/user/st-app/.venv/bin/streamlit run app.py --server.port 8501 --server.headless true
Restart=always

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now streamlit'
```

`--server.headless true` stops Streamlit from trying to open a browser or ask for an email on first run. You don't need `--server.address 0.0.0.0`: with no address set, Streamlit already listens on every interface, so the hosting proxy can reach it without `--localhost`:

```bash
sbx exec sb_emnyl4g91w "curl -s localhost:8501/_stcore/health; echo; ss -ltn | grep 8501"
```

```text
ok
LISTEN 0      2048               *:8501            *:*
```

From `systemctl start` to the health endpoint answering took 0.62 seconds. Use `/_stcore/health` as your readiness check in scripts. `/` only serves the static HTML shell.

## 4. Give it a private URL

```bash
sbx host sb_emnyl4g91w 8501 --title Streamlit
```

```text
https://leafy-lunar-bugle-8501.box.sandboxer.com?_token=<token>
```

Without the token, the proxy answers and Streamlit never sees the request. That includes the WebSocket:

```bash
curl -si https://leafy-lunar-bugle-8501.box.sandboxer.com/ | head -1
curl -s -o /dev/null -w "%{http_code}\n" https://leafy-lunar-bugle-8501.box.sandboxer.com/_stcore/health
```

```text
HTTP/2 401
401
```

The token link returns a `302` that sets the 30-day `__sbx_auth` cookie. With that cookie, the WebSocket handshake to `/_stcore/stream` returned `101 Switching Protocols`, and without it `401`. A browser sends the cookie on its own, so opening the token link is all a viewer has to do.

We opened the link in headless Chromium (Playwright 1.64.1), clicked the button three times, reloaded, and uploaded a three-row CSV:

```text
first response: 200
app rendered after 2004 ms
after 3 clicks: Clicks this session: 3
after reload: Clicks this session: 0 | slow_square(12) = 144 in 0.11 s
Uploaded temps.csv: 3 rows, columns ['city', 'temp']
cookies: __sbx_auth, ajs_anonymous_id, _streamlit_xsrf
websocket: wss://leafy-lunar-bugle-8501.box.sandboxer.com/_stcore/stream frames received: 18
websocket: wss://leafy-lunar-bugle-8501.box.sandboxer.com/_stcore/stream frames received: 23
```

A reload starts a new session, so `st.session_state` goes back to 0, while `st.cache_data` is shared by the server process, so the 2-second function came back in 0.11 seconds. `ajs_anonymous_id` comes from Streamlit's usage statistics; turn it off with `browser.gatherUsageStats = false` ([config options](https://docs.streamlit.io/develop/api-reference/configuration/config.toml)).

## Leave CORS and XSRF protection on

Answers for running Streamlit behind a proxy often say to start it with `--server.enableCORS false --server.enableXsrfProtection false`. On a hosted URL you don't need either:

- The page, the WebSocket and the upload all come from the same origin (`https://<subdomain>-8501.box.sandboxer.com`). Streamlit's origin check passes and the `_streamlit_xsrf` cookie matches, so the upload above worked with both protections on.
- The origin check is doing real work. Inside the sandbox, a WebSocket handshake with a foreign `Origin` header got `403` from Streamlit, while the same handshake with the hosted URL as `Origin` got `101`:

```bash
# inside the sandbox, straight to Streamlit
Origin https://evil.example -> 403
Origin https://leafy-lunar-bugle-8501.box.sandboxer.com -> 101
```

Streamlit's [config docs](https://docs.streamlit.io/develop/api-reference/configuration/config.toml) also note that the two settings conflict, and that XSRF protection takes priority if it is on while CORS is off. If an upload fails with a `403`, check that you opened the app on its hosted URL and not through a second proxy or an iframe on another domain.

## Public or private?

| | Private (default) | `sbx host <id> 8501 --public` |
| - | - | - |
| Who can open it | Anyone with the token link or its cookie | Anyone |
| WebSocket needs | The `__sbx_auth` cookie, which the browser has after the token link | Nothing |
| Use it for | Internal tools, apps that read your data or secrets | Demos with no sensitive data |

Streamlit has no login of its own in this setup. A public URL runs your app, with its database credentials and API keys, for any visitor. Switching public and back to private mints a new token, which cuts off old links.

## Stop, resume and crashes

We left the browser tab open (after clicking twice), stopped the sandbox, waited 20 seconds, and resumed it:

```bash
sbx stop sb_emnyl4g91w --wait      # 2.2 s; the URL returned 404 while stopped
sbx resume sb_emnyl4g91w           # 1.3 s, memoryRestored: true
```

```text
before stop: Clicks this session: 2
after resume + 1 click: Clicks this session: 3 (1528 ms)
```

The Streamlit process was the same one (`MainPID=673` before and after). The open tab's WebSocket reconnected to it, and the session kept its state, so the next click counted to 3. Memory comes back only when the resume lands on a machine with the same CPU type. Otherwise the sandbox boots from disk (`memoryRestored: false`): the venv, the app and the hosted route are kept, and the enabled unit starts Streamlit again, but sessions and `st.cache_data` start empty. We didn't force a disk-only boot in this test.

Crash recovery is quick. After `sudo kill -9` on the Streamlit process, systemd started a new one (`NRestarts=1`) and `/_stcore/health` answered again 0.87 seconds later. A new process has no sessions, so state from before the crash is gone.

## FAQ

### Why does my Streamlit page spin forever?

The HTML loaded but the `/_stcore/stream` WebSocket did not connect. On a private URL that happens when the handshake has no `__sbx_auth` cookie, for example in a script that sent `?_token=` on the WebSocket URL. Open the token link first, or see the cookie example in [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url).

### Do I need --server.address 0.0.0.0?

No. With no address set, Streamlit 1.66.1 listened on all interfaces (`*:8501`). If you set `server.address` to `127.0.0.1`, host the port with `--localhost`.

### Should I turn off enableCORS or enableXsrfProtection?

No. The hosted URL serves everything from one origin, so uploads and the WebSocket work with both on. Turning them off removes Streamlit's check against other sites driving your app.

### Which size do I need?

The test app ran on `small` (2 vCPU, 4 GB) with most of the memory free. Pick `default` (4 vCPU, 8 GB) if your app loads large dataframes or models.

## Related

- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Run code-server (VS Code in the browser)](/guides/run-code-server-vs-code-in-the-browser)
- [Run a Gradio app in a sandbox](/guides/run-a-gradio-app-in-a-sandbox)
- [Hosting](/docs/hosting)
