Skip to content

Production deployment guide

This guide is opinionated. It tells you the dials that matter when moving signet from signet serve --dev to a real production rollout and explains why each one is here. Follow it top-to-bottom and you end up with a deployment that is hardened against the failure modes the threat model in security.md calls out.

TL;DR checklist

If you only read one section, read this. The rest is the why.

  • Set SIGNET_HMAC_SECRET to a 32-byte hex value generated by openssl rand -hex 32. Do not rely on --allow-ephemeral-key in production: a process restart wipes the verification root.
  • Set SIGNET_AUDIT_LOG_PATH to a path on durable storage.
  • Run with --workers N>1 only when paired with the FileLockingJsonlBackend and HmacChain(cache_prev=False). See Multi-process workers below.
  • Keep --strict-error-redaction (the default) on. The verbose body shape leaks check names and rule labels, which is operational intelligence.
  • Put real authentication in front of signet. Caller-asserted owner headers are attribution, not authentication. mTLS or an OIDC proxy at the edge: see integrations/auth.md.
  • Configure an external anchor backend (Rfc3161Anchor against FreeTSA is the lowest-friction option) so audit-chain timestamps are independently verifiable.
  • Pick the right HTTP probe: /healthz for liveness, /readyz for readiness. /readyz probes the upstream and 503s when it is unreachable; pair it with k8s readiness so traffic sheds without killing the pod.

The remaining sections expand each item.

TLS and the trust boundary

signet is deliberately not a TLS terminator. Run a reverse proxy (nginx, caddy, an ALB, an envoy sidecar) that:

  1. Terminates TLS for inbound clients. signet serves cleartext HTTP on 127.0.0.1 (the default --host) and trusts that nobody else is on that interface.
  2. Strips client-supplied owner headers (X-Commit-Owner, X-Agent-Id, X-Policy-Name, X-Policy-Version) and rewrites them based on the authenticated identity. Otherwise any client that can reach signet can claim to be anyone, because signet does not authenticate the headers themselves — see the warning in checks/owner_resolution.md.
  3. Optionally enforces mTLS. The integrations/auth.md doc has working nginx, FastAPI middleware, and oauth2-proxy recipes for this.

Binding signet directly to 0.0.0.0 is supported for niche deployments (single-tenant, network-isolated), but the default is loopback for a reason. Use --host 0.0.0.0 only after working through the trust-model section in architecture.md.

CSRF posture

For server-to-server callers (the default agent and tool-router shape), CSRF is not a concern: there is no browser, no cookie, no implicit authority. Owner attribution comes from explicit headers your edge proxy injects.

If you mount signet behind a browser-fronted UI (chat product, admin console) on the same origin as authenticated session cookies, treat it like any cookie-authenticated API:

  • Require a CSRF token on every state-changing request.
  • Set SameSite=Strict on the auth cookie.
  • Set cors_allow_credentials=False unless you fully understand the consequences. The default is False.
  • Do not use cors_allowed_origins=("*",) with credentials — the browser and the spec will refuse the combination, but signet will accept the misconfiguration at startup.

Worker count and cache_prev

The simplest production deployment is a single-worker process. It scales further than people expect: signet's per-request work is microseconds, the upstream LLM is the bottleneck.

When you do scale to multiple uvicorn workers (--workers N>1):

  • Use FileLockingJsonlBackend instead of the default JsonlBackend. It serializes appends with fcntl (POSIX) / msvcrt (Windows) so two workers cannot fork the chain.
  • Pass cache_prev=False to HmacChain. Each append re-reads the chain head under the cross-process lock; without this, a worker's cached prev_hmac will race ahead of a sibling worker's writes and the next append will dangle.
from pathlib import Path

from signet.audit.backend import FileLockingJsonlBackend
from signet.audit.chain import HmacChain
from signet.audit.keyring import Key, KeyRing

ring = KeyRing(active=Key(key_id="prod-2026-q2", secret=...))
chain = HmacChain(
    backend=FileLockingJsonlBackend(Path("/var/log/signet/audit.jsonl")),
    keyring=ring,
    cache_prev=False,  # required when running uvicorn --workers > 1
)

If you forget either piece, the verifier catches it post-hoc — but the failure mode is "we cannot prove the audit chain wasn't tampered with after the fact", which defeats half the point of running signet.

Rate limiting under multi-process uvicorn

RateLimitCheck uses an in-process OrderedDict bucket store by default. Under uvicorn --workers N>1, each worker has its own independent bucket per owner — meaning the effective per-owner rate limit is N × the configured rate.

If you run multi-worker AND care about strict rate limiting, swap the in-process backend for the bundled Redis adapter:

