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>-1_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. /etc/ruscker/ruscker.yml only carries service settings (every
option is documented inline in the shipped file); put your secrets in
/etc/ruscker/ruscker.env (read by the unit):
RUSCKER_ADMIN_TOKEN=... # openssl rand -hex 32
RUSCKER_MASTER_KEY=... # encrypted credentials and MFA secrets
RUSCKER_COOKIE_KEY=... # keep sticky sessions stable across restarts
DOCKER_REGISTRY_PASSWORD=... # referenced as ${DOCKER_REGISTRY_PASSWORD} in the YAML
The package generates stable master and cookie keys when they are missing.
Keep RUSCKER_MASTER_KEY backed up: 2FA enrolment returns 503 without it,
and changing it makes existing encrypted credentials and TOTP factors
undecryptable.
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/ruscker.yml \
--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
By default an app cold-starts: min-replicas defaults to 0, so
no container runs until the first visitor arrives (they see a brief
splash while it spawns), and the auto-scaler reaps it once idle — the
same on-demand behaviour as ShinyProxy and Shiny Server Free, and what
keeps a many-app portal light. To keep an app warm (no cold-start
latency on the first hit), set min-replicas: 1 (or more) on that spec;
the scaler then keeps that many replicas running at all times.
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.) This inter-host traffic is plain HTTP by design — if the
link between the machines isn’t trusted, encrypt it at the network
layer (WireGuard or an encrypted overlay), which covers every
published port at once. On a single host none of this applies:
containers publish on 127.0.0.1 and the proxy connects over
loopback, so the traffic never leaves the machine — see the Data
plane section of Security.
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 shows the host inline next to each
replica’s container id, so you can see where each
replica landed.
Validating it. The default crate tests cover weighted spread, bin-pack, capacity caps, anti-affinity fallback, placement-cache accounting and degraded-host reconciliation without a Docker daemon:
cargo test -p ruscker-docker
An additional 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 accepts images up to
# 10 MB (12 MB request limit, allowing multipart overhead).
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-*. This is required for cookies to carry the Secure flag
behind a TLS-terminating proxy — without the flag,
forwarded headers from any client would be spoofable, so Ruscker
ignores them entirely (cookies are then minted as if on plain HTTP, and
per-client API rate limiting keys on the TCP peer, i.e. your proxy).
Both halves are needed: the proxy_set_header X-Forwarded-Proto
above and the YAML flag. ShinyProxy-migrated configs usually carry
the flag already.
The Containers dashboard short-polls
GET /admin/dashboard/snapshot, and the process Logs page short-pollsGET /admin/logs/poll?cursor=…. Both responses are finite and need no buffering exception. Only the per-replica log viewer opens anEventSource, and only after the operator clicks Live. Disable nginx buffering for that explicit follow path:location ^~ /admin/dashboard/logs/ { proxy_pass http://127.0.0.1:8090; proxy_buffering off; proxy_read_timeout 1h; # + the same proxy_set_header lines as above }Since v0.2.49,
/admin/logs/streamis a retired compatibility endpoint that returns204 No Content, which tells pages loaded before a rolling upgrade to stop reconnecting. Do not proxy it as a long-lived stream.
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 poller, fetch, WebSockets, 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. These things must be shared by every instance:
--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. - the same
RUSCKER_MASTER_KEYon every instance — all nodes must be able to decrypt stored registry credentials and user TOTP factors. A missing key makes MFA enrolment fail closed.
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.
With
--config-db-url, the proxy, landing and scaler all resolve specs from the shared Postgres catalog first (YAML is the union fallback for ids not in the DB) — an admin edit on one instance is served live by every instance. 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/ruscker.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:
- ./ruscker.yml:/etc/ruscker/ruscker.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/—ruscker.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 and MFA factors are unusable without
RUSCKER_MASTER_KEY, so back up the key too — separately.ruscker export --db <file>also writes a YAML snapshot; it does not export credential or MFA secrets.
Upgrading
Build the new .deb, copy it over, and reinstall keeping your config:
sudo dpkg -i --force-confold ruscker_<version>-1_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.