Sign inSign up

secherkasov/skerry-sync

By secherkasov

•Updated 4 days ago

Self-hosted zero-knowledge sync server for the Skerry SSH client. SQLite/PostgreSQL, amd64+arm64.

Image
0

2.3K

secherkasov/skerry-sync repository overview

⁠Skerry Sync Server

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.


⁠Quick start

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.

⁠Docker Compose, SQLite
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:
⁠Docker Compose, PostgreSQL
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.


⁠Configuration

Every setting is an environment variable. Defaults are fit for a local run.

VariableDefaultPurpose
SKERRY_JWT_SECRETdev-insecure-change-meJWT 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_HOST0.0.0.0Bind interface. 127.0.0.1 behind a reverse proxy.
SKERRY_PORT8080Listen port.
SKERRY_DB_URLjdbc:sqlite:/data/skerry-sync.dbJDBC URL; jdbc:postgresql://… switches to PostgreSQL.
SKERRY_DB_USER / SKERRY_DB_PASSWORD(empty)Database credentials (PostgreSQL).
SKERRY_REGISTRATIONopenAnything other than open closes POST /auth/register with 403.
SKERRY_MAX_ACCOUNTS0Hard 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_TTL900Access-token lifetime, seconds.
SKERRY_REFRESH_TTL2592000Refresh-token lifetime, seconds (30 days).
SKERRY_PAIRING_TTL300Lifetime of a one-shot QR pairing session.
SKERRY_TOMBSTONE_DAYS90Retention of deletion tombstones before physical cleanup.
SKERRY_MAX_BODY_BYTES4194304Request-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_ISSUERskerry-syncJWT iss claim.
SKERRY_METRICSoffPrometheus /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_SECONDS60Refresh 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.


⁠Web frontend

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.

URLWhoCredentialShows
/anyonenoneWhether the instance is serving, its version, whether registration is open, the URL to paste into a client.
/accountaccount owneraccount id + web passwordDevices, teams, live sessions, record envelopes, the account's own log.
/consoleoperatorSKERRY_ADMIN_TOKENInstance 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.


⁠Admin CLI

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
CommandPurpose
healthLiveness and version (no token required).
statsInstance 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> --yesDelete 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.
activityRecent audit-log events.
metricsRaw 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.


⁠Health and metrics

EndpointPurpose
GET /healthzLiveness, open, never touches the database. Used by the container healthcheck.
GET /readyzReadiness: 200 with {"status":"ready","db":"up"}, 503 after three consecutive failed database probes.
GET /metricsPrometheus 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"]

⁠Running it in production

  • Terminate TLS in front of it (Caddy, nginx, Traefik). The admin token travels in the 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.
  • Set 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.
  • Close registration once your devices are in: SKERRY_REGISTRATION=closed, optionally SKERRY_MAX_ACCOUNTS as a backstop.
  • Back up the volume. For SQLite that is /data/skerry-sync.db; for PostgreSQL use pg_dump. The backup is ciphertext — restoring it without your master password gives nobody anything, including you.
  • Keep 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.


⁠What it does not do

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

Tag summary

Content type

Image

Digest

sha256:a2ea2fe19…

Size

207.1 MB

Last updated

4 days ago

docker pull secherkasov/skerry-sync