Skip to content

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:

CommandWhat startsUse when
docker compose up -dshellwatch onlyYou point hydra.* in config.yaml at an Hydra you run elsewhere.
docker compose --profile hydra up -dshellwatch + hydraBoth on one host (the common self-hosted case).
docker compose up -d hydrahydra onlyLocal 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:

Terminal window
pnpm hydra:migrate

That 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):

Terminal window
docker compose --profile hydra run --rm hydra migrate sql -e --yes

ShellWatch’s own migrations still run automatically from drizzle/ on startup — only Hydra’s are manual.

Terminal window
mkdir -p data keys
cp 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 -d

Open https://shellwatch.example.com and register your first passkey — the first registration becomes the admin account.

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-stopped

The 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):

Terminal window
# 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=true

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: 10m

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 boot
TagWhat it is
latestLatest stable release.
X.Y.ZSpecific version (e.g. 0.5.2).
X.YLatest patch for a minor (e.g. 0.5).
stableTracks main branch.
developTracks 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).

MountPurposeNotes
/app/config.yamlConfigurationMount read-only.
/app/data (./data)Both SQLite databases: shellwatch.db (+ WAL files) and hydra.sqliteMust 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/keysSSH private keysMount read-only.
VariableDefaultDescription
HOST0.0.0.0Bind address
SHELLWATCH_DBsqlite:./data/shellwatch.dbShellWatch’s database connection string
SHELLWATCH_CONFIGconfig.yamlConfig 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.

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:

Terminal window
sudo chown -R 1001:1001 ./data ./keys
services:
shellwatch:
user: "1001:1001"

The image’s HEALTHCHECK hits GET /health every 30 seconds. With compose:

Terminal window
docker compose ps # STATUS column shows (healthy)
Terminal window
docker compose pull
pnpm hydra:migrate # if the Hydra image bumped
docker compose --profile hydra up -d

ShellWatch 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.

Terminal window
docker compose logs -f shellwatch
docker compose logs -f hydra

ShellWatch 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=...).