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_SECRETto a 32-byte hex value generated byopenssl rand -hex 32. Do not rely on--allow-ephemeral-keyin production: a process restart wipes the verification root. - Set
SIGNET_AUDIT_LOG_PATHto a path on durable storage. - Run with
--workers N>1only when paired with theFileLockingJsonlBackendandHmacChain(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
(
Rfc3161Anchoragainst FreeTSA is the lowest-friction option) so audit-chain timestamps are independently verifiable. - Pick the right HTTP probe:
/healthzfor liveness,/readyzfor readiness./readyzprobes 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:
- 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. - 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 inchecks/owner_resolution.md. - Optionally enforces mTLS. The
integrations/auth.mddoc 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=Stricton the auth cookie. - Set
cors_allow_credentials=Falseunless you fully understand the consequences. The default isFalse. - 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
FileLockingJsonlBackendinstead of the defaultJsonlBackend. It serializes appends withfcntl(POSIX) /msvcrt(Windows) so two workers cannot fork the chain. - Pass
cache_prev=FalsetoHmacChain. Each append re-reads the chain head under the cross-process lock; without this, a worker's cachedprev_hmacwill 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:
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 asignet benchperformance gate against every PR touchingpipeline.py. - kubernetes — multi-replica deployment
via a minimal Helm chart, with
/healthzliveness,/readyzreadiness, 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.