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.