Server configuration
The UniSSH server is configured in layers: defaults → config.toml → environment. Environment keys use the form UNISSH__SECTION__KEY=... (double-underscore nesting). Secrets (TLS key, Postgres URL, and the optional ops token) should come from the environment or Docker secrets, never the committed file.
Start from the shipped template:
cp config.example.toml config.tomlSections
Section titled “Sections”[server]
Section titled “[server]”[server]bind = "0.0.0.0:8443"public_url = "" # external base URL for links/redirects; # empty → derived from the requesttls_cert = "/secrets/cert.pem" # in-process TLS 1.3 (rustls)tls_key = "/secrets/key.pem"trust_proxy = falseacme = falseSet tls_cert/tls_key for in-process rustls (TLS 1.3 only), or leave them empty and terminate TLS at a reverse proxy with trust_proxy = true. public_url is the externally reachable base URL (used when the server builds links); leave it empty to derive it from the request. A cors_allowed_origins key (a list, empty by default) adds extra browser origins for CORS — unneeded in the same-origin Compose deployment, where CORS stays off.
[db]backend = "sqlite" # "sqlite" | "postgres"url = "/app/data/unissh.db" # sqlite: file path (or ":memory:") # postgres: postgres://user:pass@host/dbmax_connections = 16[limits]
Section titled “[limits]”Request and object bounds, plus a per-IP rate limit.
[limits]max_body_bytes = 25165824 # 24 MiBmax_object_bytes = 17825792 # 17 MiBmax_objects_per_push = 1000delta_page_size = 500delta_max_page_size = 1000delta_max_page_bytes = 8388608 # 8 MiBrate_limit_per_ip_rps = 20rate_limit_burst = 40max_object_bytes is the largest single object a push may carry. The default admits the largest object a client produces: a full session recording (an 8 MiB capture, up to twice that once stored, plus headroom). With a lower value such an item is refused; the client then keeps it on the device, uploads everything else and names the item in its sync report.
max_body_bytes is the largest request body. Objects travel base64-encoded, so it must be at least 4/3 of max_object_bytes; the defaults satisfy that. A reverse proxy in front of the server needs the same limit (client_max_body_size 24m in nginx).
delta_page_size and delta_max_page_size are the default and the largest number of objects in one page of a pull; delta_max_page_bytes bounds the same page by size. A page is cut before the object that would exceed it and always carries at least one, so an object larger than the budget still syncs, in a page of its own. Clients follow the paging on their own, including clients older than 0.6.
Only uploads (POST /v1/sync/push) accept a body up to max_body_bytes. Every other route, including the ones that answer before sign-in, stops at 2 MiB.
Servers up to 0.5 defaulted to 1 MiB objects and, whatever max_body_bytes said, accepted request bodies of at most 2 MiB. A configuration file written for them that sets max_object_bytes = 1048576 keeps that limit after an upgrade: raise it (or drop the line) to let recordings sync.
[sync]
Section titled “[sync]”[sync]freshness_window_seconds = 30 # window for online-only live-grantsvalidate_signatures = true # defense-in-depth record-signature checksmin_instance_generation = 0 # anti-rollback floor (Σ next_seq); 0 = offvalidate_signatures(on by default) re-verifies each Vault/Item/Manifest/Grant record’s Ed25519 signature on write, byte-exact with the core, dropping forged/tampered objects early. This is defense-in-depth, not the security boundary — the client still re-verifies on read.min_instance_generationis an operator-anchored, out-of-band floor for the instance-wide sequence (next_seq). The server refuses to boot if a restored snapshot is below it, closing the new-client/TOFU rollback gap. Anchor this value outside the database. See Backups & anti-rollback restore and the sync model.
[session]
Section titled “[session]”Token and lifecycle TTLs (seconds):
[session]access_ttl_seconds = 900refresh_ttl_seconds = 2592000nonce_ttl_seconds = 120invite_default_ttl_seconds = 86400relay_ttl_seconds = 120janitor_interval_seconds = 300idempotency_ttl_seconds = 86400[obs]log_format = "json" # "json" | "text"otel_endpoint = "" # OTLP export is NOT compiled in: a value here # only warns at startup. Metrics: /metrics.metrics_bind = "127.0.0.1:9090"[setup]
Section titled “[setup]”Controls the one-time setup code that the first user presents to claim the instance and become its owner. There is no bootstrap token.
[setup]code = "" # empty → a random setup code is printed to the # server log on first boot (while unclaimed); # set a value to pin a deterministic code for IaC. # Env: UNISSH__SETUP__CODEThe server stores only sha256(code), and the code is valid only while the instance is unclaimed — a second claim is refused. Read the printed code with docker compose logs server 2>&1 | grep -i "setup code". Because only the hash is kept, a generated code cannot be printed a second time; if the log is gone, unissh-server setup-code --rotate --config <your config> issues a new one without touching any data (point it at the config you serve with — the default database path is relative, so a different working directory silently creates an empty one) (a pinned code is never printed at all — it came from you). See the Docker Compose deployment.
[oidc]
Section titled “[oidc]”Optional SSO (OpenID Connect). Disabled by default; when enabled, the login screen offers “Sign in with SSO”, and IdP groups map to space memberships (reconciled on every login).
[oidc]enabled = falseissuer = "" # IdP issuer URLclient_id = ""audience = "" # expected id_token `aud`; empty → client_idjwks_url = "" # empty → {issuer}/.well-known/jwks.jsongroups_claim = "groups" # id_token claim holding the group listmax_reassertion_age_seconds = 604800 # 7 days before a full OIDC re-auth# [[oidc.group_map]] # repeat per mapping: IdP group → space membership# group = "engineering"# space_id = "<space-id>"# role = "member"SSO asserts identity + space memberships only — never vault keys: the id_token is verified against the issuer JWKS and bound to the presented keyset via an OIDC nonce, and dropping an IdP group removes that space on the next login.
An optional break-glass operator surface (/v1/ops/*, header X-UniSSH-Ops-Token), off by default:
[ops]token = "" # empty → ops surface DISABLED (the default) # set via UNISSH__OPS__TOKENThis is server-trusted infrastructure access (overview / instance / seq-bump), not a keyset and never decryption. It is not how the admin panel normally signs in — the panel authenticates by escrow or SSO; the ops token is a last-resort infrastructure lever.
[audit]
Section titled “[audit]”Optional audit export sinks. Absent means the server exports nothing; sinks are configured here only, never through the API. The log holds server events only: SSH sessions never pass through the server and are not recorded here (see the audit log).
[audit.webhook]url = "https://siem.example.com/unissh"secret_env = "UNISSH_AUDIT_WEBHOOK_SECRET" # or: secret_file = "/run/secrets/audit_webhook"batch_size = 100 # entries per POSTtimeout_secs = 10 # a timeout fails the batchThe webhook POSTs batches of entries as JSON, each entry shaped like a line of the JSON Lines export, with X-UniSSH-Signature: sha256=<hex HMAC-SHA256 of the body> and X-UniSSH-Delivery: <first seq>-<last seq>. A 2xx acknowledges the batch; anything else, a redirect or a timeout retries the same batch with exponential backoff (1 s up to 5 min, with jitter).
- The secret never goes in the TOML.
secret_envnames an environment variable that holds it;secret_fileis a path to a file that holds it (a trailing newline is ignored). Set exactly one. A webhook without a readable secret is a startup error. - The HMAC key is the secret’s literal bytes, exactly as written. A hex- or base64-looking secret is not decoded, so receivers must not decode it either.
- Use
https://. Batches carry audit entries and metadata; a plainhttp://URL to a non-loopback host is accepted but warned about at boot. - A section whose
url,secret_envandsecret_fileare all empty counts as absent (so a deployment can pass empty variables through); a partly set one fails startup. - At-least-once. The server records the last acknowledged
seqand resumes after it on restart. A batch can arrive twice (for example, a crash right after the receiver answered), so receivers dedupe onseq. - Every key also works from the environment:
UNISSH__AUDIT__WEBHOOK__URL,UNISSH__AUDIT__WEBHOOK__SECRET_ENV, and so on. - Writing the receiver: the body, the signature recipe with a worked example, and the dedupe rule are in Audit webhook integration.
[audit.syslog]address = "127.0.0.1:514" # host:port of the collector; IPv6 in brackets: "[::1]:514"protocol = "tcp" # "tcp" (default) or "udp"facility = "auth" # kern, user, ..., auth, authpriv, ..., local0..local7app_name = "unissh" # RFC 5424 APP-NAMEThe syslog sink sends one RFC 5424 message per entry, at severity notice:
<37>1 2026-10-03T09:12:44Z unissh.example.com unissh - login [unissh@32473 seq="42" event="login" space_id="" vault_id=""] {"event":"login",...}- Header. The timestamp is the entry’s
recorded_at(UTC). The hostname is the host ofserver.public_url, or-when it is unset. MSGID is the event kind, or-when there is none. - Structured data.
unissh@32473carriesseq,event,space_idandvault_id(base64; empty when the entry has none, andeventis empty for a client-signed entry). UniSSH has no IANA Private Enterprise Number of its own, by design; 32473 is the number RFC 5612 reserves for documentation.unissh@32473is a frozen wire identifier: collector parsers key on it, so changing it would be a breaking change. - Body. The entry exactly as in the JSON Lines export’s
entry: compact JSON for a server event, the base64 of the blob for a client-signed entry. It is UTF-8 sent as RFC 5424 MSG-ANY without a BOM, so collectors should not expect one. For chain verification use the export or the webhook, which carryentry_bloband the hash fields. - UDP vs TCP. UDP sends and forgets: the cursor advances once every datagram of a batch is sent, so a datagram lost on the way is lost. An entry too large for one datagram (65,507 bytes over IPv4, 65,527 over IPv6) is skipped with a
udp_oversizewarning naming itsseq, so it never holds back later entries; use TCP if entries can be that large. TCP uses octet counting (RFC 6587) on one persistent connection, re-opened after an error, and advances the cursor only after the write succeeds; otherwise the same batch is retried with backoff. - No TLS. Syslog goes out in plaintext; a non-loopback collector is warned about at boot. Use a forwarder on the same host (rsyslog, syslog-ng, Vector) to carry it further over TLS.
- Both sinks can be configured at once; each keeps its own cursor, so one sink’s outage does not hold back the other.
- Each sink’s last delivered
seq, lag and last error show on the admin panel’s audit screen and atGET /v1/admin/audit/sinks; Prometheus getsunissh_audit_sink_delivered_seq{sink},unissh_audit_sink_lag{sink}andunissh_audit_sink_failures_total{sink}(see watching the sink). - An empty
addresswith every other key empty or default counts as absent; an emptyprotocol,facilityorapp_nametakes its default. A bad address, protocol, facility or app name is a startup error. From the environment:UNISSH__AUDIT__SYSLOG__ADDRESS,UNISSH__AUDIT__SYSLOG__PROTOCOL, and so on.
Where a sink starts. The delivered position (the cursor) is kept per sink type, webhook or syslog, not per URL or address. Changing url or address to a new receiver continues from the old cursor, so the new receiver gets no history. A sink type that has never delivered starts at seq 1 and sends the whole log. To backfill a new receiver, load the JSON Lines export into it, or stop the server and delete that sink’s row from the audit_sink_cursor table.
Environment overrides
Section titled “Environment overrides”Any key maps to an environment variable by uppercasing and joining with double underscores:
UNISSH__SERVER__BIND=0.0.0.0:8443UNISSH__SERVER__TRUST_PROXY=trueUNISSH__DB__BACKEND=postgresUNISSH__DB__URL=postgres://unissh:secret@postgres:5432/unisshUNISSH__SETUP__CODE=my-pinned-setup-code # optional — pin instead of a random oneUNISSH__OPS__TOKEN=$(openssl rand -hex 32) # optional — only if you enable break-glass opsGenerate strong tokens with openssl rand -hex 32. In the Compose stack these live in a gitignored .env; nothing secret is baked into images. See Docker Compose deployment.