sidepage
Local-first hosting and tunneling for code, static sites, and notebooks.
sidepage serve wraps almost anything — a script, a static site, a
Streamlit or FastAPI app, a Python MCP server, a Jupyter notebook —
behind a local reverse proxy and hands you a URL. sidepage new
scaffolds a static site to get started.
Status: serve, secrets, inspect, and bring-your-own-domain
tunneling are real and tested end to end. Features that need a Sidepage
cloud backend that doesn't exist yet (brokered tunneling, account login,
the directory beyond this machine) print a clear "not implemented"
message instead of failing silently or being left out of the CLI. See
Project status for the full breakdown.
Install
Requires uv (it fetches Python 3.12+ for you if needed).
uv sync
uv run sidepage --help
Or activate the venv and call it directly: source .venv/bin/activate && sidepage --help.
Quickstart
# Serve a static site
uv run sidepage serve tests/fixtures/static-site --name demo
# Serve a Streamlit app, gated behind a token
uv run sidepage serve tests/fixtures/streamlit-app/app.py --name demo --auth token
# Serve a FastAPI app — /docs (Swagger UI) works automatically
uv run sidepage serve tests/fixtures/fastapi-app/app.py --name demo
# Serve a Python MCP server over real Streamable HTTP — even if its own
# __main__ only ever calls mcp.run() (stdio), sidepage never runs that
# entrypoint, so it's reachable at /mcp regardless
uv run sidepage serve tests/fixtures/mcp-app/app.py --name demo
# Serve a Jupyter notebook — a full, editable Lab instance with a live
# kernel, reachable through the proxy like anything else
uv run sidepage serve tests/fixtures/notebook-app/notebook.ipynb --name demo
# Inject a secret and expose it over a real public tunnel
uv run sidepage secrets set MY_KEY
uv run sidepage serve some_app.py --env MY_KEY --anon
# Auto-stop after 30 minutes of no traffic, and inject another running
# app's URL as SIDEPAGE_PEER_API_URL
uv run sidepage serve frontend.py --idle-timeout 1800 --peer api=backend
Every serve call blocks the terminal until Ctrl+C (or sidepage stop <app-name> from another terminal), tearing everything down immediately —
no background/daemon mode.
How it works
Two things sit between "just run a script" and what serve does:
- A local reverse proxy runs on your machine in front of the app's
real port. It enforces
--auth, counts usage, shows a holding page while the app boots, and proxies HTTP + WebSockets — the wrapped app itself needs zero Sidepage-specific code. - A tunnel, chosen per call:
--anonfor a free, no-account*.trycloudflare.comURL, or--domain <domain>for your own Cloudflare domain (see Bring your own domain below). Without either flag,servejust listens on127.0.0.1.
Commands
| Command | What it does |
|---|---|
sidepage serve <target> |
Wrap and host a static dir, script, or app — see flags below. |
sidepage stop <app-name> |
Tear down a running app. |
sidepage ls / sidepage status <app-name> |
List / check apps running on this machine. |
sidepage usage <app-name> |
Request and connection counts for an app. |
sidepage inspect [<app-name>] |
Interactive HTTP console against a running app. |
sidepage secrets set|list|remove |
Encrypted local vault for standing credentials. |
sidepage account domain set |
Provision a BYO Cloudflare domain — see below. |
sidepage new <name> |
Scaffold a static site. |
sidepage app register "<invocation>" <name> |
Save a serve invocation under a short name. |
sidepage app list / show <name> / unregister <name> |
Manage saved apps — see below. |
sidepage promote <app-name> |
Widen an app's discovery scope. Not yet meaningful — only local scope exists today. |
sidepage login / sidepage account status |
Not implemented — no Sidepage account backend to talk to yet. |
serve's main flags:
sidepage serve <target> [--type auto|code|static|notebook] [--name <app-name>]
[--auth open|token] [--anon | --domain <domain>]
[--token <value>] [--env <SECRET_NAME>]...
[--timeout <seconds>] [--idle-timeout <seconds>]
[--peer <role>=<app-name>]...
--typeis usually inferred:codetargets are auto-detected as Streamlit, FastAPI, or a Python MCP server (officialmcpSDK or the third-partyfastmcppackage) and launched with their real launcher (streamlit run,uvicorn <module>:<app>, oruvicorn --factory <module>:<mcp-var>.<app-method>); anything else falls back to a generic$PORT-reading launch.notebook(.ipynb) targets get a full, editable Jupyter Lab instance with a live kernel. MCP servers are launched by bypassing their own entrypoint entirely (same trick as FastAPI) — a script whose__main__only callsmcp.run()(stdio, the default) still ends up served over real Streamable HTTP at/mcp, since that entrypoint is never executed.--auth open|token—tokengates the app behind a header, query param, or browser cookie set by a gate page. (network/oauthparse but aren't built.)--env <SECRET_NAME>— repeatable; injects a named vault secret into the wrapped process's environment. Fails loud if the name isn't stored.--anon/--domainare mutually exclusive — see How it works.--timeout <seconds>/--idle-timeout <seconds>— auto-teardown; see Timeouts, lazy start, and peers below.--peer <role>=<app-name>— repeatable; wire one served app to another's URL. Same section below.
Run sidepage <command> --help for the full flag list, including ones
that parse but aren't implemented yet (they report that clearly rather
than silently doing nothing).
Timeouts, lazy start, and peers
Auto-teardown. --timeout <seconds> stops the app once its total
lifetime (from serve start) reaches the limit; --idle-timeout <seconds> stops it once that many seconds pass with no proxied HTTP
request or WebSocket message — the timer resets on every one. Both are
composable with each other and checked in the same blocking loop Ctrl+C
already interrupts, so an auto-stop tears down exactly like sidepage stop would: immediately, no drain window.
sidepage serve demo.py --idle-timeout 1800 # stop after 30 idle minutes
sidepage serve demo.py --timeout 3600 # stop after 1 hour no matter what
Lazy start. For code/notebook targets, the wrapped process isn't
launched at serve time — it launches on the first inbound request,
behind the same "starting…" holding page a slow boot already shows. A
serve call that nobody ever hits never spends the CPU/memory to boot
the wrapped app at all. (static targets are already in-process and
instant, so there's nothing to defer there.) This is automatic — no flag.
Peers. --peer <role>=<app-name> (repeatable) resolves another
currently running served app's URL and injects it as
SIDEPAGE_PEER_<ROLE>_URL in the wrapped process's environment — useful
for a frontend that needs to reach a backend whose tunnel URL doesn't
exist until it's actually served, and changes across --anon runs.
Resolution fails loud (nonzero exit, clear message) if the named peer
isn't running yet. The app can also re-resolve peers live, at any point,
via GET /.sidepage/peers.json — gated by the app's own --auth tier
like any other route — so a peer that restarts mid-session with a fresh
URL is never stale the way the boot-time env var would be. code/
notebook targets only; there's no subprocess to inject into for a
static target, so --peer on one is rejected up front.
sidepage serve backend.py --name backend
sidepage serve frontend.py --peer api=backend # $SIDEPAGE_PEER_API_URL in frontend's env
Saved apps (the local registry)
Save a serve invocation under a short name and re-run it without
retyping flags:
sidepage app register "abc.py --auth token" abc-app
sidepage serve abc-app
Any flag passed at serve time overrides the registered one for that
one run only — the saved registration itself is never changed:
sidepage serve abc-app --scope web # runs with --auth token (registered)
# but --scope web for just this run
sidepage app show abc-app prints the saved config; add --with "<flags>"
to preview the effective merged config before actually running it, e.g.
sidepage app show abc-app --with "--scope web".
A registered app's target is resolved once, at registration time — so
--type is stored as a concrete value (code, static, notebook),
never "auto." sidepage app register refuses a literal --token <value>: auth tokens are per-process and regenerate on every serve
call, so storing one would defeat the point of them being ephemeral.
--env <SECRET_NAME> is fine to save — it's a reference to a vault entry,
never the secret value itself.
sidepage app list
sidepage app unregister abc-app
Bring your own domain
Route apps through your own Cloudflare domain instead of
*.trycloudflare.com. One-time setup:
- Create a Cloudflare API token (dashboard → My Profile → API Tokens)
scoped to:
- Account → Cloudflare Tunnel → Edit
- Zone → DNS → Edit
- Zone → Zone → Read
- Store it in the vault, then provision the domain:
sidepage secrets set cf-api-token sidepage account domain set example.com --api-token-name cf-api-token
This creates one Cloudflare Tunnel for the whole domain and stores its run-token in the vault automatically — the CLI prints the vault name it landed under (cf-tunnel-token::example.com), since it was never typed by you. - Serve apps through it:
sidepage serve app.py --domain example.com
Every app served under the same domain shares that one tunnel — no new
Cloudflare resources or tokens per app. The shared cloudflared process
starts with the first app on a domain and stops with the last.
Project layout
src/sidepage/
├── cli.py Root Typer app
├── commands/ Argument parsing & help text — one module per command group
├── core/ The SDK: serve/tunnel/proxy orchestration, secrets vault, running-app registry, saved-app registry
└── config/ Local config paths (XDG-style, overridable via SIDEPAGE_HOME)
tests/
├── fixtures/ Real apps used as test targets (static site, Streamlit, FastAPI, MCP, notebook)
└── test_*.py Unit and integration tests
docs/
├── CHECKLIST.md Build status for every command and core module
├── OPEN_QUESTIONS.md Design decisions — resolved and still-open
└── SPEC_V5_DRAFT.md v5 proposals — timeout/lazy-start/--peer (built, this doc) plus still-parked ideas
Development
uv sync # install runtime + dev deps
uv run ruff check . # lint
uv run pytest # full suite (~4 min; mostly first-run dependency resolves)
Runtime dependencies are real, not stubs: Starlette, uvicorn, httpx, and
websockets back the reverse proxy; cryptography backs the secrets
vault. cloudflared and network access (for uv run to resolve wrapped
apps' dependencies) are expected to be available wherever tests run.
Project status
Real and tested end to end: serve for static, code, and notebook
targets (Streamlit/FastAPI/Python-MCP auto-detected, generic $PORT
fallback, full Jupyter Lab for .ipynb), open/token auth, --env
secret injection, --anon tunneling, BYO-domain tunneling (account domain set + serve --domain), secrets, stop/ls/status/usage,
inspect for HTTP/static targets, the local app registry (app register|list|show|unregister + serve <app-name>, with real one-off
override merging), --timeout/--idle-timeout auto-teardown, lazy start
for code/notebook targets (subprocess deferred to the first request), and
--peer <role>=<app-name> (boot-time env injection plus a live
GET /.sidepage/peers.json).
Not implemented, and reports that clearly rather than silently
no-op'ing: brokered (default) tunneling, login/account status, the
discovery directory beyond this machine, --guardrail, --auth network/oauth, MCP tool browsing in inspect, and the OS-keychain
backend for the secrets vault (encrypted-file only for
now).
See docs/CHECKLIST.md for the full per-feature
breakdown, and docs/OPEN_QUESTIONS.md for
design rationale behind what's resolved and what's still open.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sidepage-0.1.0.tar.gz.
File metadata
- Download URL: sidepage-0.1.0.tar.gz
- Upload date:
- Size: 74.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.9.21 {"installer":{"name":"uv","version":"0.9.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0a20e907e1e4cf92e8ac54f27ca38b79b6192e746267016383fab98b095da49b
|
|
| MD5 |
79774edc0073baaeb86fbb626e9913d0
|
|
| BLAKE2b-256 |
9a726c6611907659367c282228945cff419ac77af772f934eb342c6a68ce6696
|
File details
Details for the file sidepage-0.1.0-py3-none-any.whl.
File metadata
- Download URL: sidepage-0.1.0-py3-none-any.whl
- Upload date:
- Size: 95.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.9.21 {"installer":{"name":"uv","version":"0.9.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6afa99c3f880da7e320681f1f1aa106abe577e4d471bcbc427f6944732725021
|
|
| MD5 |
a7e6db1b22f01590aca4223fcfeaf2bb
|
|
| BLAKE2b-256 |
cf808c8a1a0c89b73224e6f3a1218e32d97ff131594c087ce7b94ebc16c05941
|