Self-hosted zero-knowledge sync server for the Skerry SSH client. SQLite/PostgreSQL, amd64+arm64.
2.3K
Self-hosted, zero-knowledge sync server for Skerry — an open-source SSH client for Linux, Windows, macOS and Android.
The client is local-first and fully functional without a server. This image exists for one job: carrying an encrypted vault between your devices. There is no vendor cloud — the instance is yours.
Zero-knowledge by design. What sits on the server is ciphertext (the wrapped dataKey,
encrypted vault records) and sync metadata. Authentication is SRP-6a: the password is never
transmitted, and the server cannot decrypt anything you store.
linux/amd64, linux/arm64latest, X.Y.Z (exact release), X.Y (latest patch of a minor)SQLite in a named volume, no configuration:
docker run -d --name skerry-sync -p 8080:8080 \
-e SKERRY_JWT_SECRET="$(openssl rand -base64 48)" \
-e SKERRY_ADMIN_TOKEN="$(openssl rand -hex 16)" \
-v skerry-data:/data \
secherkasov/skerry-sync:latest
The server listens on http://localhost:8080. Paste that URL into the app: Settings → Sync.
SKERRY_JWT_SECRET is the only variable production actually requires — the server refuses to
start on the built-in default unless SKERRY_DEV=1. Rotating the secret invalidates every
issued token.
services:
skerry-sync:
image: secherkasov/skerry-sync:latest
restart: unless-stopped
ports: ["8080:8080"]
environment:
SKERRY_JWT_SECRET: "change-me" # openssl rand -base64 48
SKERRY_ADMIN_TOKEN: "change-me" # openssl rand -hex 16
SKERRY_REGISTRATION: "open" # close it once your devices are registered
volumes: ["skerry-data:/data"]
volumes:
skerry-data:
services:
skerry-sync:
image: secherkasov/skerry-sync:latest
restart: unless-stopped
ports: ["8080:8080"]
depends_on: [db]
environment:
SKERRY_JWT_SECRET: "change-me"
SKERRY_ADMIN_TOKEN: "change-me"
SKERRY_DB_URL: "jdbc:postgresql://db:5432/skerry"
SKERRY_DB_USER: "skerry"
SKERRY_DB_PASSWORD: "change-me"
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: skerry
POSTGRES_USER: skerry
POSTGRES_PASSWORD: change-me
volumes: ["skerry-db:/var/lib/postgresql/data"]
volumes:
skerry-db:
The driver is chosen by the URL scheme: jdbc:sqlite: or jdbc:postgresql:. Schema migrations
run at startup.
Every setting is an environment variable. Defaults are fit for a local run.
| Variable | Default | Purpose |
|---|---|---|
SKERRY_JWT_SECRET | dev-insecure-change-me | JWT signing secret. Startup fails on the default unless SKERRY_DEV=1. |
SKERRY_ADMIN_TOKEN | (empty) | Operator token for /console and /admin/*. Empty ⇒ admin endpoints are closed. |
SKERRY_HOST | 0.0.0.0 | Bind interface. 127.0.0.1 behind a reverse proxy. |
SKERRY_PORT | 8080 | Listen port. |
SKERRY_DB_URL | jdbc:sqlite:/data/skerry-sync.db | JDBC URL; jdbc:postgresql://… switches to PostgreSQL. |
SKERRY_DB_USER / SKERRY_DB_PASSWORD | (empty) | Database credentials (PostgreSQL). |
SKERRY_REGISTRATION | open | Anything other than open closes POST /auth/register with 403. |
SKERRY_MAX_ACCOUNTS | 0 | Hard cap on total accounts; 0 is unlimited. |
SKERRY_TRUSTED_PROXIES | (empty) | Reverse-proxy IPs whose X-Forwarded-For is trusted for per-IP rate limits. Empty ⇒ the header is ignored. |
SKERRY_ACCESS_TTL | 900 | Access-token lifetime, seconds. |
SKERRY_REFRESH_TTL | 2592000 | Refresh-token lifetime, seconds (30 days). |
SKERRY_PAIRING_TTL | 300 | Lifetime of a one-shot QR pairing session. |
SKERRY_TOMBSTONE_DAYS | 90 | Retention of deletion tombstones before physical cleanup. |
SKERRY_MAX_BODY_BYTES | 4194304 | Request-body cap (4 MiB); larger requests get 413. |
SKERRY_CORS_HOSTS | (empty) | Comma-separated allowed origins. Empty disables CORS — native clients are not subject to it. |
SKERRY_JWT_ISSUER | skerry-sync | JWT iss claim. |
SKERRY_METRICS | off | Prometheus /metrics: off (404), token (bearer), open (no credential). |
SKERRY_METRICS_TOKEN | (empty) | Bearer token for SKERRY_METRICS=token. Startup fails if the mode is token and this is empty. |
SKERRY_METRICS_INVENTORY_SECONDS | 60 | Refresh interval of the inventory gauges (minimum 15, 0 disables them). |
SKERRY_DEV | (unset) | 1 unlocks the default JWT secret — local development only. |
Container defaults: the image ships SKERRY_HOST=0.0.0.0, SKERRY_PORT=8080 and
SKERRY_DB_URL=jdbc:sqlite:/data/skerry-sync.db, runs as an unprivileged user (uid/gid 999) and
declares a volume at /data. A bind mount has to be chown 999:999.
One static bundle, three entrances, served by the server itself — no external CDN, CSP is
default-src 'self', fonts and icons are bundled, the pages work offline.
| URL | Who | Credential | Shows |
|---|---|---|---|
/ | anyone | none | Whether the instance is serving, its version, whether registration is open, the URL to paste into a client. |
/account | account owner | account id + web password | Devices, teams, live sessions, record envelopes, the account's own log. |
/console | operator | SKERRY_ADMIN_TOKEN | Instance totals, accounts, observability, audit log. |
The web password is a separate credential set in the app (Settings → Sync → Web access) and
is unrelated to any vault key. A browser session reads metadata the server already holds in the
clear; its token is restricted server-side to read-only access without /vault/keys and
/vault/records, plus device revocation. It cannot decrypt a record — dataKey is not part of
the flow. Team membership and key rotation are not manageable from a browser: those seal
envelopes under a team key no browser session holds.
Interface languages: English, Russian, Chinese.
skerry-admin ships inside the image and drives the same /admin endpoints as the console — one
implementation, one authorization gate. It never touches the database directly.
docker exec -e SKERRY_ADMIN_TOKEN=… skerry-sync skerry-admin stats
docker exec -e SKERRY_ADMIN_TOKEN=… skerry-sync skerry-admin accounts list
docker exec -e SKERRY_ADMIN_TOKEN=… skerry-sync skerry-admin devices list --account [email protected]
docker exec -e SKERRY_ADMIN_TOKEN=… skerry-sync skerry-admin devices revoke devA --account [email protected]
docker exec -e SKERRY_ADMIN_TOKEN=… skerry-sync skerry-admin accounts delete [email protected] --yes
| Command | Purpose |
|---|---|
health | Liveness and version (no token required). |
stats | Instance totals: accounts, active devices, records, storage. |
accounts list / accounts records <id> | Accounts with aggregates; per-account record metadata. |
accounts purge-tombstones <id> | Drop deletion markers every device has synced past. |
accounts delete <id> --yes | Delete an account with all its data (irreversible). |
devices list [--account id] | Active devices, most recently seen first. |
devices revoke <id> --account <id> | Revoke a device. |
activity | Recent audit-log events. |
metrics | Raw Prometheus exposition (uses SKERRY_METRICS_TOKEN). |
Options: --url (default SKERRY_ADMIN_URL, else http://127.0.0.1:$SKERRY_PORT), --token /
--token-file, --limit, --json, --help. Exit codes: 0 ok, 1 error, 2 usage,
3 unauthorized, 4 not found, 5 server unreachable.
| Endpoint | Purpose |
|---|---|
GET /healthz | Liveness, open, never touches the database. Used by the container healthcheck. |
GET /readyz | Readiness: 200 with {"status":"ready","db":"up"}, 503 after three consecutive failed database probes. |
GET /metrics | Prometheus exposition. Disabled by default. |
/metrics is closed by default on purpose: the exposition is metadata about the instance — how
many accounts, how much ciphertext, how often logins fail — and metadata is the whole attack
surface of a zero-knowledge server. An unrecognized value of SKERRY_METRICS means off, so a
typo cannot open it.
# prometheus.yml
scrape_configs:
- job_name: skerry-sync
authorization:
credentials: "<SKERRY_METRICS_TOKEN>"
static_configs:
- targets: ["skerry-sync:8080"]
X-Admin-Token header; without TLS it is visible on the wire. Bind the server to 127.0.0.1
and let the proxy hold the public port.SKERRY_TRUSTED_PROXIES to your proxy's IPs, otherwise every request looks like it
comes from the proxy and per-IP rate limits key on the wrong address.SKERRY_REGISTRATION=closed, optionally
SKERRY_MAX_ACCOUNTS as a backstop./data/skerry-sync.db; for PostgreSQL use
pg_dump. The backup is ciphertext — restoring it without your master password gives nobody
anything, including you.SKERRY_JWT_SECRET stable. Changing it signs every device out.The server root must be the proxied path root — a reverse-proxy path prefix is not supported.
It cannot read your vault. It has no access to your master password, and the wrapped dataKey
it stores is opened only on your devices. Losing the master password is unrecoverable by design:
there is no reset, because there is nothing on the server to reset it with.
Full deployment guide — configuration reference, API endpoints, TLS examples, backups and the privacy model: server/README.md.
Content type
Image
Digest
sha256:a2ea2fe19…
Size
207.1 MB
Last updated
4 days ago
docker pull secherkasov/skerry-sync