Ruscker
Ruscker is a high-performance portal and orchestrator for containerized web workloads. Behind a single proxy, it manages both:
- Container-per-session interactive apps — R/Shiny, Streamlit, Dash, Voilà, Jupyter, RStudio.
- Container-per-API stateless HTTP services — Plumber2, FastAPI.
Deployed as a single, ultra-lightweight static binary with instant startup, Ruscker comes fully equipped with an admin panel, real-time monitoring, and load balancing. It uses a familiar YAML schema, so migration is smooth and configuration is effortless.
How it works
Visitors and API clients hit one Ruscker process. It serves the landing page and admin UI, and reverse-proxies each request to the right app container — picking a replica, keeping Shiny sessions sticky, upgrading WebSockets, and rewriting URLs. When no replica can take the load (and the spec allows it), Ruscker asks the Docker daemon to spawn one; idle containers are reaped automatically.
Why Ruscker
Modern web workloads demand speed and minimal overhead. Ruscker is engineered to keep the runtime light while staying compatible:
- Zero-friction migration — bring your apps over with a familiar YAML schema, no rewrite.
- Single compiled binary — one artifact to ship and run, with a tiny idle footprint and instant startup.
- Batteries included — a proper admin panel, a live monitoring dashboard, and load balancing, out of the box.
In production
Ruscker is on v0.1.80 and runs in production today. Built for extreme efficiency, its idle footprint sits in the low tens of megabytes:
~540 MB → ~14 MB idle — measured on a real production deployment.
It handles complex, multi-spec configurations with no unsupported features during migration, and apps spawn on demand reliably. Releases are multi-arch and cosign-signed; the Roadmap tracks what’s shipped and what’s next.
What’s in the box
- Reverse proxy + load balancer with sticky sessions, WebSocket
forwarding, per-spec replica pools, an auto-scaler, and URL rewriting
(a generalized runtime shim patches
fetch,XMLHttpRequest,WebSocket,script.src,link.href, and more) so unmodified apps work behind a sub-path. - Container backend (Docker) that spawns app containers on demand,
applies per-container CPU/memory limits, and reaps idle ones. Per-spec
container-envandcontainer-cmdlet you configure notebook servers (Jupyter, RStudio) without custom images. - Admin panel — apps CRUD with a full advanced form, a unified
media library (built-in logos, uploads, drag-and-drop, “in use”
badges), an encrypted credentials store (AES literal or
${VAR}env-ref, resolved only at pull time), a landing-page editor (colors, intros, SEO, social meta, analytics, custom HTML blocks, header/footer logos with alignment and links), audit log, user accounts with Viewer / Editor / Admin roles, and a live monitoring dashboard (CPU/memory, live-follow logs, stop/restart). - Sub-path mounting: serve the whole portal under a prefix via
server.context-pathor--base-path. Health probes (/healthz,/readyz) stay at the root for load balancers. - Operations: graceful shutdown, structured (JSON) logging, per-API
rate limiting + CORS, request body-size limits, gzip/br compression,
immutable-versioned static assets, and an opt-in Prometheus
/metricsendpoint. - Distribution: a cosign-signed multi-arch container image
(
ghcr.io/strategicprojects/ruscker), a Debian package with a hardenedsystemdunit, static musl tarballs, and a Homebrew tap.
Where to next
- Quickstart — from zero to a running app in minutes.
- What Ruscker can serve — Shiny, Streamlit, Dash, FastAPI, JupyterLab, LLM UIs, BI tools, and more.
- Where Ruscker fits — what Ruscker is for and when to use it.
- Installation — Docker, the
.deb, orbrew. - Migrate an existing config — point Ruscker at your
existing
application.yml. - Configuration — the full YAML reference.
- The admin panel — what each screen does.
- Deploying in production — systemd + nginx.
- Roadmap — shipped phases and what’s planned.
What Ruscker can serve
Ruscker started as a ShinyProxy alternative, but the model underneath is more general: one container per session for stateful apps, one container per replica for stateless APIs. Anything that runs in a Docker container and speaks HTTP or WebSocket is a candidate — which makes Ruscker a portal runtime for containerized web apps, not just a Shiny host.
Each app becomes a card on the landing page and a route under
/app/{spec} (interactive) or /api/{spec} (stateless). Ruscker handles
spawning, sticky sessions, WebSocket upgrades, URL rewriting, load
balancing, and reaping idle containers.
Showcase demos
A fresh Ruscker install seeds 13 demo cards automatically. Three of them use own-fork images published on Docker Hub; the rest link to official docs or use well-known public images:
| Card | Image | Notes |
|---|---|---|
| Shiny | openanalytics/shinyproxy-demo:latest | R Shiny demo app, port 3838 |
| Shiny for Python | openanalytics/shinyproxy-shiny-for-python-demo:latest | Python, port 8080 |
| Jupyter | quay.io/jupyter/minimal-notebook:latest | token-less, base_url=/ |
| RStudio Server | rocker/rstudio:latest | per-session IDE, port 8787 |
| R Markdown | openanalytics/shinyproxy-rmarkdown-demo:latest | Shiny backend, port 3838 |
| Streamlit | openanalytics/shinyproxy-streamlit-demo:latest | port 8501 |
| Dash | milkway/ruscker-dash-demo:latest | our fork — serves at root, no env quirks; multi-arch |
| Quarto | milkway/ruscker-quarto-demo:latest | our fork — pre-rendered static HTML on nginx (~67 MB vs ~430 MB) |
| FastAPI | milkway/ruscker-fastapi-demo:latest | our fork — stateless API kind; multi-arch |
| Voilà | openanalytics/shinyproxy-voila-demo:latest | Jupyter notebooks as apps |
| Bokeh | (external link) | docs card, no container |
| Plumber | (external link) | docs card, no container |
| Ruscker | (external link) | docs card, no container |
The three own-fork images (milkway/ruscker-dash-demo, milkway/ruscker-fastapi-demo,
milkway/ruscker-quarto-demo) are the reference for “how to make a Ruscker-ready
container”: they serve at root, ship no SHINYPROXY_PUBLIC_PATH dependency,
and the Dash/FastAPI forks are multi-arch (amd64 + arm64).
Seeding is idempotent — it only runs once per database. If you delete a showcase card, it stays gone on subsequent restarts.
Interactive, stateful apps
There are two interactive spec kinds in Ruscker:
shiny— the Shiny model: state on the server, a reactive WebSocket connection per session. Sticky sessions and WebSocket forwarding are on by default. Covers R Shiny and Shiny for Python.app(interactive app) — same sticky-session + WebSocket behavior, but for apps that aren’t Shiny specifically: Streamlit, Dash, Voilà, JupyterLab, RStudio Server, and similar. Usetype: appin your spec (or Ruscker infers it from well-knowntype:values likestreamlit,dash,voila).
Both kinds give each session its own container slot and forward WebSocket upgrades transparently.
Supported frameworks:
- R — Shiny (the reference case), Quarto Live,
flexdashboardwithruntime: shiny. - Python — Streamlit, Dash (Plotly), Gradio, Panel (HoloViz), Solara, Mesop, Reflex, Bokeh server.
- Notebooks as apps — Voilà, Marimo, Observable Framework.
- Julia — Pluto.jl, Genie + Stipple, Dash.jl.
Stateless HTTP APIs
Any request can go to any replica — the simplest case, load-balanced round-robin with no sticky cookie.
- R — Plumber and Plumber 2, Ambiorix, RestRserve.
- Python — FastAPI, Flask / Quart, Litestar, Django REST, Sanic.
- Other languages — Go (Gin, Echo, Chi), Node (Express, Fastify, Hono, Nest), Rust (Axum, Actix), Ruby (Rails API, Sinatra), Elixir (Phoenix API), PHP (Laravel, FrankenPHP).
This is something ShinyProxy doesn’t do naturally — Ruscker treats APIs as a first-class spec kind with their own scaling and rate limiting.
ML / LLM model serving
Models exposed over HTTP fit the stateless-API path; GPU workloads benefit from per-spec replica limits.
- Serving runtimes — BentoML, MLflow Models, Seldon, TorchServe, TensorFlow Serving (HTTP), NVIDIA Triton (HTTP).
- LLMs — Ollama, vLLM, Text Generation Inference, LiteLLM proxy.
Per-user notebooks and IDEs
Each user gets an isolated container — the JupyterHub pattern, with Ruscker’s portal and admin on top.
- JupyterLab / Jupyter Notebook (included in the showcase seed),
RStudio Server (included in the showcase seed),
code-server(VS Code in the browser), Theia, Marimo Lab.
BI and data exploration
Isolate a dashboard tool per team or per tenant.
- Apache Superset, Metabase, Redash, Apache Zeppelin, Datasette, Evidence, Rill, Grafana (when you want per-tenant isolation).
Generative-AI UIs
Multiplex GPUs and isolate users in front of generative tools.
- Stable Diffusion WebUI (AUTOMATIC1111, ComfyUI, Forge), Open WebUI, LibreChat, AnythingLLM, Flowise, Langflow, self-hosted Gradio demos.
Data tooling and ETL UIs
- Apache Airflow, Dagster, Prefect, Mage, Kestra, NocoDB, Baserow, Directus, self-hosted Supabase Studio.
Database admin consoles
Surface a DB console as just another card on the portal.
- pgAdmin, phpMyAdmin, Adminer, Mongo Express, Redis Insight, CloudBeaver.
Works, with caveats
- WebRTC apps (Jitsi, BigBlueButton) — Ruscker proxies the HTTP/WS signalling and the frontend, but UDP media needs a separate relay (e.g. coturn).
- gRPC — runs over HTTP/2; unary APIs work, bidirectional streaming with per-session routing needs extra configuration and testing.
Not the right tool
- Purely static sites (Hugo, Astro output) — overkill; use nginx or Caddy directly.
- Service-mesh-grade microservices (automatic mTLS, dense distributed tracing, complex canaries) — that’s Istio/Linkerd on Kubernetes. Ruscker is a portal proxy, not a service mesh.
- Long CPU-bound batch jobs — use a scheduler (Slurm, Nomad, Airflow workers). Ruscker is for interactive sessions and short requests.
Who it’s for
- Public sector, universities and research centers publishing analytics dashboards for the public or for staff.
- BI and data-science teams that want to ship R/Shiny and Python/Streamlit apps without standing up a Kubernetes cluster.
- Consultancies delivering analytical tools to each client at isolated URLs.
- AI teams self-hosting LLM and generative WebUIs with per-user isolation.
The breadth here is the point: what looks like a “ShinyProxy alternative” is, in practice, a portal runtime for any containerized web app.
Where Ruscker fits
Ruscker is a portal-and-orchestrator for container-per-session and container-per-API workloads — interactive apps that want a fresh container per visitor, and stateless HTTP services pooled per replica. If your apps run in a container and speak HTTP or WebSocket, Ruscker wraps them in a portal, a reverse proxy, sticky sessions, auto-scaling and an admin panel — configured by a single YAML file.
What Ruscker is good at
- Per-session isolation — one container per visitor for stateful apps (Shiny, Streamlit, Dash, Voilà, notebooks), so sessions never share state.
- Stateless APIs — pooled per replica with an auto-scaler (Plumber2, FastAPI, and any HTTP service).
- Mixed frameworks under one portal — every app is a card on the landing page; anything in a container is first-class.
- A real admin panel — apps CRUD, a media library, an encrypted credentials store, a live monitoring dashboard, an audit log, and user roles — instead of editing a file and restarting.
- Light to run — a single static binary with a low-tens-of-MB idle footprint and instant startup.
Self-hosting a single framework
Streamlit, Dash, Voilà and Gradio ship their own dev server but no multi-app portal, session isolation, auth, or scaling — self-hosting means rolling your own reverse proxy, container lifecycle and landing page. That glue is exactly what Ruscker is. Point a spec at your image and you get the portal, sticky sessions, WebSocket forwarding, scaling and reaping for free. See What Ruscker can serve.
Sub-path handling (the strip model)
Ruscker uses a strip model: the proxy strips /app/{id} from the
request path before forwarding, so the container always receives a
root-relative path (e.g. /lab/... instead of /app/jupyter/lab/...).
The container does not need to know its mount path — the proxy injects
a <base href>, rewrites static URLs in HTML responses, and patches
runtime fetches via a small JS shim.
The practical consequence: serve apps at the root and don’t hard-code a
mount path. Jupyter, for example, runs at --ServerApp.base_url=/:
- id: jupyter
container-image: quay.io/jupyter/minimal-notebook:latest
container-port: 8888
container-cmd:
- start-notebook.py
- --IdentityProvider.token=
- --ServerApp.allow_origin=*
- --ServerApp.base_url=/
If you’re carrying over a config where the app reads a *_PUBLIC_PATH
environment variable to self-prefix its URLs, drop it under Ruscker —
the strip model already handles the mount path, and a self-prefixing app
would misconfigure itself and 404 on every request.
For the rare app that genuinely needs its external mount path — to build
absolute URLs the proxy can’t rewrite — Ruscker exposes an explicit opt-in
token #{publicPath}, substituted at spawn with the spec’s actual mount
path (including any --base-path prefix), for use in container-cmd or
container-env. Do not use it for Jupyter: under the strip model a
non-root base_url makes it 404 every path (see
Troubleshooting). Apps like Shiny, Streamlit, Dash
and Voilà never need it.
Secrets via env-var interpolation
Ruscker supports ${VAR} references in the YAML, resolved at spawn,
not at parse: the literal ${DB_PASSWORD} is stored in the config and the
database; only when a container is actually started does Ruscker look up
the environment variable and inject the real value. This means secrets
never land in the database — the DB only ever sees the placeholder. Set
secrets in the process environment (or in /etc/ruscker/ruscker.env for
the systemd service) and reference them by name in the YAML.
When Ruscker is not the right tool
- You need a publishing / authoring workflow — pushing content from an IDE, scheduled reports, content versioning. Ruscker is a proxy/orchestrator; you bring your own container images.
- You’re all-in on Kubernetes and want a CRD-native operator today — Ruscker schedules onto Docker hosts (over ssh/tcp), not Kubernetes.
- You need enterprise SSO gating app access per user (OIDC/SAML/LDAP
at the proxy level) — Ruscker’s auth covers admin roles (Viewer /
Editor / Admin) and per-app visibility (
access-groups/access-users), but proxy-level SSO is not yet supported.
If none of those apply, start with the Quickstart.
Quickstart — a portal full of demos
From nothing to a portal of live demos in a couple of minutes. You
need Docker running locally and the ruscker binary (see
Installation — or just docker run the image,
shown below).
1. Run it (the portal seeds itself)
Ruscker reads a config file, but it can be almost empty — the database
seeds the demos. Save this two-line application.yml:
proxy:
title: My Ruscker
Then start it with an admin token and --db (the admin database):
RUSCKER_ADMIN_TOKEN=$(openssl rand -hex 32) \
ruscker serve --config application.yml --bind 127.0.0.1:8080 --db ruscker.db
- Ruscker auto-connects to Docker when the daemon socket is
reachable, so app containers spawn out of the box. Pass
--no-dockerto run landing-only (then/app/*returns 503); pass--dockerto make a failed connect a fatal error instead of falling back to landing-only (useful for a remote daemon). - On first boot with
--db, Ruscker seeds 13 showcase cards — one live demo per supported framework (Shiny, Streamlit, Dash, Voilà, Jupyter, RStudio, …) plus external links for the rest — and seeds the framework logos into the Media library. The seed is idempotent; cards you delete stay deleted on subsequent boots.

Prefer the container image? Mount the Docker socket and a volume for the
DB (the image is cosign-signed; :latest tracks the current release):
docker run --rm -p 8080:8080 \
-e RUSCKER_ADMIN_TOKEN=$(openssl rand -hex 32) \
-v "$PWD/application.yml:/etc/ruscker/application.yml:ro" \
-v "$PWD/ruscker.db:/data/ruscker.db" \
-v /var/run/docker.sock:/var/run/docker.sock \
ghcr.io/strategicprojects/ruscker:latest \
serve --config /etc/ruscker/application.yml --bind 0.0.0.0:8080 \
--docker --db /data/ruscker.db
2. Open it
| URL | What you get |
|---|---|
| http://127.0.0.1:8080/ | the portal — the seeded showcase cards |
| http://127.0.0.1:8080/app/shiny/ | a live demo — Ruscker spawns the container on first hit |
| http://127.0.0.1:8080/admin | the admin panel (with RUSCKER_ADMIN_TOKEN set) |
| http://127.0.0.1:8080/healthz | liveness (always 200) |
Click any live-demo card to see on-demand container spawn in action, then watch the admin dashboard to see the replica start, serve, and stop. The first request to an app spawns its container; it’s reaped automatically once idle.
3. Add your own app
Two ways, neither of which needs a restart for the admin route:
-
From the admin panel (recommended) — go to
/admin→ Apps → Add app, pick a type, fill the form (there’s a live card preview), and Save. Everything is editable here — image, ports, scaling, resource limits, access — without touching YAML. -
In YAML — add a spec to your config. A tiny stateless example (
traefik/whoamiis a public echo image, so there’s nothing to build):proxy: title: My Ruscker specs: - id: hello display-name: Hello description: A stateless echo server. container-image: traefik/whoami:latest port: 80Validate before (re)starting — it catches typos and unsupported features:
ruscker validate application.yml # add --strict-compat to flag any ShinyProxy feature Ruscker would ignore
The schema is ShinyProxy-compatible, so an existing application.yml
works here too — see Migrating from ShinyProxy.
What just happened
Ruscker rendered the landing page, seeded the showcase catalogue into the database, and on the first request to an app asked Docker to start its container, routed you to it, and will reap it when idle. For interactive apps — Shiny, Streamlit, Dash, Voilà, Jupyter, RStudio — Ruscker adds sticky sessions and WebSocket forwarding automatically. For stateless APIs (Plumber2, FastAPI) it load-balances across replicas with no sticky overhead.
See What Ruscker can serve for the full framework list and Configuration for every spec field (replica pools, CPU/memory limits, registry credentials, routing, rate limits…).
Next steps
- Configuration — the full YAML reference. See Per-user access to restrict apps by user / group.
- The admin panel — manage specs, images, users, and the live dashboard without editing YAML.
- Deploying in production — systemd + nginx, TLS, multi-host, and active-active HA (including mounting the portal under a subpath and the sticky-upstream requirement for sign-in sessions in HA).
- Troubleshooting — when an app won’t load.
Installation
Ruscker is a single binary. Pick the packaging that fits your host.
Debian / Ubuntu (.deb)
The most ShinyProxy-like install: a systemd service on your Docker host. Packages for amd64 and arm64 are attached to every GitHub release.
# amd64
sudo apt install ./ruscker_<version>-1_amd64.deb
# arm64
sudo apt install ./ruscker_<version>-1_arm64.deb
This:
- installs
/usr/bin/ruscker, - creates a
rusckersystem user, - installs a hardened
ruscker.serviceunit and enables + starts it, - drops a minimal config at
/etc/ruscker/application.ymland a secrets file at/etc/ruscker/ruscker.env, - generates a unique admin token on first install and prints it once (there is no default password),
- runs with the admin catalog enabled (
--db /var/lib/ruscker/ruscker.db), so the admin panel works out of the box and a set of showcase apps seeds on first boot.
systemctl status ruscker
curl http://localhost:8080/healthz
sudo grep RUSCKER_ADMIN_TOKEN /etc/ruscker/ruscker.env # your admin token
Log in at /admin with the printed token to manage apps, the landing
page and users — that’s where day-to-day configuration lives (stored
in the catalog DB, not the YAML). application.yml holds deployment
settings; put secrets in /etc/ruscker/ruscker.env. After editing either
file, sudo systemctl restart ruscker.
To actually run the demo apps (and your own containers) enable the Docker
backend: sudo ruscker-enable-docker. See Deploying in
production for nginx + TLS, and
Configuration for what lives where.
Uninstall & reset
Pick the scope you want:
| Goal | Command | What’s left |
|---|---|---|
| Remove the software, keep config + data | sudo apt remove ruscker | /etc/ruscker (config, admin token, keys) and /var/lib/ruscker (catalog DB) — a reinstall resumes where you left off. |
| Remove everything (no trace) | sudo apt purge ruscker | Nothing. Drops the catalog DB and /etc/ruscker — including application.yml and the admin token / master key in ruscker.env. |
| Reset to a brand-new install | sudo apt purge ruscker && sudo apt install ./ruscker_<version>-1_amd64.deb | A pristine box: the default application.yml (no custom title/specs) and a freshly generated admin token, exactly like a first install. |
| Wipe the data only, keep config | stop, remove the DB, start (below) | Config + token unchanged; the catalog is empty, so migrations re-run and the showcase cards re-seed on next boot. |
To wipe just the catalog (a “fresh portal” without uninstalling):
sudo systemctl stop ruscker
sudo rm -f /var/lib/ruscker/ruscker.db # + -wal/-shm if present
sudo systemctl start ruscker
The catalog DB lives in
/var/lib/ruscker; your config and secrets live in/etc/ruscker.purgeclears both; removing the DB clears only the catalog. The portal title comes fromproxy.titleinapplication.yml, so it survives a data-only wipe — only apurge(or editing the file) changes it.
Static musl tarball
For hosts without a package manager, or for quick installs without a systemd unit, download the static musl binary directly. Tarballs for amd64 and arm64 are on the releases page.
tar -xzf ruscker-<version>-linux-amd64.tar.gz
sudo install -m 755 ruscker-<version>-linux-amd64/ruscker /usr/local/bin/ruscker
ruscker --version
The binary has no shared-library dependencies and runs on any glibc-free or glibc Linux system.
Homebrew (macOS / Linux)
For a local install — handy on a macOS workstation for development — use the tap:
brew install strategicprojects/tap/ruscker
ruscker --version
On Linux the formula pulls the static musl binary from the matching
release; on macOS it builds from source (so a Rust toolchain is fetched
as a build dependency). Each release auto-publishes its formula to the
tap, so brew upgrade tracks the latest version.
Ruscker spawns Linux containers, so the
--dockerbackend needs a Linux Docker host. A macOS Homebrew install is meant for running the portal locally and for development, not for hosting app containers.
Docker
docker run --rm -p 8080:8080 \
-v "$PWD/application.yml:/etc/ruscker/application.yml:ro" \
ghcr.io/strategicprojects/ruscker:latest \
serve --config /etc/ruscker/application.yml --bind 0.0.0.0:8080
To let Ruscker spawn app containers (the --docker backend), also
mount the Docker socket and add --docker:
docker run --rm -p 8080:8080 \
-v "$PWD/application.yml:/etc/ruscker/application.yml:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \
ghcr.io/strategicprojects/ruscker:latest \
serve --config /etc/ruscker/application.yml --bind 0.0.0.0:8080 --docker
Mounting the Docker socket grants control of the host’s Docker daemon — the same trade-off ShinyProxy carries. Prefer the
.debon the Docker host when you can.
From source
Requires Rust (the pinned toolchain installs automatically via
rust-toolchain.toml):
cargo build --release --bin ruscker
./target/release/ruscker --help
Verifying release artifacts
Every tagged release is signed with cosign
using GitHub Actions OIDC (keyless — no public key to fetch). Each
asset ships a .sha256 plus a .sig + .pem (signing certificate);
the container image is signed by digest.
# Container image
cosign verify ghcr.io/strategicprojects/ruscker:<version> \
--certificate-identity-regexp '^https://github.com/StrategicProjects/ruscker/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# A downloaded asset (tarball or .deb)
cosign verify-blob ruscker-<version>-linux-amd64.tar.gz \
--signature ruscker-<version>-linux-amd64.tar.gz.sig \
--certificate ruscker-<version>-linux-amd64.tar.gz.pem \
--certificate-identity-regexp '^https://github.com/StrategicProjects/ruscker/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
The exact commands are also printed in each release’s notes.
The serve command
ruscker serve --config <path> [--bind 0.0.0.0:8080] [--docker]
[--db <file>] [--images-dir <dir>] [--log-format json]
[--base-path <prefix>]
| Flag | What it does |
|---|---|
--config | Path to the application.yml. |
--bind | Listen address (defaults to the YAML’s proxy.port). |
--docker | Enable the container backend (spawn app containers). |
--db | SQLite file backing the admin panel. Without it, /admin/* is read-only-ish and the editor returns 503. |
--images-dir | Directory served at /assets/img/. Auto-discovered from the config / ShinyProxy template-path when omitted. |
--log-format | text (default) or json. |
--base-path | Mount the whole portal under a URL prefix (e.g. --base-path /apps). Overrides server.context-path in the YAML. Health probes (/healthz, /readyz) stay at the root. |
Secrets come from the environment: RUSCKER_ADMIN_TOKEN,
RUSCKER_MASTER_KEY, RUSCKER_COOKIE_KEY,
DOCKER_REGISTRY_PASSWORD.
Migrating from ShinyProxy
Ruscker reads the same application.yml schema as ShinyProxy, so
in most cases you point it at your existing config and it just works.
1. Pre-flight check
Before switching anything, ask Ruscker what your config uses:
ruscker validate application.yml # general report
ruscker validate --strict-compat application.yml # migration pre-flight
--strict-compat lists every ShinyProxy feature your config uses that
Ruscker does not honour (e.g. Kubernetes backend, minimum-seats-available,
non-none authentication) and exits non-zero if it finds any. A clean
run means a drop-in migration.
In production, a real 31-spec ShinyProxy 3.2.0 config reported “no unsupported features”.
The validator also flags plaintext credentials in the YAML — move
any docker-registry-password to ${DOCKER_REGISTRY_PASSWORD} and set
the variable in the environment (or /etc/ruscker/ruscker.env).
2. Credentials and env-var interpolation
Any string value in application.yml can reference an environment
variable with ${VAR} or ${VAR:-default}:
docker-registry-password: ${DOCKER_REGISTRY_PASSWORD}
The literal ${VAR} token is what gets stored (in the config file, the
database, and exports) — it is resolved to the real value only when a
container is actually spawned. This means registry passwords and
per-spec container-env secrets never land in the database.
For teams managing several apps that share a registry credential, Ruscker also has a named credential store in the admin panel. Store the credential there once, then reference it by name in the spec:
- id: my_app
container-image: registry.example.com/team/app:latest
docker-registry-credential: my-registry-cred # name from the store
When docker-registry-credential is set, it takes precedence over the
inline docker-registry-username / docker-registry-password fields.
The credential store accepts either an encrypted password or a pure
${VAR} env-ref (resolved at pull time, not stored in cleartext).
The inline fields are still valid and kept for back-compat — use whichever fits your workflow.
3. Sub-path mounting (context-path)
If you run Ruscker on a path prefix rather than a dedicated subdomain
(e.g. example.org/apps/ instead of apps.example.org), use
server.context-path:
server:
context-path: /apps # normalized: leading slash, no trailing slash
ShinyProxy’s nested form is also accepted without changes:
server:
servlet.context-path: /apps
Or override it at startup with the CLI flag (wins over YAML):
ruscker serve --base-path /apps --config application.yml ...
The portal and admin routes are all mounted under the prefix; the
health probes (/healthz, /readyz) stay at the root so your load
balancer does not need to know the prefix. Your reverse proxy just
needs to forward requests under the same path through to Ruscker.
4. Card logos
ShinyProxy serves card logos from its template-path’s assets/img/
folder. When you run serve without --images-dir, Ruscker
auto-discovers them next to the config:
<config-dir>/assets/img/<config-dir>/<template-path>/assets/img/
So a config left in place finds its logos with no extra flags.
You can also upload images through the admin Media panel and
reference them by filename in the spec’s logo field.
5. Side-by-side cutover (recommended)
You don’t have to flip everything at once. A safe pattern (proven in production) keeps ShinyProxy reachable while Ruscker takes the root:
- Run Ruscker on a spare port (e.g.
127.0.0.1:8090). - In nginx, route
/→ Ruscker and/sp/→ ShinyProxy (give ShinyProxy aserver.servlet.context-path: /sp). - Compare the two live, and roll back by restoring the nginx config if needed.
Because Ruscker uses the same /app/{spec} URL scheme, existing
bookmarks keep working after the cutover.
What Ruscker adds
Beyond parity, you also get: a real admin panel (no more hand-editing
YAML), a live monitoring dashboard, per-spec container-env /
container-cmd injection, per-API rate-limiting and CORS, per-user
and per-group app visibility, health probes, graceful shutdown, and a
tiny footprint — idle memory drops from ~540 MB to ~14 MB. See
The admin panel.
Not supported (yet)
Authentication schemes other than none and the Kubernetes backend
are the main gaps — validate --strict-compat is the authoritative
source of truth for your specific config. For apps that handle their
own auth (a common case), none is correct: Ruscker just routes
traffic.
The following ShinyProxy per-spec fields are accepted by the parser but currently ignored:
minimum-seats-available— usemin-replicasinsteadlabels— custom container labels (phase 3.5)network-connections— container network wiring (phase 3.5)kubernetes-*— Kubernetes backend
Fields that are now fully supported (and no longer flagged by
--strict-compat): volumes, container-env, container-cmd,
port (maps to container-port), and the scaling and lifecycle knobs
scale-up-threshold, scale-down-threshold, scale-down-grace,
max-lifetime, container-lifetime, drain-timeout,
stop-on-logout, and concurrent-requests-per-replica.
Configuration
Ruscker has two layers of configuration, and it helps to keep them apart:
- Portal content — your apps (specs), the landing page, users,
credentials, and media. This lives in the database and is managed
from the admin panel; you don’t write any YAML for it.
On a fresh
--dbthe portal even seeds a set of showcase apps for you. - Runtime / deployment — how and where Ruscker runs: the address it binds, whether it sits at the site root or under a subpath, the Docker backend, the database, secrets, and HA. These are CLI flags and environment variables, set once at startup.
The application.yml file is the bootstrap + migration format: it
must exist (it can be two lines), it carries the runtime proxy:
settings that aren’t flags, and it’s how you import an existing
ShinyProxy config. If you prefer to drive everything from YAML
(GitOps), you still can — but for a normal install the admin panel is
where you manage apps, and the YAML stays small.
Secrets are never written in the YAML — use
${VAR}interpolation and set the variables in the environment (or/etc/ruscker/ruscker.env).
Where each setting lives
| You want to… | Set it in |
|---|---|
| Add / edit apps, APIs, links | Admin panel → Apps (or proxy.specs to import) |
| Customise the landing (title, colours, logos, SEO, blocks) | Admin panel → Portal |
| Manage users, roles, group membership | Admin panel → Users |
| Store registry credentials | Admin panel → Credentials |
| Bind address / port | --bind (or proxy.bind-address / proxy.port) |
| Serve at the root or a subpath | --base-path (or proxy.server.context-path) |
| Enable / disable the Docker backend | auto · --docker · --no-docker |
| Database (catalog, users, sessions) | --db <file> · --config-db-url (Postgres/HA) |
| Admin token + crypto keys | RUSCKER_ADMIN_TOKEN, RUSCKER_MASTER_KEY, RUSCKER_COOKIE_KEY |
| Log format | --log-format text|json |
Deployment settings
The decisions you make at startup. Most have both a CLI flag and an env var; see Deploying in production for the full systemd
- nginx walkthrough.
Served at the root, or under a subpath?
The most common deployment question. By default Ruscker serves the portal
at the site root (https://apps.example.org/). If you can’t dedicate
a subdomain and need it under a path (https://example.org/apps/), set a
base path:
ruscker serve --config application.yml --bind 127.0.0.1:8080 --db ruscker.db \
--base-path /apps
(Equivalently proxy.server.context-path: /apps in the YAML, or the
RUSCKER_BASE_PATH env var.) Ruscker then emits every URL — landing,
admin, assets, and the /app proxy — under /apps, and rewrites app
responses so unmodified Shiny / Streamlit / Jupyter apps work behind the
prefix. Point your reverse proxy’s /apps/ location at Ruscker. Full
nginx example: Mounting under a base path.
Bind address, Docker, database
--bind <addr:port>— where Ruscker listens (overridesproxy.bind-address/proxy.port). Behind nginx, bind to localhost.- Docker backend — auto-connects when the daemon socket is reachable.
--no-dockerruns landing-only (the/appproxy returns 503);--dockermakes a failed connect fatal (e.g. a remote daemon you require). --db <file>— the SQLite catalog (apps, users, landing, audit, sessions); required for/admin/*. For active-active HA use--config-db-url postgres://…(a shared catalog) instead.
Secrets
Set these in the environment (the .deb puts them in
/etc/ruscker/ruscker.env):
RUSCKER_ADMIN_TOKEN— unlocks/adminand is the break-glass login. Without it, admin routes return 503.RUSCKER_MASTER_KEY— AES-256 key for the encrypted credentials store.RUSCKER_COOKIE_KEY— HMAC key for sticky-session cookies. Set it explicitly in production so sessions survive restarts (and are valid cross-instance in HA); without it a random key is generated per process.
High availability
Running more than one instance behind a load balancer? Share the catalog
(--config-db-url), the proxy session store (--session-store-url), the
admin session store (--admin-session-store-url), and the same
RUSCKER_COOKIE_KEY across instances, so any node can serve any request.
See Shared admin sessions.
Per-user access
access-groups / access-users on a spec scope who can see the
card on the landing and reach the upstream at /app / /api. A spec
with neither key is open — visible to everyone, including anonymous
visitors. Otherwise:
- An admin session sees everything.
- A signed-in user sees a restricted spec when their username is in
access-usersor one of their groups is inaccess-groups. - An anonymous visitor only sees open specs.
Enforcement is real — the /app and /api guards reject unauthorized
requests (anonymous on /app → redirected to login; otherwise 403), not
just hide the landing card.
You set both keys on the spec form (admin panel → Apps), and group membership per user on the admin Users page. The same user record drives both portal visibility and admin role (Admin / Editor / Viewer) — see The admin panel. In YAML the keys look like:
proxy:
specs:
- id: open-app
display-name: Open App
container-image: demo/img # no access keys ⇒ open
- id: analysts-app
display-name: Analysts App
container-image: demo/img
access-groups: [analysts]
- id: vip-app
display-name: VIP App
container-image: demo/img
access-users: [carol]
The full YAML reference
Everything below is the complete application.yml schema (the same
docs/YAML_SCHEMA.md shipped in the repo). Reach for it to migrate an
existing ShinyProxy config, or to drive specs and landing from YAML
instead of the admin panel — not for a normal, admin-panel-managed
install.
YAML schema reference
Reference for every field Ruscker understands in application.yml.
For ShinyProxy users: this document marks which ShinyProxy features are supported, extended by Ruscker, deferred to later phases, or not supported.
Top level
server: { ... } # Optional. Spring Boot-style server config
proxy: { ... } # The main Ruscker config
logging: { ... } # Optional. Logging config
server block
Supported:
server:
useForwardHeaders: true # Honor X-Forwarded-* headers
forward-headers-strategy: native # 'native' | 'framework' | 'none'
secure-cookies: true # Set Secure flag on cookies
servlet.session.timeout: 3600 # Spring flat-key form
# OR equivalently:
servlet:
session:
timeout: 3600
context-path: /apps # Mount portal under a subpath (see below)
# ShinyProxy's nested form is also accepted:
servlet.context-path: /apps
Resolved via Server::session_timeout_secs() — either form works.
server.context-path — subpath mounting
Serves the whole portal under a URL prefix when you can’t dedicate a
subdomain (e.g. example.org/apps/ instead of box.example.org/).
Normalized form: leading slash, no trailing slash ("box",
"/apps/", and "/apps" all become /apps). The CLI flag
--base-path /apps overrides the YAML.
ShinyProxy emits this as server.servlet.context-path; the flat
server.context-path form is also accepted. Empty / absent ⇒ served
at the root (the default).
Operational notes:
- The health probes
/healthzand/readyzstay at the root regardless of the prefix — load balancers don’t need to know it. - The chrome’s root-absolute URLs (
/admin/...,/assets/..., redirects) are rewritten on the response so they all carry the prefix; runtime fetches (fetch/XMLHttpRequest/WebSocket/EventSource) get a small shim that prefixes them too. - Cookies set
Path=/(already sent by browsers for{base}/...). - Configure your reverse proxy to pass requests through under the
same prefix — see the Mounting under a base path section of
book/src/deploying.md.
Other server.* fields from Spring Boot are accepted by serde but
ignored by Ruscker.
proxy block
Top-level proxy fields
| Field | Type | Default | Notes |
|---|---|---|---|
title | string | "Ruscker" | Browser tab title |
landing-page | string | "/" | Where the portal is served |
hide-navbar | bool | false | Suppress default navbar |
template-path | path | none | Override template directory |
heartbeat-rate | ms | 10000 | Client heartbeat interval |
heartbeat-timeout | ms | 3600000 | Session expiry; -1 = never |
container-wait-time | ms | 60000 | Max wait for container Ready |
shutdown-grace-ms | ms | 30000 | Drain window on SIGTERM/Ctrl-C before forced exit; /readyz reports draining during it. Ruscker extension |
max-body-size | size | none | Global cap on proxied request bodies ("10m", "1g", bytes); over → 413. Per-spec max-body-size overrides. Ruscker extension |
metrics-enabled | bool | false | Expose a Prometheus /metrics endpoint (unauthenticated when on — firewall it). Ruscker extension |
metrics-interval | s | 5 | How often the dashboard polls the backend for per-replica CPU/mem. A busy host can slow it (10–15) to ease the Docker daemon. 0 ⇒ default. Ruscker extension |
hosts | list | [] | Docker hosts for multi-host scheduling (Phase 6). Empty ⇒ the local daemon. Ruscker extension |
proxy.hosts — multi-host scheduling (Phase 6)
Empty (the default) means the single local Docker daemon — today’s behaviour. List hosts to spawn app containers across several daemons:
proxy:
hosts:
- id: ssh-1
address: ssh://ops@10.0.0.11 # Docker over SSH (simplest)
- id: tcp-1
address: tcp://10.0.0.12:2376 # Docker over TLS
tls: { ca: /etc/ruscker/ca.pem, cert: /etc/ruscker/cert.pem, key: /etc/ruscker/key.pem }
max-containers: 40 # optional cap
weight: 2 # optional, for spread placement
- id: local
address: unix:///var/run/docker.sock
Address schemes: ssh://user@host[:port], tcp://host:port (needs
tls), http://host:port (plain TCP — trusted networks only),
unix:///path. validate flags empty/duplicate ids, unknown schemes,
and tls mismatched with the scheme.
Per-host: max-containers caps how many containers a host runs;
weight (default 1) biases spread placement toward bigger hosts.
Per-spec placement controls how a spec’s replicas land across hosts:
- id: heavy-shiny
placement: spread # spread (default) | bin-pack
anti-affinity: true # keep replicas on distinct hosts (best-effort)
spreaddistributes replicas (weighted least-loaded) for fault isolation;bin-packfills one host before using the next.anti-affinity: trueprefers hosts not already running the spec, falling back to the strategy above if every eligible host does (so scaling never stalls). Hosts atmax-containersare skipped; if all are full, the spawn fails (the scaler retries). |container-log-path| path | none | Directory for per-container logs | |port| u16 |8080| HTTP listener port | |bind-address| string |"0.0.0.0"| Listener interface | |authentication| enum |none|none(MVP) /openid/ldap/saml/simple| |landing-customization| block |{}| Branding, SEO/social meta, analytics, custom HTML blocks, sign-in visibility — see §proxy.landing-customization. Ruscker extension | |specs| array |[]| List of apps/links/APIs |
Authentication
MVP only supports none. The other variants are accepted by the
parser (so existing YAML doesn’t fail) but Ruscker will warn at boot
that auth is unimplemented and continue as if none. Phase 8 adds
real auth support.
For applications that handle their own auth internally (a common
case), none is the correct choice — Ruscker just routes traffic.
proxy.landing-customization
Branding, SEO, analytics, and custom-HTML overrides for the public
landing. Every subfield is optional; an empty landing-customization
block (the default) renders the stock landing.
proxy:
landing-customization:
# Branding — CSS colors applied to the landing header
header-bg: "#0f6e56" # any CSS color
header-fg: "#ffffff" # contrast override when bg is dark
# Intro paragraph between header and filters
intro: "Welcome to the portal." # single-language fallback
intro-locales: # per-language overrides; locale code → text
pt: "Bem-vindo ao portal."
en: "Welcome to the portal."
es: "Bienvenido al portal."
fr: "Bienvenue sur le portail."
# SEO / social-share meta tags injected into the landing `<head>`
seo-title: "Portal — Org Name" # overrides `proxy.title` for <title>
seo-description: "Internal apps." # <meta name="description"> + og:description
og-image: /assets/img/og.png # path or absolute URL for og:image
# Analytics — admin-trusted, injected verbatim into landing <head>
analytics-html: |
<script defer src="https://plausible.io/js/script.js"
data-domain="example.org"></script>
analytics-origins: "https://plausible.io" # space-separated; widens landing CSP
# Operator CSS — injected as a <style> late in the landing <head>,
# so it can override the built-in styles
custom-css: ".rk-card { border-radius: 0; }"
# Header / footer logos (admin-managed in the live editor; see below)
logos:
- url: /assets/img/org-mark.png # uploaded, built-in, or absolute URL
slot: header # `header` | `footer`
align: left # `left` | `center` | `right`
link: https://org.example # optional click-through
height: 40 # render height in px
margin: 12 # optional outer margin in px
# Sign-in visibility (anonymous viewers only)
show-admin-link: true # default true; false hides the entrance
# Custom HTML blocks (admin-managed in the live editor; see note below)
blocks:
- slot: top # `top` (after header) | `bottom` (after grid)
title: "Maintenance banner" # internal label, not shown publicly
html: '<div class="...">Scheduled downtime Sunday 02:00 UTC.</div>'
csp-origins: "" # space-separated origins this block needs
enabled: true # default true
Field reference:
| Field | Type | Default | Notes |
|---|---|---|---|
header-bg | CSS color | none | Header background. Match your brand’s primary color. |
header-fg | CSS color | none | Header text — set when header-bg is dark and the default loses contrast. |
intro | string | none | Plain text (no HTML); single-language fallback. |
intro-locales | map | {} | Locale code → intro string. Wins over intro for matching locales. |
seo-title | string | proxy.title | Override for <title>. |
seo-description | string | resolved intro | <meta name="description"> + og:description. |
og-image | path / URL | none | og:image for social-share. |
analytics-html | string | none | Trusted raw HTML, injected verbatim into landing <head>. |
analytics-origins | string | none | Space-separated origins added to the landing CSP (script-src/connect-src/img-src). |
custom-css | string | none | Trusted raw CSS, injected as a <style> late in the landing <head> so it overrides the built-in styles. |
show-admin-link | bool | true | When false, anonymous visitors don’t see the “Sign in” entrance. Logged-in users still see their panel link. |
show-highlights | bool | true | Show the “Featured” carousel above the filters. The carousel still only renders when at least one spec is featured. |
logos[] | list | [] | Header/footer logos (see below). |
blocks[] | list | [] | Custom HTML blocks (see below). |
logos[] subfields:
| Field | Type | Default | Notes |
|---|---|---|---|
url | string | required | Image URL — /assets/img/... (uploaded), a built-in (/assets/showcase/..., /assets/brand/...), or an absolute URL. |
slot | enum | required | header or footer — where the logo renders. |
align | enum | required | left, center, or right within the slot. left/right integrate into the chrome: header-left replaces the Ruscker mark, header-right trails the buttons, footer-left sits far-left, footer-right trails the version+mark. center renders in a separate bar. Logos sharing a slot+alignment render side by side. |
link | URL | none | Optional click-through — when set, the logo becomes an <a>. |
height | px | default | Per-logo render height in pixels; falls back to a built-in default when unset. |
margin | px | none | Optional outer margin in pixels around the logo, for spacing from adjacent chrome or a neighbouring logo. |
blocks[] subfields:
| Field | Type | Default | Notes |
|---|---|---|---|
slot | enum | required | top or bottom — render position on the landing. |
title | string | required | Admin-only label; not rendered publicly. |
html | string | required | Trusted raw HTML, injected verbatim into the chosen slot. |
csp-origins | string | "" | Space-separated origins this block’s content needs, folded into the landing CSP. |
enabled | bool | true | Toggle without deleting. |
Trust model:
analytics-html,custom-css, andblocks[].htmlare rendered unescaped. Only set them from a trusted source. Anything the snippet loads from outside Ruscker’s origin must also be listed in the matching*-originsfield, otherwise the landing’s CSP blocks it.
custom-css, logos[], and blocks[] are admin-managed in the live
landing editor (the logos picker and blocks editor live on the Portal
page). Blocks are stored in their own DB table; the blocks[] slot in
this YAML schema exists so a future ruscker export round-trip can
serialize them, but at the moment the import/export path does not
populate it — operators edit blocks from the admin UI. logos[] does
round-trip through import/export. SEO, analytics, and show-admin-link
are deploy-level policy and live only in this block.
Specs
A spec describes one app, API, or external link. Every spec has an
id and lives in proxy.specs[].
Common fields (all spec types)
- id: my_app # required, kebab-case
display-name: "My App" # shown on the card
description: "What it does" # HTML inline allowed
template-properties: # free-form bag for the landing
logo: "/assets/img/myapp.png"
icon: lock # 'lock' | 'lock_open'
type: app # 'app' | 'package' | 'talk' | 'report' | 'api'
updated: "18/05/2025"
state: active # 'active' | 'inactive'
link: https://external.example # optional explicit URL
Containerized specs (Shiny, Streamlit, Dash, Voilà, API)
- id: my_app
container-image: org/repo:tag # required for containerized
type: shiny # optional, default 'shiny' if image set
container-port: 8501 # port the app listens on inside the
# container; default 3838 (Shiny).
# Set for Streamlit (8501), Dash
# (8050), … . ShinyProxy `port:`
# is accepted as an alias.
seats-per-container: 10 # sessions per replica
max-lifetime: 360 # minutes — hard recycle (enforced, #334)
container-lifetime: 360 # minutes — soft recycle when idle (enforced, #334)
heartbeat-timeout: 3600000 # ms — per-spec override (enforced)
stop-on-logout: false # end a user's sessions on logout (enforced, #337)
docker-registry-username: acme
docker-registry-password: ${DOCKER_REGISTRY_PASSWORD} # use env vars!
docker-registry-domain: docker.io
docker-registry-credential: dockerhub-acme # OR reference a stored
# credential by name (Ruscker
# extension; see below)
volumes: # bind mounts (ShinyProxy-compatible)
- /srv/myapp/data:/data # persistent data
- /srv/myapp/www:/www:ro # static assets, read-only
container-env: # env vars injected into the container
DB_HOST: db.internal # (ShinyProxy-compatible)
DB_PASSWORD: ${DB_PASSWORD} # ${VAR} resolved at spawn, not at parse —
# the literal is stored; secret never hits the DB
container-cmd: # override the image's CMD (argv list)
- R
- -e
- shiny::runApp('/app', port=3838, host='0.0.0.0')
access-groups: [staff, ops] # who may see/reach this app
access-users: [alice] # (ShinyProxy-compatible)
volumes is a list of Docker bind specs (/host:/container, optionally
:ro/:rw), mapped to the container’s HostConfig.binds. Add as many
as needed; editable in the admin Advanced form (one per line).
Bind-mounting host paths is root-equivalent and admin-only — see
SECURITY.md.
container-env is a NAME: value map injected into the container as
environment variables (Docker Config.Env). ${VAR} /
${VAR:-default} references in the values are resolved at spawn, not
at parse: the ${VAR} literal is what gets stored (config / DB / export),
and the real value is only ever materialized when the container is
created — so an app secret passed this way never lands in the database.
container-cmd
is an argv list that overrides the image’s baked CMD (Docker
Config.Cmd); omit it to keep the image default. Both are
ShinyProxy-compatible and editable per spec.
access-groups / access-users (ShinyProxy-compatible) scope who can
see an app on the landing and reach it at /app / /api. A spec
with neither is open — visible to everyone, including anonymous
visitors. Otherwise: a logged-in user sees it when their username is in
access-users or one of their groups is in access-groups; an admin
always sees everything; an anonymous visitor sees only open apps. Group
membership is set per user in the admin panel. Enforcement is real (not
just hiding the card). See the Roadmap Phase 8.
Registry credentials — inline vs. named (docker-registry-credential)
A spec can authenticate to a private registry two ways:
- Inline
docker-registry-username/docker-registry-password/docker-registry-domain— ShinyProxy-compatible. Always use${ENV_VAR}for the password (never a literal in YAML). - Named
docker-registry-credential: <name>— a Ruscker extension that references an entry in the admin’s encrypted credential store (managed under Credentials in the admin panel). The username, password, and registry are resolved from the store at pull time (decrypted withRUSCKER_MASTER_KEY), so no secret — not even a${VAR}reference — needs to live in the spec at all.
When docker-registry-credential is set, it takes precedence over
the inline docker-registry-* fields for that spec. Leave it unset to
use the inline fields. The spec form’s Registry section is a picker
over stored credential names.
Smart routing — sub-path context for the upstream
Ruscker mounts each app under a sub-path (/app/{id}, /api/{id}) but
the container usually assumes it lives at the server root. Two
mechanisms bridge that gap:
-
Forwarded-prefix headers (always on). Every proxied request carries the public mount context to the upstream so a prefix-aware app can self-route and emit correct absolute links:
Header Value Consumed by X-Forwarded-Prefix/app/{id}(no trailing slash)Spring, Traefik, FastAPI root_pathX-Script-Namesame mount path WSGI, Dash, Plumber X-Forwarded-Protohttp/httpsas seen by the clientabsolute-URL builders behind TLS X-Forwarded-Hostthe public Hostabsolute-URL builders Apps that ignore these headers are unaffected. To honour them you typically point the framework at the prefix — e.g. uvicorn
--root-path /app/my-api, or Dashrequests_pathname_prefix. -
HTML rewriting (
inject-base-href, defaulttrue). For apps that can’t be told their prefix, Ruscker injects<base href>and rewrites root-relative URLs in/app/{id}HTML responses (see the rewriter inroutes::rewrite). This is the safe default and covers Shiny out of the box. Set it tofalseper spec once the app self-routes from the headers above — then the rewriting is redundant and best disabled:- id: my_api container-image: org/fastapi:tag type: api inject-base-href: false # app reads X-Forwarded-Prefix itselfinject-base-hrefonly affects/app/{id}responses;/api/{id}responses are never rewritten. Editable in the admin Advanced form under Routing. -
The
#{publicPath}token (opt-in). For an app that needs its own base-url told to it as a config value — Jupyter’s--ServerApp.base_url, for instance — drop the literal token#{publicPath}into anycontainer-cmdargument orcontainer-envvalue. At spawn it’s substituted with the spec’s public mount path with a trailing slash (e.g./app/{id}/, or/apps/app/{id}/under a base path;/api/{id}/for APIs). This is Ruscker’s analog of ShinyProxy’sSHINYPROXY_PUBLIC_PATH, but it is never injected automatically — Ruscker strips the mount prefix before forwarding, so most apps should serve at root and rely on the rewriting above. Reach for the token only when an app genuinely needs the public path in its own configuration:- id: jupyter container-image: org/jupyter:tag type: app container-cmd: - jupyter - lab - --ServerApp.base_url=#{publicPath}Note
#{publicPath}is resolved at spawn, distinct from the parse-time${VAR}env interpolation, which never sees this runtime value.
External link specs (no container)
- id: my_pkg
display-name: "My Package"
description: "An R package"
template-properties:
type: package
link: https://pkg.example # the destination URL
logo: "/assets/img/mypkg.png"
icon: lock_open
state: active
Just omit container-image and provide template-properties.link.
Ruscker won’t try to orchestrate anything — clicking the card
navigates to the link.
API specs (Plumber2 / FastAPI / etc.)
- id: my_api
type: api # explicit type, overrides auto-detect
container-image: org/my-api:latest
api:
port: 8080 # container port
docs-path: /__docs__ # OpenAPI/Swagger UI
health-path: /__healthz__ # readiness check
rate-limit: 100/min # per-IP rate limit at proxy
cors: true # permissive CORS headers
min-replicas: 1
max-replicas: 3
concurrent-requests-per-replica: 100
routing-strategy: round-robin # APIs don't need sticky
api.rate-limit — per-client throttling
Enforced at the proxy, before any container is spawned or woken,
so a throttled caller costs nothing downstream. Format is
N/unit where unit is one of s/sec/second(s),
m/min/minute(s), or h/hr/hour(s) (case-insensitive):
rate-limit: 100/min # at most 100 requests per client per minute
rate-limit: 5/s
rate-limit: 1000/hour
A request over the limit gets 429 Too Many Requests with a
Retry-After header. The window is a sliding one, per
(spec, client).
Client identity. The “client” is the caller’s IP. When the
operator opts into forwarded headers
(server.useForwardHeaders: true, or a forward-headers-strategy
other than none), the left-most X-Forwarded-For address is used
— the right choice when Ruscker sits behind a reverse proxy.
Otherwise the real TCP peer is used: X-Forwarded-For is not
trusted unless opted in, since a direct client could otherwise spoof
it to dodge the limit.
A malformed rate-limit value is ignored (no limit applied) and
flagged by ruscker validate.
api.cors — permissive CORS headers
cors: true makes the proxy add permissive CORS headers
(Access-Control-Allow-Origin: *, common methods, * headers) to
every response for that API spec, and answer OPTIONS preflight
requests itself (204) without touching the container. Headers an
upstream app already set are never overwritten — an API that does
its own CORS wins. CORS applies only to the /api/ route family.
max-body-size — cap proxied request bodies
Limits how large a request body the proxy will forward, for both
/app/ and /api/ routes. Set it globally on proxy.max-body-size
and/or override it per spec:
proxy:
max-body-size: 10m # global default
specs:
- id: upload_api
container-image: org/api:1
type: api
max-body-size: 100m # this spec accepts larger uploads
Format is the Docker-style size string used elsewhere ("512" bytes,
"10m", "1g"; binary units). The effective limit is the spec’s own
value if set, otherwise the global default; unset everywhere means no
limit (the default, preserving prior behaviour).
A request whose Content-Length exceeds the limit is rejected with
413 Payload Too Large before any container is touched. A chunked or
under-declared body that grows past the cap mid-stream is also stopped
(it surfaces as a 502). A malformed size string is ignored (no limit
applied) and flagged by ruscker validate.
Load-balancing fields (any containerized spec)
| Field | Type | Default | Notes |
|---|---|---|---|
min-replicas | u32 | 1 | Always running |
max-replicas | u32 | 5 (≥ min-replicas) | Per-container apps auto-scale to up to 5 independent replicas by default; set explicitly to raise/lower |
scale-up-threshold | float | 0.8 | scale up when pool utilization exceeds this (enforced, #333) |
scale-down-threshold | float | 0.3 | only retire idle replicas while utilization is below this (enforced, #333) |
scale-down-grace | s | 300 | idle-grace before retiring a replica (enforced, #333) |
drain-timeout | s | 60 | grace for in-flight sessions on a max-lifetime recycle (enforced, #335) |
routing-strategy | enum | varies | See below |
concurrent-requests-per-replica | u32 | 100 | API-only — per-replica in-flight cap the scaler scales on (enforced, #336) |
Autoscaling knobs (#326). By default the scaler scales on seat saturation (
sessions_activevssessions_max) with built-in grace ticks. All the per-spec scaling/lifecycle knobs are now enforced (opt-in where noted):scale-up-threshold/scale-down-threshold/scale-down-grace(#333 — pool-utilization-driven scale-up + a conservative scale-down gate + per-spec idle grace; unset ⇒ the default rules),max-lifetime/container-lifetime(#334 — recycle past the age cap),drain-timeout(#335 — grace for a busymax-lifetimerecycle),stop-on-logout(#337 — a signed-in user’s sticky sessions end immediately on logout), andconcurrent-requests-per-replica(#336 — API specs have no sticky sessions, so the scaler meters their capacity by in-flight requests against this per-replica cap).ruscker validate --strict-compatno longer flags any of them.
Routing strategies
least-connections— pick replica with most free seats. Default for Shiny, Streamlit, Dash, Voilà.round-robin— cycle through replicas. Default for API.weighted-random— random with weights = remaining seats. Not yet implemented (falls back to round-robin).resource-aware— pick based on CPU/mem load. Requires phase 4 metrics. Falls back to least-connections.
Spec kind dispatch
The effective kind drives runtime behavior:
- Explicit
typefield wins if set - Otherwise:
container-imageset →shiny, unset →external
The kind controls:
- Whether sticky session cookies are issued (
shiny,streamlit,dash,voila— yes;api,external— no) - Default routing strategy
- Default
seats-per-container(10 for web-framework apps — Shiny, Streamlit, Dash, Voilà — which serve many sessions per process; 100 for API; 0 for external). A single-user IDE (RStudio, Jupyter) should setseats-per-container: 1so each visitor gets an isolated container. - Whether WebSocket forwarding is attempted
Environment variable interpolation
Any string value can use ${VAR_NAME} or ${VAR_NAME:-default}:
docker-registry-password: ${DOCKER_REGISTRY_PASSWORD}
heartbeat-rate: ${HEARTBEAT_RATE:-10000}
Rules:
- Variable names:
[A-Z_][A-Z0-9_]* - Missing variable without default: hard error at parse time
- Missing variable with default: substituted with the default
- Comments (lines starting with
#) are not interpolated
This applies to the whole YAML file, not just credentials. Use it for any value that varies between environments.
template-properties
Free-form key-value bag. The current landing template uses:
| Key | Type | Notes |
|---|---|---|
logo | string | Path or URL to card image |
cover | string (CSS) | Card-cover background — a solid color or gradient. Empty ⇒ a per-kind tint |
icon | lock | lock_open | Access level |
type | app | package | talk | report | api | Badge category |
subject | string | Subject/topic of the app — drives the Subject filter facet on the landing |
featured(a top-level spec field, not a template-property): setfeatured: trueto highlight the app in the landing’s Featured carousel above the filters. The carousel shows only whenlanding-customization.show-highlightsis on (the default) and at least one spec is featured. Defaultfalse. |updated| string | Display date (DD/MM/YYYY) | |state|active|inactive| Whether to enable card | |link| URL | External URL for non-container specs |
You can add custom keys — they’re ignored unless the template uses them. Useful for future custom templates.
logging block
logging:
file:
name: logs/ruscker.log
Accepted for ShinyProxy compat. Ruscker uses tracing for logging
and respects the RUST_LOG env var as well.
Not supported (MVP)
These ShinyProxy fields are accepted by the parser but currently ignored:
proxy.specs[*].kubernetes-*— Kubernetes backend, phase 6proxy.specs[*].port— explicit upstream port (Ruscker usesapi.portfor APIs, auto-detects for Shiny)proxy.specs[*].minimum-seats-available— pre-warm pool (planned)proxy.specs[*].labels,proxy.specs[*].network-connections— phase 3.5proxy.docker.*— global docker config (use defaults or env vars)
(proxy.specs[*].volumes and container-env / container-cmd are now
supported — see “Containerized specs” above.)
Setting any of these will produce a startup warning but not an error.
Run ruscker validate --strict-compat <config> to list every
unsupported feature a config uses (and exit non-zero if any are
found) — the recommended pre-flight check when migrating from
ShinyProxy.
The admin panel
The admin panel is Ruscker’s main advantage over editing YAML by hand.
It lives at /admin, is gated by a token, and needs a SQLite database
(serve --db <file>).
Everything is configurable from the web UI — every spec field (including API, scaling, resource and lifecycle settings), the media library, encrypted credentials, and the whole landing page. YAML import/export exists for migration and backups, not as a requirement.
Logging in
On first run, set RUSCKER_ADMIN_TOKEN (the .deb generates one on
install and prints it once) and browse to /admin/login: with no
accounts yet you’re asked for the token, then walked through
creating the first admin account (username + password). After that
everyone signs in with their account at /admin/login. The token stays
as a break-glass login (/admin/login?token=1) so you can never be
locked out. Until a token is set, /admin/* returns 503 and only the
public landing + proxy are served.
The footer has the same language (pt-BR / en-US / es-ES / fr-FR) and theme (light / dark / auto) pickers as the public portal. The top-right corner shows your current access level.
Users and access levels (roles)
Each person gets their own account (username + password). Admins manage
accounts under Users (/admin/users): create one, assign a role,
reset a password, or remove it. Each row shows a coloured avatar with the
user’s initials and their groups as coloured badges (a group keeps the
same colour on the Groups and Apps pages). A new user gets an initial
password you choose and is asked — once, on first login — whether to
change it. Password fields are masked (type password) throughout the
panel, so a shoulder-surfer can’t read a password as you type it.
| Role | Can do |
|---|---|
| Viewer | view the Dashboard only (read-only) |
| Editor | view + manage Apps and Media; view the Dashboard and stop/restart replicas |
| Admin | everything, including managing users, credentials, the landing editor, custom blocks and the audit log |
The panel shows only the sections your role can reach. Enforcement is
server-side — hiding a nav link is just UX; the routes themselves
return 403 for a role that isn’t allowed. The audit log records the
acting username. A last-admin guard stops you deleting or demoting
the only remaining admin (so the portal can’t be locked out); the
RUSCKER_ADMIN_TOKEN break-glass login is the other safety net.
Screens
Dashboard
A live view of running replicas, refreshed over Server-Sent Events. The
headline KPI cards (containers, apps with replicas, sessions, memory)
count up on load. Below them, replicas are grouped by app in
expandable cards: each card’s header summarises the app — replica count,
worst replica state, and aggregate sessions / CPU / memory with little
meters — and expands to the per-replica detail (state, container id,
uptime, sessions, CPU, memory) with stop / restart / logs actions.
A toolbar offers an expand/collapse-all control. Shows a banner when
started without --docker.

Apps
The list of specs — apps, APIs and external links — with create, edit and delete. Each row shows the app’s framework logo next to its name, a colour-coded kind pill, and an Access column with the spec’s access-group badges (or a globe + “public” when ungated). Each row also has a featured star next to the actions: click it to toggle whether the app appears in the landing page’s Featured carousel, inline, without opening the editor (solid = featured).

The add/edit form is organised into three bands so the layout maps to
intent: Identity (id, name, description), a Metadata & visibility
band (the featured flag, access groups/users, subject, logo and cover),
and a collapsible Advanced band for runtime knobs. It has a type
selector, a live card preview, and a logo picker that pulls from
the media library (no need to type /assets/img/... by hand). A “?”
help popover on every field explains what it does.
Under the collapsible Advanced band, every remaining spec option is editable too — so an app can be configured end-to-end from the web UI, without touching YAML:
- Runtime — seats per container, session lifetime, inner container port and platform.
- API (for
type: api) — container port, rate limit, docs/health paths, permissive CORS. - Scaling — min/max replicas and concurrent requests per replica.
- Resources — per-container CPU and memory limits.
- Lifecycle — the heartbeat (idle-session) timeout.
Every advanced field is optional; leaving it blank keeps Ruscker’s default, so the section stays out of the way until you need it.

Groups
Groups (/admin/groups, admin-only) gate which apps a user sees. They’re
derived, not a separate table: a group exists as long as a user belongs
to it or an app lists it under access-groups. The page shows every group
with its members and the apps it gates, and lets you edit them in place:
- Rename a group — the change propagates across every user membership and every app that references it.
- Delete a group — it’s removed everywhere (an app left with no groups becomes open to all).
- Add / remove members inline, and create a group by adding its first member under a new name.
Edits touch the database-managed users and apps. An app defined in the
serve --config YAML stays read-only here (edit the file for those).
Media
Upload images (PNG/JPEG → WebP), served at /assets/img/<file>. These
are the card logos and covers.
The gallery is a single unified library — built-in logos (brand marks shipped with Ruscker) are seeded here automatically alongside your uploads. Every image can be deleted from the gallery; if it is referenced by any spec logo/cover or landing logo, the entry shows an “in use” badge so you know before deleting.
When editing a spec you can open a modal picker (search, browse, drag and drop, or upload inline without leaving the form) to select a logo or cover. A “Choose image” button auto-uploads on file select for a one-click flow.

Credentials
A named, AES-256-GCM-encrypted store for registry credentials (needs
RUSCKER_MASTER_KEY). Passwords never appear in the YAML or in the
panel after saving. Each entry accepts either a literal password
(encrypted at rest) or a pure ${VAR} env-ref — stored verbatim and
resolved to the real value only at container pull time.
In the spec form the Registry section is a credential picker: type or select the name of a stored credential and Ruscker resolves it at spawn. There is no need to inline registry passwords in a spec.
Landing editor
Customise the public landing without a custom template:

- Colours + intro — header background/foreground, a per-locale intro paragraph.
- SEO & sharing — page title, meta description,
og:image, with a live Google-style search-result preview that updates as you type. The landing<head>emitsdescription+og:*+twitter:card. - Analytics & custom code — paste a provider snippet (Plausible, Matomo, GA…) and list its origins; Ruscker widens only the landing’s CSP so the script can load. The custom-CSS and analytics/HTML fields are syntax-highlighted code editors (the custom HTML blocks editor too).
- Logos — add logos to the landing header or footer slot. Each logo has its own alignment (left / center / right), an optional click-through link, and a height in pixels. Multiple logos sharing a slot and alignment render side by side. Images come from the Media library — pick one with the same modal picker used in the spec form.
Blocks
Custom HTML blocks rendered in the landing top (after the header) and
bottom (after the card grid) slots. Create/edit/delete, enable/
disable, and reorder within a slot with the ↑/↓ buttons. Each block can
declare CSP origins for any third-party content it embeds.
Block and analytics HTML is rendered verbatim on the public landing. It’s admin-only input — the intentional escape hatch — so only paste HTML you trust.
Disk
Storage at a glance (Admin-only). A usage hero shows host disk used / total with a percentage and a stacked bar split into Ruscker images, other used, and free. Below it, two panels list the Ruscker-managed containers and images — each removable, with an “in use” cross-reference so you don’t delete something a running app or the effective catalog needs, plus bulk “prune stopped containers” and “remove unused images”.
Logs
The server log stream, live over Server-Sent Events. Lines are colour- coded by level (error / warn / info / debug), with a level dropdown, a free-text filter, and a pause/resume control. A download link grabs the current buffer.
Audit log
Every admin mutation (spec/image/credential/landing/block changes, imports) is recorded with actor, action, target and timestamp.
Config vs. database
serve --config drives the public landing and the proxy. The admin
panel reads and writes the SQLite DB. Use ruscker import to populate
the DB from a YAML, and ruscker export to round-trip it back — both
preserve specs, landing customization, SEO/analytics and custom
blocks.
Deploying in production
This walks through the production pattern Ruscker was designed for:
the .deb on a Docker host, behind nginx, optionally side-by-side
with an existing ShinyProxy.
1. Install and configure
sudo apt install ./ruscker_<version>_amd64.deb
Manage your apps, landing page and users in the admin panel at
/admin — the unit runs with --db, so the catalog is live out of the
box. application.yml only carries deployment settings; put your
secrets in /etc/ruscker/ruscker.env (read by the unit):
RUSCKER_ADMIN_TOKEN=... # openssl rand -hex 32
RUSCKER_MASTER_KEY=... # for the credentials store
RUSCKER_COOKIE_KEY=... # keep sticky sessions stable across restarts
DOCKER_REGISTRY_PASSWORD=... # referenced as ${DOCKER_REGISTRY_PASSWORD} in the YAML
2. Enable the container backend
The shipped unit serves landing + admin + proxy but not the --docker
backend. The easy path is the helper, which writes the drop-in for you
(preserving your --bind/--config/--db):
sudo ruscker-enable-docker
Or do it by hand with a drop-in (so upgrades don’t clobber it):
sudo systemctl edit ruscker
[Service]
SupplementaryGroups=docker
ExecStart=
ExecStart=/usr/bin/ruscker serve --config /etc/ruscker/application.yml \
--bind 127.0.0.1:8090 --docker --db /var/lib/ruscker/ruscker.db
sudo systemctl daemon-reload && sudo systemctl restart ruscker
Adding
rusckerto thedockergroup is effectively root on the host — the same trade-off ShinyProxy carries.
On-demand vs. pre-warmed
Ruscker’s auto-scaler keeps min-replicas (default 1) warm per
spec. With many specs that’s a lot of idle containers. To match
ShinyProxy’s on-demand behaviour (spawn on first request, reap when
idle), set min-replicas: 0 on the specs.
Multiple Docker hosts (Phase 6)
By default Ruscker drives the local Docker daemon. To spread app
containers across several hosts, list them under proxy.hosts — then
Ruscker schedules, routes, monitors and reaps containers on all of
them from the one process:
proxy:
hosts:
- id: gpu-1
address: ssh://ops@10.0.0.11 # Docker over SSH — simplest
- id: gpu-2
address: tcp://10.0.0.12:2376 # Docker over TLS
tls: { ca: /etc/ruscker/ca.pem, cert: /etc/ruscker/cert.pem, key: /etc/ruscker/key.pem }
max-containers: 40 # cap; placement won't exceed it
weight: 2 # bigger host ⇒ more of the spread
specs:
- id: heavy-shiny
container-image: org/shiny:latest
placement: spread # spread (default) | bin-pack
anti-affinity: true # replicas on distinct hosts
Transports. ssh://user@host[:port] (reuses your SSH keys — no
daemon TLS to set up), tcp://host:port + tls (mutual TLS), or
http://host:port (plain TCP — trusted networks only). SSH is the
least-effort option. Empty hosts keeps the single-local-daemon mode.
Networking — the catch. On a remote host Ruscker publishes each
container’s port on the host’s 0.0.0.0 and proxies to
host:published-port. So the Ruscker box must be able to reach the
app hosts on the ephemeral published ports (roughly 32768–60999).
Put Ruscker and the hosts on a private network and open that range
between them; do not expose those ports to the public — they’re
unauthenticated app backends. (For SSH hosts, the SSH connection is
only the Docker control plane; the data plane is still this direct
TCP path.)
Placement. spread (default) distributes replicas (weighted by
weight) for fault isolation; bin-pack fills one host before the
next. anti-affinity: true keeps a spec’s replicas on distinct hosts,
falling back gracefully when every eligible host already runs it.
max-containers caps a host; if all hosts are full a spawn fails and
the scaler retries. The dashboard’s Host column shows where each
replica landed.
Validating it. A gated integration test exercises spawn + spread + routed stop against two real daemons:
RUSCKER_IT_HOST1=ssh://ops@10.0.0.11 \
RUSCKER_IT_HOST2=ssh://ops@10.0.0.12 \
cargo test -p ruscker-docker --features multihost-it -- --nocapture
3. nginx
Terminate TLS at your edge / load balancer and forward to nginx with
X-Forwarded-Proto: https. A minimal reverse proxy:
server {
listen 80;
server_name portal.example.com;
# Media-library / card-image uploads. nginx defaults to 1 MB, which
# silently 413s any larger image before it reaches Ruscker (the admin
# "upload doesn't work" symptom). Ruscker itself accepts up to 12 MB.
client_max_body_size 16m;
location / {
proxy_pass http://127.0.0.1:8090/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # WebSocket (Shiny)
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
Set server.useForwardHeaders: true in the YAML so Ruscker trusts
X-Forwarded-* (needed for Secure cookies and per-client API rate
limiting).
Two Ruscker endpoints stream over Server-Sent Events (SSE): the live dashboard (
/admin/dashboard/events) and the Ruscker log tail (/admin/logs/stream). nginx’s default response buffering delays or blocks these streams — disable it for both paths:location = /admin/dashboard/events { proxy_pass http://127.0.0.1:8090; proxy_buffering off; proxy_read_timeout 1h; # + the same proxy_set_header lines as above } location = /admin/logs/stream { proxy_pass http://127.0.0.1:8090; proxy_buffering off; proxy_read_timeout 1h; # + the same proxy_set_header lines as above }
4. Side-by-side with ShinyProxy
To run both during a migration, give ShinyProxy a context path and route by prefix:
# ShinyProxy under /sp/ (set server.servlet.context-path: /sp in its YAML)
location /sp/ { proxy_pass http://127.0.0.1:8080; /* + the proxy_set_header lines */ }
# Ruscker at the root
location / { proxy_pass http://127.0.0.1:8090/; /* + the proxy_set_header lines */ }
Cut over by reloading nginx; roll back by restoring the previous
config. Keep a backup of the site file and run nginx -t before every
reload.
4b. Mounting under a base path (subpath)
When you can’t create a subdomain (no DNS governance), serve the whole
portal under a subpath like example.org/apps/. Start Ruscker with
--base-path /apps (or server.context-path: /apps in the config —
ShinyProxy’s server.servlet.context-path is also accepted), and point
nginx at it preserving the prefix (no trailing path on proxy_pass,
so the /apps stays in the forwarded URL):
# Forward both the bare /apps and everything under /apps/ — without an
# nginx-side redirect (Ruscker does its own canonicalization below).
location = /apps { proxy_pass http://127.0.0.1:8080; /* + proxy_set_header from §3 */ }
location /apps/ { proxy_pass http://127.0.0.1:8080; /* + proxy_set_header from §3 */ }
Ruscker then nests every route under /apps (/apps, /apps/admin/…,
/apps/app/{spec}/…), rewrites the URLs/redirects it emits to carry the
prefix, and injects a small runtime shim so JS-built requests (the live
dashboard’s SSE, fetch, etc.) resolve under /apps too. /healthz and
/readyz stay at the root for load-balancer probes — don’t put them
behind the /apps locations. --base-path is empty by default, so
root-mounted deploys are unaffected.
The canonical landing URL is
/apps(no trailing slash); a request to/apps/308-redirects to it. Keeping nginx as a plainproxy_pass(never redirecting) means that single hop can’t loop.
5. Health checks
Point your load balancer / orchestrator at:
GET /healthz— liveness, always200(no dependencies).GET /readyz— readiness; probes the DB (SELECT 1) and the Docker backend, returns503while draining or when a dependency is down.
On SIGTERM Ruscker flips /readyz to draining, lets in-flight
sessions wind down up to proxy.shutdown-grace-ms, then exits.
Running active-active (HA, Phase 7)
For high availability you can run several Ruscker instances behind a
load balancer, all sharing one Postgres. There’s a runnable harness
(two instances + nginx + Postgres) in examples/ha/ — start
there. The three things every instance must share:
--config-db-url postgres://…— the admin catalog (specs, landing, users, credentials, audit) lives in Postgres instead of a per-node SQLite file, so an edit on any instance is seen by all. Runs the same migrations as SQLite;--dbstays the single-node default.--session-store-url postgres://…— one sharedproxy_sessionstable. Each instance reconciles the cluster-wide per-replica session counts, so routing and the scaler agree across the fleet.- the same
RUSCKER_COOKIE_KEYon every instance — the sticky cookie is an HMAC, so a shared key lets any instance validate a cookie another minted. With that, the shared session table, and every instance pointed at the same Docker backend (so they reconcile the same replicas), a session survives the load balancer moving it between instances.
Scaler leader election is automatic: instances elect one leader via
a Postgres advisory lock; only the leader spawns/reaps replicas, the
rest serve traffic. If the leader dies its lock releases and another
takes over within one scaler tick — nothing to configure. Each instance
logs its role at startup (acquired leadership / standing by).
Use a direct Postgres connection for the leader lock. The advisory lock is session-scoped — it lives on one specific backend connection and is held for the process’s lifetime. Point
--config-db-url(or--session-store-url) at Postgres directly, or through a pooler in session mode only. A transaction-pooling proxy (e.g. PgBouncer in transaction mode) hands the lock-holding connection to other clients between statements, which can break the single-leader guarantee and let two instances scale at once.
App traffic doesn’t need sticky upstreams — proxy sessions are
shared (the sticky cookie is a shared-key HMAC and the proxy_sessions
table is shared), so round-robin is fine for /app and /api. Keep
server.useForwardHeaders: true and pass X-Forwarded-* as in the
nginx section above.
Shared admin sessions (eliminate the sticky-upstream caveat)
By default the sign-in session (the cookie minted at /admin/login)
lives in an in-memory map per process. An admin — or, with per-group app
visibility (access control), any signed-in user —
who logs into instance A and is then round-robined to instance B is
bounced back to the login screen (B doesn’t know the session), and on
the proxy path B would treat them as anonymous (so a restricted app
could 403/redirect even though they’re entitled to it).
Recommended fix: point Ruscker at a shared Postgres for the admin session table (#185):
ruscker serve … --admin-session-store-url postgres://ruscker:pw@pg:5432/ruscker
(or RUSCKER_ADMIN_SESSION_STORE_URL=…). All instances then read and
write the same admin_sessions table. A short node-local TTL cache
(default 5 s) means the hot path — every authenticated request and
every /app proxy guard — keeps serving from memory; the DB is only
hit on cache miss or after a write. Logout propagates to sibling
instances within one cache window (worst case 5 s of “still signed in”
on a peer). The table auto-creates on connect; no migration step is
needed.
This removes the need for a sticky LB. Round-robin (or least-conn) is fine; any node can serve any session-bearing path.
One database, three tables.
--config-db-url(shared spec catalog),--session-store-url(proxyproxy_sessionstable from Phase 7), and--admin-session-store-url(sign-in sessions, #185) can all point at the same Postgres URL — each store creates and owns its own table. No need to provision three databases.
Fallback: sticky upstream
If you can’t host shared Postgres for admin sessions, pin the
session-bearing paths to one instance with a sticky upstream. The
simplest is ip_hash (route by client IP); a cookie-based sticky on
the ruscker_admin_session cookie is more precise if you have nginx
Plus or a similar LB.
# Pin each client to one instance so its sign-in session is found.
upstream ruscker {
ip_hash; # sticky by client IP
server 10.0.0.11:8080;
server 10.0.0.12:8080;
}
server {
# … TLS, proxy headers (see above) …
location / { # landing, /admin, /app, /api
proxy_pass http://ruscker;
}
}
Either path — shared store or sticky upstream — keeps no data lost on a failover. The shared store skips the re-login; sticky upstream re-logs in the user only when the pinned node falls over.
Today the running proxy reads its spec list from the YAML (
--config), so deploy the sameapplication.ymlto every instance; the Postgres catalog backs the admin UI. Seed a fresh Postgres catalog from YAML withruscker import --config-db-url postgres://…(idempotent, same as the SQLite--dbimport).
Running with Docker instead of the .deb
If you’d rather run the container image, mount your config and the
Docker socket (the --docker backend talks to the host daemon) and
persist the SQLite DB on a volume:
# docker-compose.yml
services:
ruscker:
image: ghcr.io/strategicprojects/ruscker:latest
command: >
serve --config /etc/ruscker/application.yml
--bind 0.0.0.0:8080 --docker --db /var/lib/ruscker/ruscker.db
ports: ["127.0.0.1:8090:8080"]
environment:
RUSCKER_ADMIN_TOKEN: ${RUSCKER_ADMIN_TOKEN}
RUSCKER_MASTER_KEY: ${RUSCKER_MASTER_KEY}
RUSCKER_COOKIE_KEY: ${RUSCKER_COOKIE_KEY}
volumes:
- ./application.yml:/etc/ruscker/application.yml:ro
- /var/run/docker.sock:/var/run/docker.sock
- ruscker-data:/var/lib/ruscker
restart: unless-stopped
volumes:
ruscker-data:
nginx sits in front exactly as above. Mounting the Docker socket grants
the container control of the host daemon — the same root-equivalent
trade-off as the docker group with the .deb.
Backups
State lives in two places:
-
/etc/ruscker/—application.ymlandruscker.env(your tokens). Back these up with the rest of/etc. -
The SQLite DB (
/var/lib/ruscker/ruscker.db) — specs, the image library, encrypted credentials, landing customization and the audit log when you run with--db. Snapshot it consistently with:sqlite3 /var/lib/ruscker/ruscker.db ".backup '/backup/ruscker.db'"The encrypted credentials are useless without
RUSCKER_MASTER_KEY, so back up the key too — separately.ruscker export --db <file>also writes a YAML snapshot (everything except the encrypted secrets).
Upgrading
Build the new .deb, copy it over, and reinstall keeping your config:
sudo dpkg -i --force-confold ruscker_<version>_amd64.deb
sudo systemctl restart ruscker
--force-confold preserves ruscker.env (your token) and the config;
the systemd drop-in is untouched. New DB migrations apply on the next
start.
Troubleshooting
/admin returns 503 “RUSCKER_ADMIN_TOKEN is not set”
No admin token is configured. Set RUSCKER_ADMIN_TOKEN (the .deb
generates one — sudo grep RUSCKER_ADMIN_TOKEN /etc/ruscker/ruscker.env)
and restart. The admin pages also need serve --db <file>; without it
the editor/list screens return 503.
Card logos don’t show up
The images aren’t being served at /assets/img/. Either pass
--images-dir <dir> pointing at the folder with the image files, or
keep the config next to its template-path’s assets/img/ so Ruscker
auto-discovers it. Check: curl -I http://localhost:8090/assets/img/<file>.
With --db, you can also upload logos in Media and pick them in
the spec form.
Apps don’t start (proxy returns 503 / 502)
503 no container backend— you started without--docker. Add it (and give the service Docker access).502— the container failed to start or pull. Checkdocker logsfor the spawnedruscker-<spec>-<id>container, and verify registry credentials. Private images needdocker-registry-username+docker-registry-password(the latter via${DOCKER_REGISTRY_PASSWORD}).
A Shiny app loads but the page is broken / no live updates
Shiny needs WebSockets. Make sure your reverse proxy forwards the
upgrade headers (Upgrade / Connection "upgrade") — see the nginx
snippet in Deploying.
The admin shows the wrong / old features after an upgrade
Templates are compiled into the binary, so changes need a rebuild +
reinstall, not just editing files on the server:
sudo dpkg -i --force-confold ruscker_<version>_amd64.deb && sudo systemctl restart ruscker.
413 Payload Too Large on a Media upload
There are two independent size limits in the upload path; the nginx limit fires first and is the more common culprit.
nginx (most common). nginx’s default client_max_body_size is 1 MB.
Any upload larger than that is rejected by nginx before Ruscker even sees
the request — the admin shows a generic “upload doesn’t work” failure with
no obvious error. Ruscker itself accepts up to 12 MB for media uploads.
Set a higher limit in your nginx server block:
client_max_body_size 16m;
See the full nginx snippet in Deploying.
Ruscker proxy.max-body-size. A separate cap applies to requests
forwarded to app containers. If a specific API spec rejects large POSTs
with 413, raise proxy.max-body-size globally or as a per-spec override.
429 Too Many Requests from an API
The spec’s api.rate-limit is throttling the caller. The Retry-After
header says when to retry. Behind a proxy, set
server.useForwardHeaders: true so the limiter keys on the real client
IP (X-Forwarded-For) instead of the proxy’s.
Building the .deb fails on a locked-down host
If the host can’t reach crates.io / static.rust-lang.org (only Docker
Hub + GitHub), build the .deb off-box — e.g. in a rust:<ver>
container on a machine with full internet, or in CI — and copy the
artifact over. Docker pulls and the build.rs Tailwind download (from
GitHub) still work from a connected builder.
perl: warning: Setting locale failed during apt/dpkg
Cosmetic — the install still succeeds. It means a locale your SSH
session forwards (commonly LC_CTYPE=UTF-8 from a macOS client via
SendEnv LC_*) isn’t a valid locale name on the Linux host (which has
C.UTF-8 / en_US.UTF-8, not bare UTF-8), so perl-based maintainer
scripts fall back to C.UTF-8. It comes from apt’s own machinery,
not Ruscker’s package scripts. To silence it, either fix the host
locale and stop forwarding the bogus one:
sudo locale-gen en_US.UTF-8 && sudo update-locale LANG=en_US.UTF-8
# optionally drop `AcceptEnv LC_*` from the host's sshd_config,
# or remove `SendEnv LC_*` for that host in your local ~/.ssh/config
or just prefix the install in your deploy/auto-update script:
export LC_ALL=C.UTF-8 LANGUAGE=
sudo apt-get install -y ./ruscker_<version>_amd64.deb
Users bounce between replicas / lose their session after a restart
The sticky-session cookie is signed with RUSCKER_COOKIE_KEY. If you
don’t set it, Ruscker generates a random key on each start — so every
restart invalidates existing session cookies and can scatter users
across replicas. Set a stable RUSCKER_COOKIE_KEY (e.g.
openssl rand -hex 32) in ruscker.env and keep it constant.
The dashboard doesn’t update live (or lags badly)
The dashboard streams over Server-Sent Events. A reverse proxy that
buffers the response will hold the stream back. Disable buffering for
/admin/dashboard/events — see the nginx note in
Deploying.
The favicon doesn’t appear in Safari (or shows a stale icon)
Safari caches favicons aggressively and sometimes keeps serving a stale or broken icon long after you upgrade Ruscker. To force a refresh:
- In Safari, go to Settings → Advanced and enable the Develop menu.
- Open Develop → Empty Caches, then reload the page.
- If that isn’t enough, close all tabs pointing at the site and reopen them.
For iOS Safari, a full Safari data clear (Settings → Safari → Clear History and Website Data) removes the icon cache.
This is a browser-side caching behaviour, not a Ruscker bug. The favicon
markup was hardened in v0.1.18 (the sizes="any" attribute that confused
Safari’s icon selection was removed).
docker pull ghcr.io/strategicprojects/ruscker is denied
A freshly-published image package starts private. Either make the
package public (Packages → the package → Package settings →
visibility), or authenticate: docker login ghcr.io with a token that
has read:packages.
Jupyter or Voilà loads a blank page / 404s on assets
Ruscker uses a strip model: /app/{id} is stripped from the request
path before forwarding, so the container always sees a root-relative path.
The proxy injects a <base href>, rewrites static URLs in HTML responses,
and patches runtime JavaScript via a shim — most apps (Shiny, Streamlit,
Dash, Voilà) need no special configuration.
Voilà — no special setup is required; the generalized runtime shim handles Voilà’s RequireJS bootstrap.
JupyterLab / Jupyter Notebook — the proxy also rewrites the
jupyter-config-data JSON block (where Lab stores baseUrl,
fullStaticUrl, and related paths) so the browser loads its chunks from
under the mount. Because Ruscker strips the mount prefix before
forwarding, the container should serve at root (base_url=/) and let
the proxy do the prefixing. Configure the spec like this:
- id: jupyter
container-image: quay.io/jupyter/minimal-notebook:latest
container-port: 8888
container-cmd:
- start-notebook.py
- --IdentityProvider.token=
- --ServerApp.allow_origin=*
- --ServerApp.base_url=/
--IdentityProvider.token= disables the login token so the proxy can
forward requests without authentication, and --ServerApp.allow_origin=*
lets the kernel WebSocket connect. Do not set
--ServerApp.base_url=#{publicPath}: under Ruscker’s strip model the
container never sees the mount prefix, so a non-root base_url makes
Jupyter 404 every path.
Do not set SHINYPROXY_PUBLIC_PATH in container-env either. That
variable is a ShinyProxy convention; Ruscker does not use it, and if a
container reads it to self-prefix URLs it will 404 on every request.
If assets still 404 after configuring the above, check
docker logs <ruscker-container-id> for startup errors and verify the
image’s server is listening on the port you set in container-port
(Jupyter uses 8888; the Ruscker default is 3838, the Shiny Server port).
Inspecting what’s running
systemctl status ruscker
journalctl -u ruscker -f
curl -s localhost:8090/readyz
docker ps --filter label=ruscker.replica_id
FAQ
Can I import my existing application.yml?
Yes — Ruscker reads the familiar YAML schema. Point it at your existing
file and run ruscker validate application.yml --strict-compat to get a
report of any feature Ruscker doesn’t support yet (it refuses to silently
ignore them). See Migrate an existing config.
What does Ruscker need to run?
Just the binary and a Docker host. Ruscker is a single static binary written in Rust — no language runtime, no toolchain, and no application server to manage alongside it. The idle footprint is ~14 MB.
Which app frameworks can it host?
Anything that runs in a container and speaks HTTP or WebSocket. R/Shiny is the reference case, but Streamlit, Dash, Voilà, Gradio, Panel, Bokeh, Plumber, FastAPI, Flask, and plain web services all work. The full list is in What Ruscker can serve.
Does it isolate sessions?
Yes. Stateful apps get one container per session with sticky routing and WebSocket forwarding by default. Stateless APIs are load-balanced across replicas with no sticky cookie.
Do I need Kubernetes?
No. Ruscker drives the Docker daemon. A single Docker host is the
common case; for more capacity it can schedule across several Docker
daemons over ssh:// or tcp:// (see
multi-host scheduling). There’s no Kubernetes
requirement — if you’re all-in on k8s, see
where Ruscker fits.
How does scaling work?
Each spec has a replica pool with min/max bounds. An auto-scaler keeps
min replicas warm, spawns more on sustained saturation, and reaps idle
ones after a grace period. Routing is least-connections (interactive) or
round-robin (APIs). It’s all in Configuration.
How does authentication work?
The admin panel has user accounts with Viewer / Editor / Admin
roles (plus a break-glass token). The same accounts gate per-app
visibility: every spec can declare access-groups / access-users
and only matching users see the card and reach /app / /api
(specs with no access keys remain open to anyone) —
see Per-user access.
External identity providers (OIDC / SAML / LDAP) for end-user
sign-in are Phase 8; user accounts are managed in
the admin Users page until then.
Where is configuration and state stored?
By default in a local SQLite database (--db), with YAML as the
import/export format. For multi-instance HA the same admin catalog and the
session store live in shared Postgres (--config-db-url /
--session-store-url). Secrets never go in YAML — use ${ENV_VAR}
interpolation. The named credentials store is AES-encrypted at rest; it
also accepts a pure ${VAR} env-ref as the password (stored verbatim,
resolved at pull time, so the decryption key is never needed for env-based
credentials).
Can I run more than one instance for high availability?
Yes. Several instances share one Postgres for the catalog and session
state, behind a load balancer; a Postgres advisory lock elects a single
auto-scaler leader with automatic failover. There’s a runnable two-node
harness in examples/ha/ — see the
active-active section of the deploy guide.
One operational caveat: until a shared admin-session store ships, pin
the sign-in session paths to a single upstream — see
Sticky upstream for the sign-in session.
Is it production-ready?
Yes. The current release is v0.1.80 — Phases 0–7 are complete and the proxy is production-ready and horizontally scalable. Releases are multi-arch and cosign-signed; see the release notes and the Roadmap. Phase 8 (external auth: OIDC / SAML / LDAP) is the main remaining optional work.
What platforms does it run on?
Ruscker runs on a Linux Docker host — install via the multi-arch Docker
image, a Debian package with a systemd unit, or a static musl tarball.
A Homebrew tap builds it on macOS for local development. The apps
themselves are Linux containers.
An app won’t load behind Ruscker — what now?
Most issues are URL-rewriting, cookie-key, or backend-readiness related. Start with Troubleshooting.
Architecture
The system design below is the same document maintained in the
repository (docs/ARCHITECTURE.md).
Architecture
Ruscker is a Rust-based proxy and orchestrator for containerized interactive web apps and stateless HTTP APIs. This document describes how the pieces fit together.
High-level diagram
All of this is a single Rust process — one static binary, ~14 MB idle,
no JVM. Visitors and API clients reach it on one port; it serves the
landing page and admin UI, reverse-proxies /app/{spec} and
/api/{spec} to the right replica (keeping Shiny sessions sticky and
upgrading WebSockets), and drives the Docker daemon to spawn and reap
containers. SQLite is the source of truth for configuration; the live
replica registry and session store live in memory.
Crate map
The workspace is six crates. ruscker-config and ruscker-core are
pure-domain — no I/O, no async (bar the async trait definitions
in core). Everything that touches the network or Docker layers on top,
and the ruscker-cli binary stitches them together.
Keeping the backend behind the ContainerBackend trait in
ruscker-core means a future Kubernetes or multi-host backend is a new
impl, not a rewrite — see Deployment shapes and
docs/adr/.
Request flow
A Shiny session lifecycle
1. Visitor hits https://portal/app/sales-dashboard/
2. Proxy reads cookie __ruscker_session
3. Cookie missing → Proxy.create_session:
a. Look up spec 'sales-dashboard' in config
b. Ask ContainerBackend.list() for current replicas
c. Router.pick(replicas) → ReplicaDecision::Use(R2) (least-conn)
d. If Saturated:
- Check spec.max_replicas
- If room, ContainerBackend.spawn() → wait for Ready → retry
- Else 503
e. SessionStore.create(Session { spec, replica: R2 })
f. Sign and set cookie __ruscker_session
4. Forward GET / to http://127.0.0.1:<R2_port>/ (path rewrite)
5. Stream response back
6. Browser opens WebSocket ws://portal/app/sales-dashboard/websocket
7. Proxy upgrades, opens parallel WS to ws://127.0.0.1:<R2_port>/websocket
8. Bidirectional frame pump
9. On heartbeat: SessionStore.touch()
10. Idle timeout reached → Session purged → if last seat, container drained
An API request lifecycle
1. Client hits https://portal/api/data-api/v1/data
2. Spec.kind() == Api → no sticky cookie path
3. Router.pick() balances by in-flight request count → R3
4. Bump R3's in-flight gauge, forward request, stream response
5. In-flight gauge drops only after the full body has streamed out
6. No session state, no follow-up — done.
An Api spec has no sticky sessions, so its replicas have no seat
notion to balance on. Instead the proxy keeps a per-replica in-flight
request gauge (routes::proxy::INFLIGHT, a process-global DashMap)
and least-connections routing picks the replica with the fewest
in-flight requests, not the most free seats. An RAII
routes::proxy::InflightGuard bumps the gauge when the forward starts;
crucially it is moved into the streaming response body, so it only
drops once the whole (possibly long) download has been sent to the
client — a large file transfer keeps counting against the replica for
its full duration, and the scaler sees real concurrency rather than a
spike that vanishes the instant headers are written.
Proxying an app under /app/{spec}/ — the strip-and-rewrite model
A containerised app expects to live at the host root: it emits
/lib/jquery.js, opens WebSocket('/websocket'), redirects to /lab.
Ruscker serves it from a sub-path (/app/sales-dashboard/). Two halves
reconcile that gap.
On the way in, the proxy strips the mount prefix. forward()
matches /app/{spec}/{*rest} and forwards only the *rest portion to
the container, so a request for /app/sales-dashboard/lib/x reaches the
upstream as /lib/x — the container believes it is at the root and
never has to know its public path. (This is the opposite of ShinyProxy’s
no-strip model; apps should be configured to serve at root, not to
self-prefix.) The proxy also stamps X-Forwarded-Prefix /
X-Script-Name / X-RStudio-Root-Path with the public mount so apps
that do build their own absolute URLs (RStudio, Jupyter) emit correct
links — see routes::proxy::apply_smart_routing_headers.
On the way out, the proxy rewrites the response so the browser sends
follow-up requests back under the mount. This lives in
routes::rewrite (inject_base_href) and runs only on the
/app/ route family, only for HTML responses:
<base href="/app/{spec}/">is injected at the top of<head>, so relative URLs (foo.css,./img/x.png) resolve under the mount.- Root-absolute attribute URLs (
<script src="/lib/x">,<link href="/...">,<form action="/...">, …) are prefixed with the mount via a streaminglol_htmlpass over a narrow selector set. A skip-list (/admin/,/assets/,/app/, …) avoids double-prefixing Ruscker’s own chrome; notably/api/is not skipped, because under the mount it is the app’s own namespace (Jupyter’s REST + kernel WebSocket live there). - A runtime JS shim is prepended before any page script. It
monkey-patches
fetch,XMLHttpRequest.open, andWebSocketto prefix absolute paths built at runtime. The shim was generalized to also patch the resource-loading property settersHTMLScriptElement.prototype.src,HTMLLinkElement.prototype.href, andHTMLImageElement.prototype.src(plusiframe/audio/video/sourceandElement.setAttribute). Those are the browser’s own fetches — never visible to the fetch/XHR wrappers — so patching them covers RequireJS/webpack chunk loading and runtime-set images generically. - A redirect
Locationheader that points at a root-absolute path (an app’s302 → /lab) is prefixed the same way, so the redirect stays inside the app instead of escaping to a Ruscker 404.
The generalized shim retired the old Voilà-specific rewrite: Voilà’s
RequireJS bootstrap assigns its static URLs to script.src at runtime,
which the patched src setter now prefixes without a bespoke pass.
JupyterLab is the one app that still needs a special case
(rewrite::rewrite_jupyter_config). Lab is served with base_url=/ and
reports baseUrl: "/" in its jupyter-config-data JSON; its bootstrap
then builds absolute, same-origin API and static URLs from that
config and injects <script src=…> for its lazy chunks. Because those
URLs are absolute strings baked into a config object — not relative paths
the browser resolves against <base href>, and not paths a root-relative
shim can intercept — Ruscker rewrites the baseUrl and full*Url fields
of that JSON to carry the mount before the HTML pass.
The base-path mount (Ruscker itself served under, e.g., /apps) is the
inverse rewrite and is handled separately: templates emit {{ base }}-
prefixed URLs directly, so the chrome no longer needs a per-request body
rewrite — only the redirect Location header (prefix_base_path).
Module boundaries
Pure layer (no I/O, no async)
ruscker-config::schemaruscker-config::envruscker-config::validateruscker-core::routingruscker-core::replica(types only)ruscker-core::session(types only —SessionStoretrait is async, but the trait def is pure)
I/O layer (async + tokio)
ruscker-docker— talks to Dockerruscker-proxy— listens on a TCP socketruscker-admin— listens on another TCP socketruscker-cli— synchronous main, spawns tokio runtime for I/O commands
State and persistence
Three sources of state, ranked by authority
- SQLite (admin DB) — source of truth for spec configurations, images, credentials, landing-page sections, audit log. Always write here first.
- Live in-memory —
ReplicaRegistry(in proxy),SessionStore(in proxy, in-memory by default). Reflects the running state of containers and sessions. - Docker — actual containers and their state. Source of truth for “is this thing alive”. The proxy queries Docker on startup to rebuild the registry.
The YAML file is NOT a source of truth in production — it’s an import/export format. Ruscker can be configured to auto-export to YAML for git versioning, but the running config lives in SQLite.
State transitions
- First boot, no DB: Bootstrap from
application.ymlif present; otherwise create empty DB. - Subsequent boots: Load from DB. The YAML is optional.
- YAML changes detected (via inotify/polling): Show diff in admin, let operator apply.
Concurrency model
- One tokio runtime, multi-threaded by default.
- The proxy accepts connections on one task per connection, handlers
use
towermiddleware stack. - Container lifecycle (
ContainerBackend::spawn,stop) runs in a dedicated task; admin/proxy request it via a channel and await the result. - The auto-scaler runs as a periodic task (every 10s).
- The session-purger runs as a periodic task (every 60s).
DashMapfor in-memory state (lock-free reads, sharded writes).
Security boundary
Trust levels
- Untrusted: visitors. They can hit
/app/*and/api/*only. Admin paths require an authenticated session. - Privileged: admin users.
/admin/*is gated by per-user password login with three roles — Viewer (read-only dashboard), Editor (apps + media), Admin (everything, incl. user management) — enforced server-side. A break-glassRUSCKER_ADMIN_TOKENbootstraps the first account. Seedocs/SECURITY.md§2. - Operator: filesystem access (the person running Ruscker). Can edit YAML, restart the process.
Secrets at rest
- Docker registry passwords: stored encrypted in
credentials.password_encvia AES-GCM with a master key fromRUSCKER_MASTER_KEYenv var. - Session cookie signing: HMAC-SHA256 with key from
RUSCKER_COOKIE_KEYenv var (auto-generated on first run if missing). - TLS: rustls with cert paths in config. Optional but recommended.
Deployment shapes
Single-node (default)
A reverse proxy terminates TLS in front of a single Ruscker, which talks to the local Docker daemon over its socket. This is what 99% of installs run — simple, fast, easy to operate.
Multi-node HA (active-active, since Phase 7)
Two or more Ruscker instances behind an L4 load balancer share a Postgres
config catalog and session store, so either can serve any session.
Exactly one instance holds the scaler leadership at a time via a Postgres
advisory lock; standbys serve traffic and reconcile counts but skip the
spawn/reap loop. The sticky cookie is an HMAC over a shared key, so any
instance can validate any other’s cookie. The ContainerBackend /
SessionStore traits leave room for a multi-host or Kubernetes backend
without touching proxy code. See the deployment guide’s
“Running active-active” section for the runnable example.
What’s not covered here
- The admin UI internals — see the
ruscker-admincrate (cargo doc --open). - The proxy’s WebSocket handling — see the
ruscker-proxycrate. - Specific algorithm choices — see
docs/adr/. - The YAML schema — see
docs/YAML_SCHEMA.md.
Roadmap
Ruscker is at v0.1.80 — Phases 0 through 7 are done and the proxy is production-ready and horizontally scalable. Phase 8 (external auth) is the main optional, demand-driven work left. For what changed in each release, see the release notes.
Shipped
Phase 0 — Scaffolding
The Cargo workspace, the ShinyProxy-compatible YAML schema, env-var
interpolation, two-phase validation, and the ruscker validate /
show / inspect CLI.
Phase 1 — Landing page
The public portal rendered from config with Askama + Tailwind 4 (no Node toolchain), full i18n in pt-BR / en-US / es-ES / fr-FR, theme and locale cookies, and the kind-tinted card grid with filters.
Phase 2 — Persistence + admin CRUD
SQLite as the source of truth (sqlx, embedded migrations),
ruscker import / export to round-trip YAML, and the admin panel:
apps list + spec form, image/media library (WebP conversion), an
AES-256-GCM credentials store, the landing-page editor, and an audit
log.
Phase 3 — Proxy + Docker backend
HTTP forwarding, sticky sessions (HMAC-signed cookie), WebSocket proxying, the Docker backend (spawn / stop / stats / logs via bollard), per-spec replica pools, the auto-scaler (scale-to-min, scale-up on saturation, scale-down on idle with hysteresis), the session tracker + heartbeat sweeper, absolute-URL rewriting so unmodified Shiny/Streamlit apps work behind a sub-path, and per-container CPU/memory limits.
Phase 4 — Monitoring dashboard
A live dashboard over Server-Sent Events: aggregate cards,
per-replica state / uptime / sessions / CPU + memory (with
sparklines), one-shot and live-follow logs, per-replica
stop / restart, and a Prometheus /metrics endpoint.
Phase 5 — Production polish → v0.1.0
/healthz + /readyz probes, graceful shutdown (session drain),
structured JSON logging, per-API rate limiting + CORS, request
body-size limits, validate --strict-compat migration pre-flight,
role-based access control (Viewer / Editor / Admin with per-user
password accounts), smart-routing headers (X-Forwarded-Prefix …),
and distribution: a multi-arch Docker image, a Debian package
with a hardened systemd unit, static musl tarballs, a curl | sh
installer, a Homebrew tap, and cosign-signed release artifacts.
Production milestone. Ruscker replaced a JVM-based stack on the same machine serving the same apps, cutting idle memory from ~540 MB to ~14 MB (~38×). A real 31-spec config migrated with no unsupported features.
Phase 6 — Multi-host scheduling → v0.1.1 / v0.1.2
A MultiHostDockerBackend that talks to several Docker hosts, with
bin-pack vs spread placement and per-spec anti-affinity (“replicas on
different hosts”) — behind the existing ContainerBackend trait, no
proxy changes. Shipped alongside per-group / per-user app visibility
(access-groups / access-users) and sub-path mounting
(server.context-path / --base-path).
Phase 7 — HA / multi-instance → v0.1.1
A Postgres SessionStore and a shared config catalog so several Ruscker
instances behind an L4 load balancer can share state and any instance
can serve any session; one scaler leader via Postgres advisory locks,
with failover. A runnable 2-instance compose harness lives in
examples/ha/. See Deployment shapes.
Post-phase polish → v0.1.4 – v0.1.80
Incremental improvements shipped after Phase 7.
Demo app images. The Dash, FastAPI, and Quarto showcase cards now
point to dedicated milkway/ruscker-*-demo images on Docker Hub.
The Quarto demo is a static nginx image (~67 MB); Dash and FastAPI serve
at the root without needing SHINYPROXY_PUBLIC_PATH. Demo forks for
Shiny, Streamlit, and Voilà are backlog — those cards still point to
upstream images.
URL-rewrite modernization. The runtime shim that rewrites relative
asset paths under /app/{id}/ is now generalized to patch
script.src, link.href, img.src, and setAttribute, which retired
the Voilà-specific rewriter. The Jupyter-config rewriter (rewrite_jupyter_config)
is kept: JupyterLab builds absolute same-origin API URLs from
PageConfig.baseUrl that a root-relative shim cannot intercept. A
full absolute-URL Path B rewrite (handling apps that hard-code
window.location.origin) is deferred — the current shim covers all
validated app types.
Credentials. The named-credential store now accepts a pure
${VAR} env-ref in addition to an AES-256-GCM literal, resolved only
at container pull time. The spec form’s Registry section is now a
credential picker; inline domain/user/password fields remain as
back-compat.
Media library. Built-in logos are seeded into the unified media library (deletable, with an “in use” badge). The spec-form image picker supports inline upload. Gallery pages are paginated with search.
Portal logos. The landing editor supports per-slot logos (header / footer) with alignment (left / center / right), an optional link, and a per-logo height.
Performance. gzip/br compression on admin and landing responses;
?v=<version> immutable cache headers on bundled CSS/JS; ETag on media
assets; WebP thumbnails; bounded Docker stats fan-out with a
configurable metrics-interval.
Security fixes. API routing uses in-flight count (not seats);
charset validation on usernames and credential names; admin password
fields are write-only in the UI; ${VAR} resolution returns an error
when the variable is unset (names the missing variable).
Disk management & admin polish (v0.1.32–v0.1.33). A new Disk panel reclaims space: remove containers, prune every stopped one (label-scoped, never touching a non-Ruscker container), and remove unused images — individually or all at once. Deleting an app now reaps its containers instead of leaving orphans. New accounts must change their password on first login. A one-line startup banner (version, bind, base path, Docker, database, spec count) shows in the admin Logs tab at the default log level. The Portal logos editor reuses the spec form’s image gallery picker.
Docker-by-default & Portal branding (v0.1.34–v0.1.35). serve
auto-connects to Docker when the daemon socket is reachable (pass
--no-docker for landing-only); showcase demos seed with
min-replicas: 0 so a fresh install no longer pre-spawns every demo
container. The Portal gains per-theme colours (independent
background/text/accent for light and dark) and logos that integrate
into the chrome — a header-left logo replaces the Ruscker mark,
header-right trails the buttons, footer-right trails the version, and a
center logo is centred within the bar; each takes an optional margin.
The landing editor is reorganised into labelled section cards with a
sticky Save bar, logos are edited as cards with segmented
position/alignment pickers, and the live preview mirrors the real portal
chrome. The social-share og:image auto-defaults to the header logo
(else the Ruscker mark) and gets a gallery picker. Finally, the live
dashboard streams through reverse proxies (X-Accel-Buffering: no), so
new containers appear in real time even behind nginx on a subpath mount.
Planned (optional)
Demand-driven — Ruscker is complete and useful without it.
Phase 8 — External auth
OIDC (Keycloak / Auth0 / Google), SAML, and LDAP, plus per-app access lists (“only group X can use this app”). The coarse Viewer / Editor / Admin RBAC already shipped in Phase 5; this is the federated-identity and fine-grained-ACL layer on top.
Explicitly out of scope
- Kubernetes backend — possible as a future
ContainerBackendimpl, but not a committed phase until there’s demand. - App-ecosystem features (pause/resume, snapshots) — these are ShinyProxy Pro territory.
- Multi-tenancy / billing and a public app marketplace.
What “done” means
After Phase 5, Ruscker can drop in where ShinyProxy runs today:
ruscker import application.yml --db /var/ruscker/ruscker.db- Stop ShinyProxy.
- Start Ruscker on the same port.
- Verify with the same browser URL.
Phase 8 is for organisations with federated-identity or fine-grained per-app access needs. Progress is tracked in the GitHub issues.
Release notes
What changed in each release. Ruscker follows semantic versioning; while
on 0.x the API and YAML schema stay backward-compatible (new fields are
optional), and breaking changes are called out here.
Downloads (binaries, .deb, container image — all cosign-signed) are on
the GitHub releases page.
v0.2.4 — 2026-06-09
Logo picker is now a searchable modal. The app form’s inline logo
thumbnail grid didn’t scale — with a large media library the tiles
overflowed and overlapped (an SVG with a big viewBox got no height from
aspect-ratio and blew up its cell). It’s now a compact control (current
logo thumbnail + “Choose from library”) that opens the shared image-picker
modal with search, the full library, and inline upload. Tiles got a
min-height fallback so a failed aspect-ratio can’t overflow them, and
logos render contain (no crop).
v0.2.3 — 2026-06-09
Gradient card-cover preview fix. A gradient default card cover didn’t
show in the Appearance preview (the mock cards stayed grey) when the
Card covers: Gradient toggle was on — the preview emitted two
background-image declarations, so the subtle overlay clobbered the
colour gradient. The preview now shows an explicit cover as-is, mirroring
the public landing. Also: the gradient builders now seed from the saved
value (gradientParse), so reopening shows the saved stops and editing a
saved gradient modifies it in place instead of silently resetting it to
the default palette.
v0.2.2 — 2026-06-09
Card-cover preview fix. Selecting the Solid card-cover mode only flipped the editing mode and left the value empty, so the live preview didn’t change until the colour picker was dragged — it read as “the cover adjustment isn’t reflecting”. Switching to Solid now seeds a starting brand colour, so the mock card updates immediately and the picker edits it live. Applies to both the Appearance default-cover builder and the per-app cover builder in the spec form.
v0.2.1 — 2026-06-09
Apps editor + Appearance polish. Four operator-reported fixes:
- Current logo reads as selected in the inline logo gallery. The
match was an exact path compare, which missed a stored value carrying
a base-path prefix (
/box/assets/img/…); it now compares by filename. - Card cover drops the “Image” mode. A logo renders on top of the cover, so an image cover + a logo painted two overlapping pictures on one card. Cover is now tint / colour / gradient only; a legacy image cover degrades to the kind-tint/accent fallback.
- Environment-variable rows are laid out one clean line each
(
KEY · = · value · ✕) instead of the inputs stacking full-width. - Default card cover in Appearance. A new Auto / Solid / Gradient
builder in the Background section sets one catalog-wide default cover
for cards without their own
cover/accent(which still win), so the default is no longer editable only per app (migration 0021).
v0.2.0 — 2026-06-09
Apps editor redesign complete + per-app accent & monogram. The
final piece of the editor rework: each app can now set an accent
colour (tints the card cover when no cover is set) and a monogram
(1–2 chars shown on the cover when there’s no logo) — both stored in
template-properties, no migration. The editor’s Appearance section
gains a swatch row and a monogram field, and the live preview reflects
them. This closes the editor redesign that also brought the handoff
section structure, the inline logo gallery, and the Access & scale
section (v0.1.98–0.1.99).
v0.1.99 — 2026-06-08
Apps editor — Access & scale. The old “Metadata” section is now Access & scale, matching the design: a Restricted access toggle (off = public, and turning it off clears the group/user lists), the Initial replicas stepper surfaced alongside access, and an Autoscaling toggle (in Advanced) that gates the replica ceiling and thresholds. (Still to come: accent colour + monogram.)
v0.1.98 — 2026-06-08
Apps editor — closer to the design. The Edit-application form now follows the handoff structure: Identity (ID + Name side-by-side, Subject) → Kind → Description (its own section) → Appearance → Container → Metadata. The Appearance section gets an inline logo gallery — pick an app logo straight from the media library (or upload via the last tile) instead of opening a modal. (More of the editor — access/scale toggles, accent colour, monogram — lands next.)
v0.1.97 — 2026-06-08
Logs spacing fix. The log lines gave the app column a fixed width, so lines without an app (most infra events) showed a large empty gap between the level and the message. The column now collapses when empty, so the message sits right after the level.
v0.1.96 — 2026-06-08
Logs tab — colour-coded event stream. The Logs view rendered every line in flat grey because the parser expected a log format the server doesn’t emit. It now colours each level (INFO blue, WARN amber, ERROR red), shows the app name and a millisecond timestamp, and the toolbar matches the design: a Pause button, Info/Warn/Error level chips, an “All apps” filter, a live line count, clear, and download — in one card.
v0.1.95 — 2026-06-08
Logo controls behave as expected. Three appearance-editor logo behaviors that read as bugs are fixed:
- The Logo size / margin sliders now resize a custom header logo too, not just the built-in mark.
- A custom header logo in any position (left/center/right) now hides the built-in Ruscker mark — no more mark-plus-logo “two logos”.
- The mark is always brand-colored; a custom header background no longer turns it grey.
v0.1.94 — 2026-06-08
Portal no longer cached + clearer header labels.
- The public portal is now served
Cache-Control: private, no-cache, so appearance changes (catalog layout, colors, …) show on the next load instead of being masked by a browser or proxy cache — and a shared cache can no longer replay one visitor’s access-filtered view to another. Bundled assets stay cached as before. - Renamed two header controls that read alike: the preset is now Header style (was “Portal header”) and the explicit color is Custom background color (was “Background color”), with a note that the custom color overrides the preset.
v0.1.93 — 2026-06-08
Appearance editor fixes. Two follow-ups from testing the editor:
- Image picker on screen. The “Choose image” modal could open
centered in the (tall) editor page instead of the viewport — often
below the fold, hidden. It now teleports to
<body>so it always centers on screen. - Live preview reflects every control. The editor’s portal preview used to mirror only a few fields; it now reacts to the per-theme palette and default theme (the whole frame repaints light/dark), logo mode/size, header preset, card-cover style, catalog layout (grid/list/sections) and density, and the visible-section toggles.
v0.1.92 — 2026-06-08
Appearance — catalog “Sections” layout + editor card order. The catalog-layout picker gains a third option, Sections: the portal catalog grouped by app type, each group under a heading that hides itself when the live filters (search / access / status / type) empty it. Grid and List are unchanged. The appearance editor’s cards are also reordered to match the design handoff (logo controls grouped together, default theme ahead of catalog layout).
v0.1.91 — 2026-06-08
Appearance — analytics provider picker. The appearance editor’s Analytics section now offers a provider picker (Google Analytics 4, Plausible, or Matomo) plus a site-key field; the portal builds the standard snippet from provider + key and opens the matching CSP origins. The raw analytics-HTML field stays as an escape hatch for anything else.
v0.1.90 — 2026-06-08
Appearance editor rebuilt toward the design handoff, plus a Disk-tab polish.
Appearance editor (the admin “Portal” tab is now Appearance, to free “Portal” for the back-to-portal link)
- Footer text is editable; blank keeps the version + wordmark lockup.
- Default theme (light/dark/auto) for a first-time visitor; their own toggle still overrides it.
- Visible sections: toggle the portal search bar and access filters.
- Brand color swatch row sets the accent in one click.
- Logo: header brand mode (mark+name / symbol-only / custom) with size and margin.
- Background: header preset (flat/soft/bold) and card cover style (tinted/gradient).
- Catalog layout: grid or list, comfortable or compact density.
Disk tab
- The table search boxes get a proper inset + search glyph, and the images / containers panels size to their own content (no blank space under the shorter table).
v0.1.89 — 2026-06-07
Reliable cold starts for scale-to-zero interactive apps (#686).
Fixes
- A
min-replicas: 0interactive app (e.g. an IDE withseats-per-container: 1) could fail to open: the scaler reaped the replica it had just spawned for the arriving visitor before they finished the cold-start splash and claimed a seat, leaving them on a dead/again-cold app. A freshly-ready replica is now exempt from idle scale-down for a short grace, so the visitor reliably lands on it. Single-user IDEs can still pinmin-replicas: 1to stay warm.
v0.1.88 — 2026-06-07
A spurious “upstream error” on the first open of an interactive app is fixed (#683).
Fixes
- Opening RStudio Server (or any interactive app) could show a bare “upstream error” on the first navigation, then work on a retry. Cause was a hyper connection-pool race: app servers close idle keep-alive connections quickly, and the proxy could dispatch a request onto a socket the app had already closed. The proxy now evicts idle pooled connections promptly and retries an idempotent (GET/HEAD) forward once on a fresh connection, so the first open just works.
v0.1.87 — 2026-06-07
The redesign’s perceived-speed primitives are now live (#623).
UI
- Top navigation progress bar — a thin teal bar grows while a page
navigation (or form submit) is in flight, across the admin and the
public portal. Honors
prefers-reduced-motion. - Content reveal — admin pages fade in as they load, and the portal card grid cascades in with a small per-card delay.
- Shimmer skeletons — a replica’s CPU/memory cell shows a shimmer while its first live reading is pending, instead of a bare dash, so a loading value reads as loading rather than empty.
These wire up the perceived-speed primitives the #623 handoff defined, completing the Design System pass.
v0.1.86 — 2026-06-07
Web apps pack more sessions per container by default.
Behaviour
seats-per-containernow defaults to 10 for web-framework apps (Shiny, Streamlit, Dash, Voilà) — they serve many concurrent sessions from one process, so a container per visitor was wasteful (the demo Shiny showed “1/1”). APIs keep 100. Single-user IDEs (RStudio, Jupyter) are the exception: setseats-per-container: 1on those so each visitor gets an isolated container, with concurrency frommax-replicas.- The app-editor’s greyed hints now match the real defaults (sessions/container 10, min-replicas 1, max-replicas 5).
v0.1.85 — 2026-06-07
Apps auto-scale to a few independent containers by default.
Behaviour
- A container app that doesn’t set
max-replicasnow defaults to 5 (was effectively 1). So a single-seat interactive app (RStudio, Jupyter, Shiny) serves up to 5 concurrent visitors — one isolated container each, started on demand — instead of locking everyone out after the first. Setmax-replicasper app to raise it (busier app) or lower it (constrained host);Externalapps are unaffected.
v0.1.84 — 2026-06-07
Tell visitors when an app is full instead of an endless “Starting…”.
Interface
- When an app is at its replica ceiling with every seat taken, a new
visitor used to see the same “Starting…” page and wait forever, as if
the container were perpetually booting. The waiting page now detects
this: while the app can still scale it shows “Starting…” as before, but
at capacity it says “
is full right now — this page opens automatically as soon as one frees up.” Both keep polling, so the visitor is let in the moment a seat frees.
v0.1.83 — 2026-06-07
Fix runaway session counts on single-seat interactive apps.
Fixes
- A single browser visit to a
seats: 1app (RStudio, Jupyter) could inflatesessions_activeto 7–9 and climbing, filling the seat and trapping the visitor (and any second visitor) on the starting splash. An app’scrossoriginscript bundles and credential-less requests arrive without the sticky cookie, and each was being counted as a new session. Now only a real visit — a top-level page navigation — opens a session and takes a seat; subresources ride the existing replica without counting. This also makesmax-replicasscale-out behave: N concurrent visitors now map to N containers instead of one visit spawning several.
v0.1.82 — 2026-06-07
Fix single-seat apps (RStudio, Jupyter) getting stuck on the starting splash.
Fixes
- A
seats: 1interactive app could trap the visitor on the “Starting…” splash forever, even with the container up: the first request reserved the app’s only seat for that session, so the app’s own follow-up navigation (RStudio → its sign-in page, Jupyter → its lab) re-entered the splash gate, found no free seat, and was shown the splash again — waiting on the seat it already held. The splash now lets a session that already holds a seat on a ready replica proxy straight through.
v0.1.81 — 2026-06-06
The hi-fi design system reaches every admin screen, plus a new live YAML import editor.
Interface (#623)
- Disk — a usage hero (host disk used / total with a stacked bar) over two side-by-side panels for the Ruscker-managed container images and containers, each with inline prune actions and an “in use” cross-check.
- Apps — a sticky filter toolbar (search + kind chips with live counts
- a sort cycler). Access-group badges now use a fixed palette so the
canonical roles always read the same colour across Apps, Users and
Groups;
publicapps show in teal.
- a sort cycler). Access-group badges now use a fixed palette so the
canonical roles always read the same colour across Apps, Users and
Groups;
- App editor — boolean options became switch toggles; replicas a
−/+ stepper; CPU and memory sliders (with the text field still the
source of truth); environment variables an add/remove
KEY = valuerepeater; access groups a pill picker (custom names still allowed); and the live card preview gained a resources/scale summary. - Appearance editor — flatter section headers and live character counters on the SEO title/description.
- Dashboard — a pulsing “Live” badge and a filter band (search + Ready/Starting/Draining/Stopped chips) over the grouped replica view.
- Users / Groups — the user list moved to the shared rounded table; Groups gained a “Public apps” rail listing every app open to everyone.
- Media — the gallery search is now the rounded search pill.
- Audit log — a sortable table with coloured actor avatars and colour-coded actions; the change diff stays available as an expandable row.
- Logs — a “Live” badge on the server log, and the per-replica container tail now shares the terminal styling.
- Import YAML — a live two-pane editor: edit or paste YAML on the left and watch the parsed apps (each marked new or update, selectable) refresh on the right as you type. Parse errors show inline; nothing is written until you confirm.
Fixes
- The Groups “Public apps” rail no longer lists apps that are gated to specific users as public.
- Restored a few status colours that weren’t rendering (the warning/ok accents behind the SEO over-limit counter, the disk high-usage figure, reclaimable-row tints and in-use badges).
- The app-editor summary now shows the heartbeat timeout in minutes, and the CPU/memory sliders no longer write a spurious zero when dragged fully left.
- The import editor’s empty-state hint no longer breaks under French.
v0.1.80 — 2026-06-06
Fix interactive apps getting stuck on the starting splash.
Fixes
- A container that exited unexpectedly could leave a stale replica behind that blocked new launches of that app and trapped visitors on the “Starting…” page — most visibly on single-seat apps like RStudio and Jupyter. The scaler now prunes replicas whose container is no longer running each tick, freeing their seats.
v0.1.79 — 2026-06-06
The redesign reaches the Apps table and the dashboard.
Interface
- The admin Apps table now shows each app’s framework logo next to its name and an Access column with colour-coded group badges (or “public”) (#623).
- The monitoring dashboard’s collapsed app rows now summarise sessions, CPU and memory with little meters, and a toolbar adds an expand/collapse-all control (#623).
v0.1.78 — 2026-06-06
Fix a cold-start splash that could loop.
Fixes
- A single-seat interactive app (e.g. RStudio) whose seat was already taken could trap a new visitor in a reloading “Starting…” splash. The readiness probe and the splash gate now use the same check, so the page advances exactly when the app can accept the visitor (#582 follow-up).
v0.1.77 — 2026-06-06
The UX redesign reaches every admin screen.
Interface
- The Appearance live preview now shows a search bar and mock cards tinted with the configured accent colour, not empty boxes (#623).
- Each Groups card has a colour-coded accent bar (matching its badge colour elsewhere), and each Credentials row shows a key-icon tile (#623).
v0.1.76 — 2026-06-06
Disk usage at a glance; a proper log viewer.
Interface
- The disk panel opens with a usage hero — total used / capacity, a percentage, and a stacked bar split into Ruscker images, other used and free (real host figures) (#623).
- The server-logs tab is now a colour-coded live terminal: lines are tinted by level, with level and free-text filters and a pause/resume control (#623).
v0.1.75 — 2026-06-06
More UX-redesign polish.
Interface
- Admin action confirmations now appear as floating toasts that dismiss themselves (#623).
- The SEO editor shows a live Google-style search-result preview that updates as you edit the title and description (#623).
v0.1.74 — 2026-06-06
More of the UX redesign.
Interface
- Users page: each account shows a coloured avatar with its initials, and its groups render as coloured badges (#623).
- Restyled form controls — range sliders (teal thumb), the featured-carousel toggle (an on/off switch) and the YAML-import checkboxes (#623).
v0.1.73 — 2026-06-05
Cold-start apps spawn again; syntax-highlighted code editors.
Fixes
- An app with
min-replicas: 0(cold start) and nomax-replicascould never start a container — the default resolved tomax-replicas: 0, so the on-demand spawn was a no-op and the booting splash hung. The default now floors at 1 for containerized apps (#623/#582).
Interface
- The Appearance custom-CSS / analytics-HTML editors and the custom HTML blocks editor now have VS Code-style live syntax highlighting (#623).
v0.1.72 — 2026-06-05
Fix a cold-start splash that could hang.
Fixes
- The “container is booting…” page could poll forever on a busy single-seat app even after the container was up and serving, leaving the visitor stuck. The readiness probe now advances as soon as the app is ready, regardless of seat occupancy (regression from v0.1.66; #582).
v0.1.71 — 2026-06-05
Dashboard redesign: replicas grouped by app.
Interface
- The monitoring dashboard now groups replicas into one expandable card per app instead of a flat table. Each card’s header shows the app, its replica count, the worst replica’s state, and total sessions; expand it to see the per-replica detail and the restart/stop/logs actions. The headline KPIs were restyled to match (#623).
v0.1.70 — 2026-06-05
First slices of the UX redesign.
Interface
- The public portal’s search and filters now stay pinned to the top while the catalog scrolls under them (#623).
- The monitoring dashboard’s headline numbers count up to their value on load (honoring reduced-motion) (#623).
- Groundwork for the redesign: shared shadow tokens and
perceived-performance primitives (top progress bar, shimmer skeletons,
content reveal), and the high-fidelity design handoff vendored under
docs/design-handoff/for reference (#623).
v0.1.69 — 2026-06-05
Lighter image and asset serving.
Performance
- Card images: a warm thumbnail hit and an ETag revalidation no longer re-read the full source blob from the database/disk — the content hash is remembered per file (#592).
- The bundled CSS/JS are brotli/gzip-compressed once at startup instead of being re-encoded on every request; clients get the precompressed variant they accept (#593).
v0.1.68 — 2026-06-05
Manage groups from the admin.
Admin
- The Groups page (
/admin/groups) is now editable: rename or delete a group (the change propagates across user memberships and app access-groups), add or remove members, and create a group by adding its first member (#540).
v0.1.67 — 2026-06-05
Close the seat over-admission race.
Fix (from the code audit)
- Completes #582: the proxy reserves a seat atomically when it picks
a replica, so two concurrent first-requests can’t both grab the last
free seat of a
seats-per-containerreplica. Combined with the scale-out in v0.1.66, a burst of new sessions now spawns up tomax-replicas(one per seat) instead of over-packing a single one.
v0.1.66 — 2026-06-04
Honor seats-per-container under load.
Fix (from the code audit)
- When every replica of a seat-based app is full, the proxy now spawns
another replica (up to
max-replicas) instead of oversubscribing a full one — soseats-per-containeris honored under concurrent load. Only at the replica cap does it fall back to overloading the least-loaded replica (#582, structural part).
v0.1.65 — 2026-06-04
Audit fixes, batch 6 (hot-path cache).
Performance (from the code audit)
- The proxy caches the resolved spec for each request for a short window (1s) instead of querying the database on every request — including every subresource of a page load (#587).
v0.1.64 — 2026-06-04
Audit fixes, batch 5 (Docker backend).
Fixes (from the code audit)
- The Docker backend distinguishes a real image-not-found (404) from a daemon error, rebuilds replica uptime from the container’s real creation time after a restart, and maps container state by matching the API enum directly (#586).
- The disk panel’s “unused images” detection cross-references the real running containers instead of an unreliable per-image count, so an image in use is no longer flagged as reclaimable (#585).
v0.1.63 — 2026-06-04
Audit fixes, batch 4.
Reliability (from the code audit)
- The auto-scaler now refuses to spawn past
max-replicas, re-checked under the spawn lock — a defensive cap against races and split-brain HA leaders (#581).
v0.1.62 — 2026-06-04
Audit fixes, batch 3.
Performance & docs (from the code audit)
- WebSocket binary/ping/pong frames forward zero-copy instead of copying each frame (#595).
- The HA deploy guide now documents that the scaler leader lock needs a direct (non-transaction-pooled) Postgres connection (#596).
v0.1.61 — 2026-06-04
Audit fixes, batch 2 (DB performance).
Performance (from the code audit)
- The Apps-list trend query is now index-backed instead of full-scanning
spec_accesson every render (#589). featuredis a real spec column, so the Apps list no longer deserializes every spec’sconfig_jsonjust to know which cards are featured (#588).
v0.1.60 — 2026-06-04
Audit fixes, batch 1.
Fixes (from the code audit)
- Config: a
${VAR}reference that appears only in a trailing inline comment (port: 3838 # uses ${VAR}) no longer hard-fails parsing (#584). - WebSocket: close frames now forward the real close code and reason to the peer instead of an empty close (#583).
v0.1.59 — 2026-06-04
FAQ cleanup + a Media spacing fix.
Docs & admin
- The FAQ questions were reworded to describe Ruscker directly rather than compare it to other systems (finishing the docs pass).
- The Media library’s search/filter toolbar no longer touches the drag-drop upload zone above it.
v0.1.58 — 2026-06-04
Docs: lead with Ruscker.
Documentation
- The introduction, README and the former “Ruscker vs. alternatives” page (now “Where Ruscker fits”) were rewritten to describe what Ruscker is and does rather than compare it against other systems — the feature-comparison table and competitor framing are gone, while the useful Ruscker-specific guidance (sub-path strip model, secrets via env-var) stays.
v0.1.57 — 2026-06-04
Consistent Media filter styling.
Admin
- The Media library’s search box and type filter now use the same look as the other admin controls (they were previously unstyled / a mismatched pill).
v0.1.56 — 2026-06-04
Filter the Media library by type.
Admin
- The Media gallery gains a type filter next to the filename search. Its options come from the formats actually present (typically SVG and WebP — raster uploads are re-encoded to WebP), and it combines with the text search.
v0.1.55 — 2026-06-04
Media filename management.
Admin
- Uploading an image whose name already exists no longer silently
overwrites it — the upload is kept under a free name
(
logo.webp→logo-2.webp) and the toast says it was renamed. - New rename action on each Media tile. The new name keeps the original extension, a taken name is refused, and every card logo/cover and landing logo that referenced the old name is rewritten to the new one — so nothing breaks.
v0.1.54 — 2026-06-03
Clearer user-account form.
Admin
- Creating a user no longer fails silently. The username and password
inputs now enforce their rules in the browser — at least 8
characters for the password (create and reset), and letters,
digits and
_ . @ -only for the username — instead of letting a bad value through to a vague “invalid input” message. The field hints and the error message spell out the rules.
v0.1.53 — 2026-06-03
Searchable, sortable admin tables.
Admin
- Every data table in the admin (Apps, Users, Credentials, both disk-panel tables) now has a search box that filters rows as you type and clickable column headers to sort ascending/descending (numeric-aware, so version/access/size sort as numbers). The Actions column stays inert.
- The spec form no longer shows the Advanced section for external Link/Package cards — there’s no image or container to tune, so only the external-link card remains for those kinds.
v0.1.52 — 2026-06-03
Friendlier YAML import.
Admin
- The import dialog now has a drag-and-drop zone in place of the bare
file input. Drop an
application.ymlonto it or click to browse; the prompt is localized (the old native “Choose file” button always showed in the browser’s language, never the panel’s). - After a selective import, the result message now reports how many credentials and images were pulled in alongside the apps — e.g. “… 3 credential(s) and 12 image(s) imported” — so the logo→Media and password→credential-store moves are visible, not silent.
v0.1.51 — 2026-06-03
Media import + safe deletion.
Admin
- Importing a ShinyProxy
application.ymlnow copies each selected app’s local logo into the Media library, so it shows up in/admin/mediaand not just on the card. Logos that are URLs, data URIs, empty or traversal-looking are skipped. - Inline Docker registry passwords in an imported config are moved into the named credentials store (encrypted at rest), de-duplicated, and the spec is rewired to reference the credential — the password never lands in the spec config.
- Deleting a Media image that is in use no longer breaks the card. The apps using it fall back to the default Ruscker logo (a cover image is cleared), and the confirm dialog spells this out before you delete.
v0.1.50 — 2026-06-03
Selective YAML import.
Admin
- Importing a ShinyProxy
application.ymlnow shows a preview list of the apps it contains — each marked New or Updates, with a checkbox — so you confirm which to import instead of taking the whole file. Only the checked apps are imported; the landing and settings are left untouched.
v0.1.49 — 2026-06-03
Access-counter follow-ups.
Admin
- The Accesses column now shows a small daily-usage sparkline (last 14 days) next to each total.
- API specs are counted too — one access per call (they aren’t session-based, so each request is the access).
v0.1.48 — 2026-06-03
A built-in access counter.
Admin
- An “Accesses” column in the Apps table shows how many times each
card/app has been used. App visits are counted once per session (not
per request), and external-link cards are counted too — clicks now
route through the portal so Ruscker can see them. Direct
/app/{id}URLs that skip the landing still count. No external analytics needed.
v0.1.47 — 2026-06-03
Better diagnostics when an app won’t start.
Proxy / Docker
- When a spawned container crashes on startup (e.g. an app that halts
because it can’t reach its database), Ruscker now fails fast —
reporting
exited (code N) during startupinstead of waiting out the full 60s readiness timeout — and attaches the tail of the container’s logs to the failure. The real cause (a DB connection error, a missing env var, a crash) is visible in the warn log and the admin Logs tab without re-running the container by hand.
v0.1.46 — 2026-06-02
Admin catalog on by default, plus a session-revocation fix.
Packaging
- The Debian/systemd unit now runs with
--dbenabled by default, so a fresh.debinstall has the admin panel live out of the box and seeds the showcase apps on first boot — no YAML editing. The Docker backend stays opt-in (sudo ruscker-enable-docker).
Security
- Changing a user’s role, deleting them, or resetting their password now revokes their live admin sessions immediately, instead of leaving the old (possibly elevated) role valid until the session expired.
Docs
- Configuration is reframed around two layers — portal content
(managed in the admin panel) vs deployment settings (CLI flags / env),
with the YAML schema as the migration reference — and the quickstart
now leads with the
--dbshowcase seed. Screenshots throughout the site and the README.
Landing
- The Featured carousel is now centered on the page, with the prev/next chevrons in the side gutters outside the cards (rather than overlaid on them), vertically centered.
v0.1.44 — 2026-06-02
A refreshed Featured carousel.
Landing
- New carousel controls. The prev/next chevrons are now circular buttons overlaid on the card row, vertically centered on the left and right edges (Material-Tailwind style), instead of a pair of buttons in the section header. They stay pinned to the visible cards whether 1, 2 or 3 fit, and disappear when everything fits on one page.
- Fixed a hover clip. A featured card’s dark top border no longer gets shaved off when you hover it inside the carousel.
v0.1.43 — 2026-06-02
Another featured-star placement fix.
Admin
- The featured star now lives inside the Actions column, alongside edit and duplicate, instead of in a separate column of its own.
v0.1.42 — 2026-06-02
A follow-up fix for the featured star.
Admin
- The featured star now fills in when toggled on. The served icon font ships only the outline star, so the “featured” state rendered an empty glyph — the star appeared to vanish on click. It’s now drawn as an inline SVG that toggles solid ↔ outline, so featuring an app shows a solid amber star as intended.
v0.1.41 — 2026-06-02
Bug fixes for the admin Apps table plus Homebrew automation.
Admin
- Featured star now works in the Apps table. The list page never loaded Alpine, so the inline star rendered empty and didn’t toggle; it’s loaded now. The star also moved next to the Actions column, where featuring reads as a row action.
Packaging
- Dropped the obsolete
welcomestarter spec from the default/etc/ruscker/application.yml. It predated the first-run showcase seed, so on a fresh install it only duplicated a card and showed up as a stray read-only CONFIG row in the admin. Fresh installs are now clean (the showcase seed fills the landing).
CI
- The release workflow now auto-publishes the Homebrew formula to the
tap on every release, so
brew install strategicprojects/tap/rusckertracks the latest version instead of drifting. (Requires aHOMEBREW_TAP_TOKENsecret; no-ops with a warning if absent.)
v0.1.40 — 2026-06-02
Two admin UX touches for managing apps.
Admin
- Inline featured star in the Apps table: toggle an app’s Featured flag straight from the list with a single click — solid star when on, outline when off — without opening each app’s editor.
- App form reorganized into three bands so the layout maps to
intent: Identity (the essentials), a visible Metadata & visibility
band (Featured, access groups/users, updated date), and the
Advanced collapse for runtime knobs. Per-session tuning
(
seats-per-container,max-lifetime) moved into Advanced; the Container card is now just “which image to run”.
v0.1.39 — 2026-06-02
Polish for the Featured carousel and subject pills.
Landing
- The Featured carousel is now paged: at most 3 cards with prev/next chevrons (shown only when there are more than three), and no horizontal scrollbar.
- The subject pill on a card fits its full text and uses a lighter, theme-matching style.
v0.1.38 — 2026-06-02
A round of admin & landing UX polish.
Landing
- A “Featured” carousel of highlighted apps above the filters. Mark
an app
featuredand toggle “Show Featured carousel” in the Portal editor; it only appears when both are set, and is a horizontal rail (1–3 cards per viewport, the rest scroll). - Each card now shows its subject as a pill next to the type badge.
Admin
- The Add/Edit App form is reorganised into labelled section cards with a sticky Save bar, matching the Portal editor.
- The registry credential field is a real selector now, with a clear “no saved credentials” hint when the store is empty.
- A read-only Groups page derives each group’s member users and the
apps it gates (from
access-groups), so you can spot typos and see who can use what.
v0.1.37 — 2026-06-01
Catch a bad image in the editor, not at the first failed launch.
Admin
- The spec editor’s container image field gains a Check button: it
asks the backend whether the image is already on the server and shows
✓ on the server / ⬇ will be pulled on first launch (or flags a
${VAR}that resolves at pull time / Docker not connected). - When the image is absent, a Pull button fetches it right away and streams the daemon’s progress live; on completion the indicator settles on present (success) or absent with the error line (failure). Private images use the spec’s selected registry credential.
v0.1.36 — 2026-06-01
A favicon fix for Safari and a cleaner uninstall.
Admin
- Every page now ships the same favicon set — the standalone login
and setup screens previously linked only the SVG icon, which Safari can
ignore (leaving a dark placeholder when moving between admin and the
landing). The icon links live in one shared partial, and a dedicated
monochrome
safari-pinned-tab.svgbacks the Safari pinned-tab icon.
Packaging
apt purge rusckernow removes/etc/rusckertoo (config + the admin token / keys inruscker.env), so a purge leaves no trace. The installation chapter documents the full uninstall & reset matrix (remove vs purge vs purge+install vs a data-only DB wipe).
v0.1.35 — 2026-06-01
Live dashboard fixes behind a reverse proxy, plus smarter share images.
Admin
- The live dashboard now streams through reverse proxies: SSE
responses send
X-Accel-Buffering: no, so new containers show up in real time even behind nginx on a subpath mount (no nginx change needed). Previously the table could appear frozen until a reload. - Social share image (
og:image) auto-defaults: when left blank it reuses the header (left) logo, else the Ruscker mark — so a shared link carries the portal’s identity without setting it twice. The editor field also gets the gallery picker. - The Safari pinned-tab
mask-iconpoints at the monochrome mark (correct for a recoloured silhouette).
v0.1.34 — 2026-06-01
Docker connects out of the box, per-theme colours, and a modernised Portal editor.
Runtime
- Ruscker now auto-connects to Docker when the daemon socket is
reachable —
servespawns app containers with no--dockerflag. Pass--no-dockerto run landing-only, or keep--dockerto make a failed connect fatal (useful for a remote daemon). - Showcase demos seed with
min-replicas: 0, so a fresh install no longer pre-spawns every demo container at boot — they cold-start on first click.
Portal
- Per-theme colours: set the background, text and accent for the light and dark themes independently in the landing editor. Blank keeps the built-in default.
- Logos integrate into the chrome: a header-left logo replaces the Ruscker mark, header-right sits after the buttons, footer-right trails the version, and a center logo is centred within the header/footer bar itself. Each logo also takes an optional margin.
Admin
- The landing editor is reorganised into labelled section cards with a sticky Save bar; logos are edited as cards with segmented position/alignment pickers; the live preview now mirrors the real portal chrome (logos + footer). Theme colour swatches show the theme default instead of black when unset.
v0.1.33 — 2026-06-01
A bulk image cleanup on the disk panel, plus a documentation fix.
Admin
- The Disk panel gains a “Remove unused” images button: reclaim
every image no container uses and no spec references, in one click.
It complements the existing one-click “remove stopped containers” —
and, like everything on the panel, it only touches that exact unused
subset (never a host-wide
docker image prune, never--force).
Docs
- The documented idle footprint is now ~14 MB (the measured value), down from the rounded ~16 MB.
v0.1.32 — 2026-06-01
Admin disk management, a forced first-login password change, and a more visible process log.
Admin
- New Disk panel (
/admin/disk, Admin-only): list and remove Ruscker-managed containers, prune every stopped one in a click (label-scoped — it never touches a non-Ruscker container on the host), and remove images no container or spec uses. Reclaims the space left behind by scaled-down or crashed replicas and by apps you’ve deleted. - Deleting an app now reaps its containers instead of leaving them running or stopped as orphans.
- New accounts must change their password on first login — the prompt can no longer be skipped, and a guard re-routes to it on every admin page until the change is done. The user-admin password fields are masked, with a reveal toggle.
- The Portal logos editor uses the same image gallery picker as the spec form — search, thumbnails, and inline upload, instead of a bare path field.
Operations
- A one-line startup banner (version, bind address, base path, Docker
on/off, database, spec count) now appears in the admin Logs tab even at
the default log level — so a fresh boot is visible without
-v. The Logs tab also distinguishes “nothing logged yet” from “no log buffer”.
Docs
- The handbook was refreshed to match the current release.
v0.1.18–v0.1.31 — 2026-05-31
Demo images, credential unification, a redesigned media library, and portal logo support.
Demo app images
- Dash, FastAPI, and Quarto showcase cards now use dedicated
fork images on Docker Hub (
milkway/ruscker-dash-demo,milkway/ruscker-fastapi-demo,milkway/ruscker-quarto-demo). Dash and FastAPI serve at the container root (noSHINYPROXY_PUBLIC_PATHconfiguration needed). The Quarto demo is a static nginx image (~67 MB).
Credentials store
- The named-credential store now accepts a pure
${VAR}env-ref as a password (stored verbatim, resolved only at pull time), in addition to the existing AES-encrypted literal. “Pure” means a whole-token${VAR}— a value with a literal prefix likeprefix${VAR}is not stored verbatim; it is treated as a literal and AES-encrypted (security fix). - The spec-form Registry section is now a credential picker; the inline domain/user/password fields are hidden back-compat fallbacks.
Media library
- Built-in logos are seeded into the Media library on first start (idempotent) — one unified gallery, no separate “Built-in logos” group. Each logo is deletable and shows an “in use” badge (cross-references spec logo/cover and landing logos).
- A modal picker in the spec form provides search, uploads, and inline upload without leaving the form; drag-and-drop is supported on both the modal and the media page.
Portal header/footer logos
- The landing editor supports logos in the header and footer slots, each with alignment (left/center/right), an optional click-through link, and a per-logo height.
Security fixes
- Username charset and credential-name charset are now validated, making credentials safely deletable.
- Admin password fields in the spec and user forms are now masked / write-only.
Proxy
- API requests (kind
Api) are now routed by in-flight request count instead of seat count, and the in-flight guard spans the full streaming response body.
v0.1.4–v0.1.17 — 2026-05-29
Live UX fixes, security hardening, and a performance pass.
Live UX fixes
- Cold-start splash: a loading screen appears on first navigation to an app while its container is starting.
- RStudio Server: the proxy injects the
X-RStudio-Root-Pathheader so RStudio rewrites its own internal links correctly behind the mount. Appkind added for notebook-style apps (Jupyter, RStudio) that don’t fit the Shiny or plain API model.- Relative font URLs fixed so icons resolve correctly when served under a sub-path.
- Alpine.js CSP flag corrected (
'unsafe-eval') so popovers and dynamic filters work. - Version number shown in the admin footer.
- The admin Blocks editor is folded into the Portal settings page.
Security hardening
- Ruscker’s own session cookies are stripped before forwarding requests to app containers — the admin session no longer leaks upstream.
- CSRF guard (Fetch-Metadata / Origin check) on all chrome-mutating actions.
${VAR}secrets incontainer-envand registry passwords are preserved verbatim through import/export and resolved only at spawn or pull time — they never appear in cleartext in the database.- Container log access is gated to Editor-and-above accounts.
Performance
- gzip / Brotli compression on all HTML, CSS, and JS chrome responses.
?v={version}appended to bundled CSS/JS URLs for cache-busting on upgrade.- ETag validation on
/assets/imgimage responses — revalidation returns a cheap 304. - WebP thumbnails in media galleries.
- Configurable
proxy.metrics-interval(seconds) for dashboard stats polling; Docker stats fan-out is now bounded. - Dashboard snapshot is memoized per locale across SSE tabs; the SSE patcher updates individual cells instead of replacing whole rows.
Admin
- Spec editing is now fully gated: specs that exist only in YAML (not
the database) are shown read-only in
/admin/specs. - The media gallery at
/admin/mediais a client-side Alpine page with filename search and paginated “show more” (24 per page).
v0.1.3 — 2026-05-29
Admin & UX polish, plus proxy fixes that unlock notebook-style apps.
Admin
- The spec form now edits every container option. The Advanced
section gained inner port, platform, environment variables and command
override, registry credentials, per-app access groups/users, CPU/memory
requests, max body size, scaling thresholds, routing strategy,
placement, and anti-affinity — each with a
?help bubble that states the default a blank field inherits. - Self-service card images. Pick a logo (or cover) from the media
library or upload one inline, right in the spec form — no need to
leave for the media page or type an
/assets/img/...path. Pasting a custom path or external URL still works.
Apps & proxy
- Per-app environment and command.
container-env(aNAME: valuemap) andcontainer-cmd(an argument list) are honored, ShinyProxy- compatible. Values flow through${VAR}interpolation, so secrets stay in the environment. This is what lets you configure notebook servers. - Jupyter (and similar) now work behind the proxy. The
/app/{id}URL rewriter handles apps that own the/api/namespace — Jupyter’s REST API and kernel WebSocket — and rewrites redirectLocationheaders, so a notebook loads and connects end-to-end under the mount. - New RStudio Server showcase card; R Markdown is now a documentation link with a corrected logo.
Fixes
ruscker importno longer deletes custom landing blocks.- The admin Logs page renders only the most recent lines (fast on a long-lived server) with a download-full-log link; live follow is unchanged.
- A spec that keeps failing to start (typo’d image, registry down) is now logged once, then quieted, then re-surfaced if it persists — instead of one warning on every scaler tick.
v0.1.2 — 2026-05-27
High-availability / multi-host hardening and sub-path mounting.
- Mount under a sub-path.
server.context-path(ShinyProxy- compatible) or the--base-path /portalflag serves the whole portal under a prefix, for reverse proxies that can’t give Ruscker its own subdomain. Health probes stay at the root for load balancers. - Fully-public portals can hide the sign-in entrance with
landing-customization.show-admin-link: false. - Multi-host robustness: authoritative placement pruning, idempotent stop, bracketed IPv6 host literals, and a degraded start when a Docker host is unreachable (fails only if none connect).
- HA leader hardening: timeouts on every step of the Postgres advisory-lock leader path so a degraded database can’t freeze a scaler tick; idle-session eviction is leader-gated.
- HA sign-in: the deploy guide prescribes a sticky upstream for the session-bearing paths.
v0.1.1 — 2026-05-26
Per-user visibility and HA session accounting.
- Per-group / per-user app visibility.
access-groupsandaccess-users(ShinyProxy-compatible) scope who can see and reach an app. The landing shows each viewer only the apps they may use, and/app+/apienforce it (an anonymous visitor is redirected to sign in; a restricted API returns 403) — not just hide the card. Users and their group memberships are managed in the admin panel. - HA Postgres session accounting fixes so a load-balancer failover counts active sessions correctly and a graceful drain can complete.
v0.1.0 — 2026-05-26
First stable release.
- Public landing page rendered from your config — cards with filters, search, theming, custom branding/SEO/analytics, custom HTML blocks, and full i18n (pt-BR / en-US / es-ES / fr-FR).
- Admin panel: spec CRUD with a live card preview, an image library (upload → WebP), an encrypted credentials store, a landing editor, and an audit log.
- Reverse proxy + Docker backend: on-demand container spawn, sticky sessions, WebSocket proxying, per-spec CPU/memory limits, auto-scaling with two-sided hysteresis, and session-heartbeat reaping.
- Monitoring dashboard with live (SSE) per-replica CPU/memory and sparklines, a logs viewer, and per-replica stop/restart.
- Accounts & security: user accounts with roles (Viewer / Editor /
Admin), login rate-limiting, security headers,
/healthz+/readyzprobes, and graceful shutdown. - Migration-friendly: ShinyProxy-compatible YAML with a
validate --strict-compatpre-flight, andimport/exportthat round-trip YAML ↔ the database. - Distribution: multi-arch container image,
.debpackages, and static musl tarballs — all cosign-signed.
Security
The threat model and hardening notes below are the same document
maintained in the repository (docs/SECURITY.md).
Ruscker — Security & Threat Model
Status: living document. Tracks the Phase 5 security audit
(issue #14). Each control is marked [implemented],
[accepted limitation], or [deferred]. File references use
crate/path:symbol so they survive line-number drift.
Scope. This covers v0.1.80: single-operator install, Ruscker behind a TLS-terminating reverse proxy, Docker on the same host. Multi-tenant / shared-team auth (OIDC, RBAC) is out of scope until Phase 8.
1. Threat model
Assets
| Asset | Why it matters |
|---|---|
RUSCKER_ADMIN_TOKEN | break-glass Admin login + first-account bootstrap — full access |
| User passwords (DB) | per-user login; stored as argon2id hashes |
RUSCKER_MASTER_KEY | decrypts the registry-credential store |
RUSCKER_COOKIE_KEY | forges sticky-session cookies |
| Registry credentials (DB) | pull access to private images |
| Running app sessions | per-visitor app state inside containers |
| The Docker daemon | full host compromise if reachable |
Attackers
- Network attacker — can reach the bound port. Mitigated by binding to localhost / private network + reverse proxy.
- Malicious visitor — hits
/app/*//api/*without admin rights. Should never reach admin surfaces or other visitors’ sessions. - Curious operator-adjacent user — has some network access, tries to brute-force the admin token or forge cookies.
- Compromised app image — a container Ruscker spawned that tries to escape its limits or reach the host/other containers.
Non-goals (explicitly out of scope for MVP)
- Defending against a hostile operator (they own the host + Docker daemon + all keys).
- Per-app ACLs and external identity providers (OIDC/SAML/LDAP). Coarse RBAC (Viewer/Editor/Admin) exists (§2); fine-grained, per-spec authorization is Phase 8.
- TLS termination (delegated to the reverse proxy).
2. Authentication & authorization
- [implemented] User accounts (#107) — per-user login
(
username+password) backed by theuserstable; passwords stored only as argon2id PHC hashes (db::users, never plaintext).verify_loginruns a decoy hash on unknown usernames so timing doesn’t reveal whether an account exists. Roles (viewer/editor/admin) are per-user. - [implemented] Break-glass admin token —
auth::AdminAuth::matches_token→ct_eq(XOR-fold, length- checked; time depends only on the public length).RUSCKER_ADMIN_TOKENalways grants an Admin session and bootstraps the first account (token login on a fresh install → forced setup). It’s the recovery path so an operator can never be locked out; treat it as a break-glass secret. The oldRUSCKER_EDITOR_TOKEN/RUSCKER_VIEWER_TOKEN(the #101 MVP) are removed — Editor/Viewer are DB accounts now. - [implemented] Login rate limiting —
auth::LoginRateLimiter(global sliding window, default 10 failures / 60 s). Saturated →429+Retry-After. Wired inroutes::admin::login_submit. Global, not per-IP: behind a reverse proxy the peer IP is the proxy, and a per-IP key would trust a spoofableX-Forwarded-For. A global cap can’t be evaded by rotating source addresses. - [implemented] Admin cookie is
HttpOnly+SameSite=StrictSecure(under TLS, see §7) —routes::admin::login_submit.
- [implemented] Opaque server-side sessions (#77) — the cookie
carries a random 244-bit session id (
auth::AdminSessionStore), never the token. Logout and server restart revoke it; a stolen cookie never exposes the token. The store is in-memory by default (InMemoryAdminSessionStore); for HA, point Ruscker at a shared Postgres via--admin-session-store-url(#185) so sessions survive a load-balancer hop. - [implemented] Role-based access control (#101/#107) — three
roles (Viewer = dashboard read-only; Editor = apps + media +
dashboard incl. stop/restart; Admin = everything, incl. user
management). Enforcement is server-side via the
AdminSession/RequireEditor/RequireAdminextractors on each route group — the permission matrix lives inRole::can_access_section/can_manage, and the nav only hides links it can’t reach (UX, not the boundary). Denied →403. Admins manage accounts at/admin/users(create/role/reset-password/delete) with a last-admin guard that refuses to delete or demote the only remaining admin. Audit entries record the acting username (ortokenfor a break-glass session). Per-app ACLs and external IdPs (OIDC/SAML/LDAP) remain Phase 8. - [implemented] Identifier charset validation (#429 / #423) — a
username (
db::users::is_valid_username) and a stored-credential name (routes::admin::credentials::is_valid_credential_name) must be non-empty and made only of identifier-ish chars (letters, digits,_ . -, plus@for e-mail logins). Both land un-encoded in a per-resource admin action URL path segment (/admin/users/{username}/...,/admin/credentials/{name}/delete), so a/,?,#, or space would make the account/credential impossible to edit or delete from the UI — the validation keeps every row manageable (and therefore deletable). Rejected → the form re-renders with an error, no row written. - [implemented] Password fields are write-only in the admin forms (#430) — the user form and the spec-form Registry section never pre-fill or render a stored password; a blank field keeps the existing value, and the input is masked so a shoulder-surfer can’t read a freshly-typed secret. Server-side, a blank password on edit is a no-op, not a wipe.
- [implemented] Bind-mount
volumesare Admin-only (#302). A spec’svolumesmap to DockerHostConfig.binds— i.e. host filesystem /docker.sockaccess — so an Editor (who can otherwise create/edit apps) cannot set or change them: the spec-form field is hidden for non-Admins and, server-side,into_speckeeps the base spec’s volumes when the actor isn’t Admin. Treat Admin as host-trusted and Editor as app-config-trusted. - [accepted limitation] Login lockout can be triggered by a flood of bad attempts (the global limiter’s trade-off). Self- heals within the 60 s window.
3. Credentials & secrets
- [implemented] Registry passwords encrypted at rest with
AES-256-GCM —
crypto::MasterKey::{encrypt,decrypt}. A fresh random nonce per encryption, stored alongside the ciphertext; never reused (new nonce on everyupsert). - [implemented] Unified credential store, two storage modes (#351) —
db::credentials::upsertaccepts either a literal password (AES-256-GCM at rest, as above) or a pure${VAR}env-ref (stored verbatim, never encrypted, flagged by an empty nonce — a real GCM nonce is 12 bytes, never empty — and resolved from the environment only at pull time). The env-ref branch is gated onruscker_config::env::is_pure_env_ref: the value must consist entirely of valid${VAR}/${VAR:-default}tokens (whole-token only). A value with any literal text — e.g.prefix${VAR}or a malformedabc${def— is not kept verbatim; it’s treated as a literal secret and AES-encrypted. This is the #422 fix: a loosecontains("${")test would have stored such a literal in cleartext at rest. Either way the DB never holds resolved cleartext. - [implemented] Master key held in
Zeroizing<[u8; 32]>inside anArc— wiped on last drop. Cookie key likewise (ruscker_proxy::sticky::CookieKey). - [implemented] DB credential store wired to image pulls —
db::credentials::resolvedecrypts only at pull time, in the spawn path, never echoed to the UI. - [implemented]
${VAR}secrets stay literal end-to-end (#260) —docker-registry-password(and any [env::SECRET_KEYS] key) is not interpolated at parse: the${VAR}placeholder is preserved throughimportinto the DB and throughexportoutput, and resolved only at the point of use (creds_from_spec, right before a pull). So the resolved secret never lands in the config DB or an export. The admin spec form treats the password as write-only — never pre-filled or rendered; a blank field keeps the stored value.docker-registry-credential(the named store — AES literal or a verbatim${VAR}env-ref, see above) is preferred for new flows; the spec-form Registry section is now just the picker for a stored credential.container-envvalues get the same treatment (#272): a${VAR}in acontainer-envvalue is preserved literal at parse and resolved only at spawn (Spec::resolved_env_pairs), so an app secret passed via${VAR}never lands in the DB either. A missing env var fails the pull/spawn with a clear message rather than passing a literal${VAR}— both for the registry password (#273) and forcontainer-envvalues (#300). - [legacy] A spec imported by an older build may hold a resolved
password in its
config_json. Re-import the YAML (which now preserves the literal) or rotate the secret to the credentials store; thescan_raw_textvalidator still flags inline cleartext in YAML. - [implemented] Plaintext secrets never logged: pull path
logs
with_creds=<bool>+ registry host, not the password; audit-log inserts carry action/target, not secret values. - [accepted limitation] Cookie key and master key are
separate, undrived keys. Deriving both from a single
RUSCKER_ROOT_KEYvia HKDF is a possible ergonomic improvement, not a security need. - [deferred] Confirm bollard never logs the auth header on
pull at its own
debuglevel (we run it atinfo+ in prod).
4. Image uploads
- [implemented] 10 MB pre-decode cap —
images::MAX_UPLOAD_BYTES, checked before any decode (defends against decompression-bomb-style payloads). - [implemented] MIME sniffing via
infer::get— PNG/JPEG/ WebP recognized by magic bytes, not the client-supplied filename/Content-Type. - [implemented]
X-Content-Type-Options: nosniffon served responses (§7) so a polyglot upload can’t be reinterpreted as active content. - [implemented] SVG script neutralization at serve time.
Uploaded SVGs are still stored as-is, but
/assets/img/*responses (routes::assets::serve_dynamic) carryContent-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox+X-Content-Type-Options: nosniff. Even if a malicious SVG is opened directly or embedded via<object>/<iframe>, its<script>/<foreignObject>can’t execute. The common<img src=…>use is unaffected (scripts never run in<img>context). The global page-header middleware usesentry().or_insertso it does NOT clobber this stricter per-asset policy. [deferred] content-level sanitization (usvg) if we ever need SVG in an active context. - [implemented] Path traversal guard on
/assets/img/{file}rejects/and.., with tests for encoded variants (%2F,%2e%2e, backslash) — all 400 / 404, never a file read.
5. SQL & database
- [implemented] All queries parameterized — no string
interpolation into SQL (
grep format!.*SELECTacrossdb/is empty). Dynamic filters indb::audit::listuseQueryBuilder::push_bind, not concatenation. - [implemented]
journal_mode = WAL+foreign_keys = ON—db::open/db::open_memory(db.rs). - [accepted limitation] No automated backups — the operator owns the SQLite file’s backup schedule. Documented in §8.
6. Proxy
- [implemented] Hop-by-hop header strip —
routes::proxyHOP_BY_HOPcovers RFC 7230 §6.1 tokens + the dynamicConnection:token list.X-Forwarded-Proto/-Portare stripped before forwarding upstream. - [implemented] Open-redirect closed —
routes::same_origin_pathreduces aRefererto a same-origin path; used by/__set/*and the login redirect. - [implemented] CSRF defense — admin cookie is
SameSite=Strict, so a cross-site POST can’t carry it, and a server-side guard (csrf_guard, #259) rejects state-changing chrome requests that aren’t same-origin: it trustsSec-Fetch-Site(same-origin/noneonly) when present, else falls back to anOriginvsHostcheck. Requests with neither header (curl, the break-glass token POST) pass — they aren’t browser CSRF. - [implemented] Ruscker cookies are stripped before forwarding
upstream (#258) —
strip_ruscker_cookiesremoves the admin session, the sticky cookie, and the theme/locale prefs from the upstream-boundCookieheader so an app container never sees them (the admin session id is a bearer). - [accepted limitation — needs origin separation] Admin and apps
share one origin by default. A script inside an untrusted app
served at
/app/{spec}is genuinely same-origin with/admin, so neitherSameSite=Strictnor the same-origin CSRF guard can stop it from issuing credentialedfetch('/admin/...')calls. The cookie strip (#258) stops the app from reading the session, but a same-origin request from the browser still carries it. If you host third-party / untrusted apps, serve the admin on a separate hostname/origin (e.g.admin.example.orgvsapps.example.org) so the browser’s same-origin policy isolates them. Trusted, first-party apps on one origin are fine. - [implemented] Sticky-cookie cross-app defense — the handler
checks
session.spec_id == spec.idbefore honoring a sticky cookie, even though itsPath=/spans apps. (routes::proxy::resolve_replica.) - [implemented] Sticky cookie integrity — HMAC-SHA256
truncated to 16 bytes (128-bit forgery resistance) over the
signed payload (
ruscker_proxy::sticky). 128 bits is far past brute-forceable within a session window. - [accepted limitation] Upstream is always
127.0.0.1:<port>— not an SSRF vector while the operator can’t point a spec at an external host. - [accepted limitation] Container labels (
ruscker.spec_id, …) are trusted bylist(). A manually-created container could forge them; acceptable because the operator owns the host. - [deferred] Add
proxy-connection(legacy HTTP/1.0) to the hop-by-hop strip list. - [deferred] WS pump backpressure — a slow client can accumulate frames. Bound the channel with a drop policy.
- [deferred] Whitelist (rather than only strip a couple of)
client-supplied
X-Forwarded-*headers before forwarding upstream.
7. TLS, headers & network
- [implemented] Security response headers on Ruscker’s own
surfaces (landing/admin/prefs/assets), NOT on proxied
/app/*,/api/*—lib::security_headers:X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: same-origin, and aContent-Security-Policy(default-src 'self'; … frame-ancestors 'none'; base-uri 'self'; form-action 'self'). - [implemented]
Securecookie flag under TLS — admin + sticky cookies setSecurewhenauth::request_is_https(readsX-Forwarded-Proto) is true. Off on plain-HTTP dev so the browser doesn’t drop the cookie. - [accepted limitation] Ruscker does NOT terminate TLS — expects a reverse proxy (see §9).
- [deferred] CSP currently allows
'unsafe-inline'for script/style because the landing + dashboard use inline<script>/<style>. A nonce-based CSP that dropsunsafe-inlineis the hardening follow-up.
8. Logging & observability
- [implemented] Default
tracinglevel (info) logs paths, spec ids, replica ids — operational metadata, no secrets, no PII. - [accepted limitation] No PII is collected today; revisit if auth/user features land.
- [opt-in] Prometheus
/metrics(proxy.metrics-enabled, off by default). When enabled it’s served unauthenticated — it exposes operational gauges (replica counts/states, per-spec sessions, per-replica CPU/memory), no secrets. Only enable it where the endpoint is reachable solely by your scraper (private network / firewall / bound behind the reverse proxy); don’t expose it to the public alongside the landing page.
9. Recommended production configuration
Reverse proxy (terminates TLS, forwards scheme)
Minimal Caddy:
portal.example.org {
reverse_proxy 127.0.0.1:8080 {
header_up X-Forwarded-Proto {scheme}
}
}
Minimal nginx:
server {
listen 443 ssl;
server_name portal.example.org;
# ssl_certificate ... ssl_certificate_key ...;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_http_version 1.1; # WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
X-Forwarded-Proto: https is what flips the Secure cookie flag
on (§7) — without it Ruscker assumes plain HTTP and omits Secure.
Binding
Bind Ruscker to localhost so only the reverse proxy reaches it:
ruscker serve --bind 127.0.0.1:8080 ...
Secrets (env vars — never in YAML)
export RUSCKER_ADMIN_TOKEN=$(openssl rand -hex 32) # 256-bit — break-glass admin
export RUSCKER_MASTER_KEY=$(openssl rand -hex 32) # AES-256 key
export RUSCKER_COOKIE_KEY=$(openssl rand -hex 32) # sticky HMAC key
On first run, log in with RUSCKER_ADMIN_TOKEN and you’ll be prompted
to create the first admin account (username + password). After
that, everyone signs in with their account; the token stays as a
break-glass / recovery path. Manage further accounts (Viewer / Editor /
Admin) at /admin/users.
- Set
RUSCKER_COOKIE_KEYexplicitly in prod — without it the sticky key is randomized per process, invalidating all sessions on restart. - Rotate
RUSCKER_ADMIN_TOKENif you suspect cookie exfiltration (the cookie holds the token literally — §2).
Backups
Snapshot the SQLite DB (the --db file) on your own schedule;
Ruscker does not back it up. With WAL, copy *.db, *.db-wal,
*.db-shm together, or use sqlite3 .backup.
10. Audit checklist status (issue #14)
Blocking-for-prod (all done):
- CSP + security headers on admin (§7)
-
Securecookie flag under TLS (§7) - Login rate limiting (§2)
Non-blocking follow-ups:
- SVG script neutralization (CSP+sandbox at serve time) (§4)
- Encoded path-traversal tests for
/assets/img(§4) - Opaque server-side admin sessions — cookie no longer holds the token; logout revokes server-side (#77)
- Operator CSP origins (blocks/analytics) sanitized before use (#82)
-
proxy-connectionin hop-by-hop strip (§6, #84) - WS pump backpressure: independent tasks + idle watchdog (§6, #81)
-
audit_log.diff_jsonverified to record metadata only — never a password/token/cookie (regression test indb::credentials) - Automated
cargo auditin CI (.github/workflows/security.yml, weekly + on dependency changes) - Nonce-based CSP, drop
unsafe-inline(§7) -
semgrepin CI (cargo-audit is wired; semgrep deferred)