Docker
The fastest way to run your own ShellWatch is the published container image. It bundles the server, the SvelteKit frontend, all native addons (better-sqlite3, ssh2, cbor-extract, cpu-features), and runs as a non-root user out of the box.
Image: ghcr.io/rado0x54/shellwatch.
A full deployment is two services: ShellWatch itself and Ory Hydra, the OAuth2/OIDC authority (a hard runtime dependency). There is no separate database server — both store their data as file SQLite under ./data (ShellWatch in shellwatch.db, Hydra in hydra.sqlite).
The repo ships a single docker-compose.yml with both services. The hydra service sits behind a compose profile so you can choose your topology:
| Command | What starts | Use when |
|---|---|---|
docker compose up -d | shellwatch only | You point hydra.* in config.yaml at an Hydra you run elsewhere. |
docker compose --profile hydra up -d | shellwatch + hydra | Both on one host (the common self-hosted case). |
docker compose up -d hydra | hydra only | Local dev — run ShellWatch on the host with pnpm dev. |
First, migrate Hydra’s schema (required)
Section titled “First, migrate Hydra’s schema (required)”Hydra’s schema is never migrated automatically — migrations can be destructive, so they’re a deliberate, backed-up step. The very first time, before you ever start the hydra service, you must create its schema manually — otherwise Hydra comes up against an empty database and fails. Re-run it after any Hydra image bump too:
pnpm hydra:migrateThat wrapper backs up ./data/hydra.sqlite (to hydra.sqlite.bak-<timestamp>) and then runs hydra migrate sql inside the compose-defined hydra container, so it uses the exact same DSN and bind mount as the running service. If you deploy from the image without a repo checkout, run the underlying command directly (back up ./data/hydra.sqlite yourself first):
docker compose --profile hydra run --rm hydra migrate sql -e --yesShellWatch’s own migrations still run automatically from drizzle/ on startup — only Hydra’s are manual.
Quick start (both services, one host)
Section titled “Quick start (both services, one host)”mkdir -p data keyscp config.sample.yaml config.yaml# Edit config.yaml — at minimum set rpId, trustedWebauthnOrigins,# server.externalUrl, the hydra: section, and (typically) allowedNetworks.
# Create .env.hydra with a real Hydra system secret (the compose default is# dev-only). Just this one line is enough:echo "HYDRA_SECRETS_SYSTEM=$(openssl rand -hex 16)" > .env.hydra
pnpm hydra:migrate # REQUIRED before first start (creates Hydra's schema)docker compose --env-file .env.hydra --profile hydra up -dOpen https://shellwatch.example.com and register your first passkey — the first registration becomes the admin account.
The shipped docker-compose.yml
Section titled “The shipped docker-compose.yml”services: shellwatch: image: ghcr.io/rado0x54/shellwatch:latest ports: - "3000:3000" volumes: - ./data:/app/data - ./keys:/app/keys - ./config.yaml:/app/config.yaml:ro environment: - HOST=0.0.0.0 # Image defaults to UID:GID 1000:1000. Override if your host user differs # (and chown ./data and ./keys to that UID:GID on the host). # user: "1000:1000" restart: unless-stopped
hydra: image: oryd/hydra:v26.2.0 profiles: ["hydra"] # only starts with --profile hydra (or `up -d hydra`) ports: - "4444:4444" # PUBLIC — discovery, /oauth2/auth, /oauth2/token - "127.0.0.1:4445:4445" # ADMIN — login/consent accept, client CRUD, introspection environment: # File SQLite in the bind-mounted ./data → /data. `_fk=true` enables the # foreign-key enforcement Hydra relies on. DSN: ${HYDRA_DSN:-sqlite:///data/hydra.sqlite?_fk=true} SECRETS_SYSTEM: ${HYDRA_SECRETS_SYSTEM:-this-is-a-dev-only-system-secret-change-me} # `--dev` accepts an http:// issuer without TLS — fine for local dev only. # See the production note below. command: serve all --dev --config /etc/config/hydra.yml volumes: - ./hydra.yml:/etc/config/hydra.yml:ro - ./data:/data healthcheck: test: ["CMD", "hydra", "version"] interval: 10s timeout: 5s retries: 5 restart: unless-stoppedThe admin port (:4445) is bound to 127.0.0.1 so it’s never reachable from off-host — keep it that way. The public port (:4444) is what your reverse proxy forwards to (see Reverse proxy & TLS).
.env.hydra only needs the system secret (and optionally a DSN override):
# Hydra's system secret — encrypts data at rest. At least 16 characters.# Generate with `openssl rand -hex 16` and paste the result here.# Rotating it invalidates all existing tokens/sessions.HYDRA_SECRETS_SYSTEM=replace-with-generated-hex-secret
# Optional: override the datastore (default is file SQLite in ./data).# HYDRA_DSN=sqlite:///data/hydra.sqlite?_fk=truehydra.yml
Section titled “hydra.yml”This file (in the repo root, mounted read-only into the container) wires Hydra to use ShellWatch as its passkey-gated login + consent provider. The shipped copy is localhost-only; for production, edit the URLs to your domain and add CORS + TLS termination — the full production version lives in Reverse proxy & TLS. The localhost default:
serve: cookies: same_site_mode: Lax public: cors: enabled: true allowed_origins: - http://localhost:3000 - http://localhost:3001
urls: self: issuer: http://localhost:4444 # must EXACTLY equal hydra.publicUrl in config.yaml public: http://localhost:4444 # ShellWatch's passkey-gated login + consent providers (browser redirects). login: http://localhost:3000/api/hydra/login consent: http://localhost:3000/api/hydra/consent logout: http://localhost:3000/api/hydra/logout error: http://localhost:3000/api/hydra/error # After logout, Hydra sends the browser to the SPA root; with no session the # auth guard restarts the OAuth flow and lands on the passkey login page. post_logout_redirect: http://localhost:3000/
oidc: subject_identifiers: supported_types: - public # Mediated DCR only — ShellWatch owns /api/hydra/register and provisions # clients via the admin API after enforcing local policy. Keep Hydra's own DCR off. dynamic_client_registration: enabled: false
strategies: access_token: opaque
ttl: access_token: 30m refresh_token: 720h id_token: 30m auth_code: 10mconfig.yaml
Section titled “config.yaml”Point ShellWatch at Hydra. Inside the compose network the admin API is reachable by service name; the public URL is the browser-facing issuer:
server: externalUrl: https://shellwatch.example.com
hydra: publicUrl: https://shellwatch.example.com # must equal Hydra's urls.self.issuer adminUrl: http://hydra:4445 # compose-internal; never public spa: clientId: shellwatch-web # auto-provisioned in Hydra on bootImage tags
Section titled “Image tags”| Tag | What it is |
|---|---|
latest | Latest stable release. |
X.Y.Z | Specific version (e.g. 0.5.2). |
X.Y | Latest patch for a minor (e.g. 0.5). |
stable | Tracks main branch. |
develop | Tracks develop branch — may be unstable. |
sha-<hash> | A specific commit build. |
For production, pin to X.Y.Z (or X.Y if you want auto-updates within a minor).
Volumes
Section titled “Volumes”| Mount | Purpose | Notes |
|---|---|---|
/app/config.yaml | Configuration | Mount read-only. |
/app/data (./data) | Both SQLite databases: shellwatch.db (+ WAL files) and hydra.sqlite | Must be persisted. Losing shellwatch.db loses accounts, endpoints, passkeys, and the audit tables. Losing hydra.sqlite loses all OAuth clients, grants and tokens — every client (web, MCP, agent) has to re-authenticate. (Session input/output is never persisted, so there’s no session-content data to lose.) |
/app/keys | SSH private keys | Mount read-only. |
Environment variables
Section titled “Environment variables”| Variable | Default | Description |
|---|---|---|
HOST | 0.0.0.0 | Bind address |
SHELLWATCH_DB | sqlite:./data/shellwatch.db | ShellWatch’s database connection string |
SHELLWATCH_CONFIG | config.yaml | Config file path |
Most runtime knobs live in config.yaml, not env vars. The HYDRA_* variables are consumed by the compose file (for the Hydra container), not by ShellWatch.
Running as a different UID/GID
Section titled “Running as a different UID/GID”The image ships with a shellwatch user pinned to UID:GID 1000:1000. If your host user has a different UID/GID, override with --user (or user: in compose) and re-own the bind mounts:
sudo chown -R 1001:1001 ./data ./keysservices: shellwatch: user: "1001:1001"Health check
Section titled “Health check”The image’s HEALTHCHECK hits GET /health every 30 seconds. With compose:
docker compose ps # STATUS column shows (healthy)Upgrading
Section titled “Upgrading”docker compose pullpnpm hydra:migrate # if the Hydra image bumpeddocker compose --profile hydra up -dShellWatch migrations are auto-run from drizzle/ on startup. Hydra’s are not — run pnpm hydra:migrate (which backs up hydra.sqlite first) whenever you pull a newer oryd/hydra image. The containers are stateless beyond the mounted ./data, so the upgrade is otherwise just pull && up -d.
Back up ./data (both shellwatch.db with its WAL/SHM sidecars and hydra.sqlite) before any upgrade you’re nervous about.
docker compose logs -f shellwatchdocker compose logs -f hydraShellWatch logs to stdout in JSON-ish text. There is no built-in log file rotation — let your container runtime handle it (--log-opt max-size=...).
Where to next
Section titled “Where to next”- Reverse proxy — terminate TLS, route the OAuth surface to Hydra, set
trustProxy. - Hardening — IP allowlist, rate limits, cookie secret, Hydra admin isolation.
- Configuration reference — every option in
config.yaml.