from signet.checks.rate_limit import RateLimitCheck
from signet.checks.redis_rate_limit_state import RedisRateLimitState

state = RedisRateLimitState(url="redis://localhost:6379/0")
check = RateLimitCheck(capacity=60, refill_per_second=1.0, state=state)

Or, more generally, implement the RateLimitState protocol against your state store of choice. The protocol is documented in signet.checks.rate_limit.

If you only need approximate rate limiting (e.g., burst protection rather than strict quotas), the default in-process backend is fine and the N× caveat is acceptable noise.

Anchor-backend selection

The audit chain's HMAC is tamper-evident if you trust the secret. External anchoring binds each entry to an independent witness so verification doesn't depend on the secret remaining secret.

Pick by threat model:

Backend Threat covered Cost Friction
NoopAnchor (default) None — chain is HMAC-only Free None
Rfc3161Anchor(url=FreeTSA) "Did this entry exist by time T?" against any third party who trusts FreeTSA Free One outbound HTTP call per append
Rfc3161Anchor(url=your-internal-TSA) Same, against an authority you control Cost of your TSA One outbound call
Custom AnchorBackend (Sigstore Rekor, internal transparency log) Public log inclusion proof Varies Implement the protocol

For a first production deployment, Rfc3161Anchor against FreeTSA is the lowest-friction option that gives you something. Move to a TSA you control once compliance asks for it.

HSM / KMS integration

The HMAC secret in KeyRing is treated as cleartext bytes by signet. For production, the secret should never leave a hardware security module: signet should call out to KMS to compute the per-entry HMAC.

This is plugin territory in v0.1.x. The audit-chain interface that matters is HmacChain._read_prev_hmac() (read) and the inline hmac.new(...) call inside append() (write). Wrap your KMS in an implementation that satisfies the same contract and pass it via a custom subclass.

This is one of the genuine "you'll want signet support for this" items — see the README's "When you need more than the OSS" section if you'd rather have it engineered.

Probe wiring

Endpoint Use for Returns
/health (alias /healthz) k8s liveness 200 with {status, service, version, uptime_seconds, audit_chain_head_hmac, pipeline_check_count, shadow}. audit_chain_head_hmac is "disabled" (no audit configured), null (configured but empty), or the last 8 hex chars of the head HMAC. shadow is true when shadow mode is active. Always 200 if the process is alive.
/readyz k8s readiness 200 if upstream answered within 1s; 503 with {status, reason, upstream} otherwise.
/version build-time identification {version, service}.
/metrics Prometheus scrape Prometheus exposition format. See the metrics module for the counter set.

Wire livenessProbe to /healthz and readinessProbe to /readyz. Don't point both probes at /health — a flaky upstream will then restart the pod instead of just shedding traffic.

Logging

signet serve --log-format json routes the stdlib logging calls through structlog and emits one JSON object per line on stderr. Wire that to your aggregator (Loki / Datadog / ELK / OpenTelemetry collector) and you get structured logs without rewriting any modules.

Audit logs are not the same thing as application logs. The audit chain is the security record (audit.jsonl), the application log is the operational stream (request volumes, upstream latencies, errors). Don't conflate them; they have different retention and different compliance posture.

Strict-error redaction

Default in v0.1.5. Refusal bodies surface only:

{"error": "refused", "correlation_id": "8f7e2c1a-..."}

The check name, the firing rule, the severity — all stay in the audit row, surfaced via correlation_id. Operators who need that detail tail the audit log; attackers do not. Disable (--no-strict-error-redaction) only for first-time integration debugging; the default is what production should run.

Quick-start examples

Three pre-built recipes live in examples/:

  • docker-compose — one-command local production. Brings up signet + a local LLM (Ollama) + an optional Prometheus/Grafana stack behind a compose profile.
  • github-action — CI gate that runs signet lint --strict, signet doctor --probe-injection, and a signet bench performance gate against every PR touching pipeline.py.
  • kubernetes — multi-replica deployment via a minimal Helm chart, with /healthz liveness, /readyz readiness, a ConfigMap-mounted pipeline, and an audit-log PVC.

Each example pins signet-sign==0.1.8 so it stays paste-and-go for the lifetime of the release.

What signet does not handle

  • TLS termination — see TLS and the trust boundary.
  • Caller authentication — owner headers are claims, not proofs. See integrations/auth.md.
  • Secret rotation — KeyRing.rotate() exists but the operational flow (when, how often, with what key-management substrate) is your call.
  • Long-term audit retention — JSONL on disk is the wire format; how you back it up, replicate it across regions, or stream it into a WORM-compliant store is your call.