What this page is
A Chirp app deploys as a single ASGI app served by Pounce, Chirp's production server. There is one server and one app — no separate API tier, no worker queue to stand up.
In development you run one auto-reloading worker. In production you turn on multiple workers, metrics, and rate limiting, wire the secure-by-default stack, and gate the deploy on a contract preflight. This page is the operator's path from a working app to a hardened launch.
Reach for it when you are shipping. For what the contract checks actually enforce, see the contract category reference; for the full server-flag and freeze map, see the [deployment overview].
Configure the app
The only difference between dev and prod isAppConfig. Pick the form you run with — all three start the same app.
Single worker, auto-reload, debug error pages. No secret needed whileenv="development".
from chirp import App, AppConfig
app = App(AppConfig(debug=True))
@app.route("/")
def index():
return "Hello!"
app.run() # single worker, auto-reload
Multiple workers, metrics, and rate limiting.env="production"turns on the env-aware safety severities, and the secret is read from the environment — never hard-coded.
import os
from chirp import App, AppConfig
config = AppConfig(
env="production",
debug=False,
secret_key=os.environ["CHIRP_SECRET_KEY"],
workers=4,
metrics_enabled=True,
rate_limit_enabled=True,
)
app = App(config=config)
@app.route("/")
def index():
return "Hello, production!"
app.run() # multi-worker
Skip the Python wiring and pass production flags on the command line. The CLI readsCHIRP_SECRET_KEYfrom the environment.
chirp run myapp:app --production --workers 4 --metrics --rate-limit
AppConfig.from_env() reads CHIRP_*environment variables for you, so you can keep config out of code entirely:
config = AppConfig.from_env(env="production", worker_mode="async")
Deploy
- 1
Set the secret and a production config
Export
CHIRP_SECRET_KEYin the deploy environment and build a productionAppConfigwithenv="production"(see the tabs above). Theenvvalue drives the deploy severities in the next step. - 2
Run the preflight
Run both checks before every deploy — they validate different contracts:
chirp check myapp:app --deploy pounce check --app myapp:app --config pounce.tomlchirp check --deployanswers "would this app passapp.check()withenv="production"?" without mutating your config — it builds a throwaway production-posture view and re-runs the env-aware safety rules (secret_key,allowed_hosts,security_stack,csp_nonce, and thedebug/metrics/sentryposture).--deployimplies--warnings-as-errors, so warnings fail the build. A deploy-ready app still passes — the check is tighten-only. Wire it as your CI deploy gate.pounce checkvalidates Pounce's server-facing inputs: import path, bind address, TLS files, and worker settings. If you do not usepounce.toml, pass the same flags you run with:pounce check --app myapp:app --host 0.0.0.0 --port 8000 --workers 4 - 3
Launch
Start the production server — multi-worker, with the features you enabled:
chirp run myapp:app --production --workers 4 --metrics --rate-limitOr build the image and let the container run it:
FROM python:3.14-slim WORKDIR /app COPY . . RUN pip install bengal-chirp CMD ["chirp", "run", "myapp:app", "--production", "--workers", "4"]
What the server gives you
Pounce handles these for every Chirp app, no configuration required:
- HTTP and WebSocket compression — ordinary responses are compressed;
text/event-streamis left uncompressed so SSE events are not buffered behind a compression window. - HTTP/2 with multiplexed streams (enabled when you set
ssl_certfile/ssl_keyfile). - Graceful shutdown — active requests finish on
SIGTERM. - Zero-downtime reload —
kill -SIGUSR1swaps in new code. - OpenTelemetry distributed tracing (configurable).
These you opt into throughAppConfigor CLI flags:
| Field | Default | What it does |
|---|---|---|
workers |
0(auto) |
Worker count;0resolves to the CPU count. |
worker_mode |
"auto" |
Pounce worker execution mode; use"async"when you register worker lifecycle hooks. |
metrics_enabled |
False |
Prometheus endpoint at/metrics. |
rate_limit_enabled |
False |
Per-IP rate limiting (token bucket). |
request_queue_enabled |
False |
Request queueing and load shedding under traffic spikes. |
sentry_dsn |
None |
Sentry error tracking. |
ssl_certfile / ssl_keyfile |
None |
TLS certificate and key (enables HTTP/2). |
For everyAppConfigfield, see Configuration.
Health and readiness probes
Chirp auto-mounts two ops endpoints — no hand-wiring, present on every app including thechirp new --minimalscaffold:
/health(liveness) — always returns plain200 ok. Wire it to a KuberneteslivenessProbe: if it stops answering, the orchestrator restarts the pod./ready(readiness) — returns503until startup completes (db connect, migrations, and every@app.on_startuphook), then runs every registered readiness check. If a check fails it returns503plus the failure list; otherwise plain200 ready. Wire it to areadinessProbeso the pod is pulled from the load-balancer rotation while it is starting up, draining on shutdown, or a dependency is down.
Both probes bypass the secure middleware stack (Session/CSRF/SecurityHeaders) and the commit teardown — they run no user handler, set no cookie, and never touch the session — and return plain200/503text outside Chirp's return-type negotiation.
Change the paths withAppConfig(health_path=..., ready_path=...) or CHIRP_HEALTH_PATH / CHIRP_READY_PATH. A user route claiming a probe path wins (the probe steps aside); app.check() flags the collision as a deploy_healthERROR so it never happens silently.
Register dependency checks before freeze:
from chirp import HealthCheck
app.add_health_check(HealthCheck("cache", check=ping_cache)) # sync or async
When a database is wired, Chirp auto-includes a Database.probe()-backed check (a SELECT 1 on a fresh pooled connection, never the request session), so /readyreflects DB connectivity for free.
A Kubernetes deployment wires them like this:
livenessProbe:
httpGet: { path: /health, port: 8000 }
readinessProbe:
httpGet: { path: /ready, port: 8000 }
Wire the secure-by-default stack
A production app with any mutating route must wireSessionMiddleware → CSRFMiddleware → SecurityHeadersMiddleware. Chirp does not inject these for you; chirp check --deploy fails when they are missing in production. Every chirp newscaffold wires them out of the box.
The full stack, ordering rules, and CSRF form patterns live on Auth Hardening — wire it before you go live.
Run behind a reverse proxy
When Chirp runs behind a proxy (nginx, a load balancer, a CDN, a platform router), the socket peer is the proxy, not the end user. The real client IP arrives in theX-Forwarded-For header. Two AppConfigfields control how that header is trusted:
| Field | Default | Effect |
|---|---|---|
trusted_proxies |
() |
Reverse-proxy peer IPs/hostnames whoseX-Forwarded-For is honored. Maps to Pounce's ServerConfig.trusted_hosts. |
forwarded_for_trusted_hops |
1 |
TrailingX-Forwarded-For hops to trust when deriving the client IP. Must be >= 1. |
config = AppConfig(
env="production",
secret_key=os.environ["CHIRP_SECRET_KEY"],
trusted_proxies=("10.0.0.1", "10.0.0.2"), # your proxy peers
forwarded_for_trusted_hops=1, # one proxy in front of the app
)
forwarded_for_trusted_hops only takes effect when the direct peer is one of your trusted_proxies. To ignore X-Forwarded-For, leave trusted_proxies empty — do not set the hop count to 0. It must be >= 1, and AppConfig raises ConfigurationErrorat construction otherwise.
Realtime workloads
SSE is Chirp's realtime contract. Pounce intentionally avoids compressingtext/event-stream responses so event delivery is not buffered behind a compression window. Use worker_mode="async"for apps with long-lived SSE connections.
Demo tier vs production tier
Some examples (notably Lucky Cat) pinworkers=1and
keep wallet, trades, notifications, and the signal bus in process memory so they
clone-and-run offline. That is a demo boundary, not a framework ceiling.
| Concern | Demo (in-memory example) | Production |
|---|---|---|
| Workers | 1 |
Nwith a shared signal backplane |
| State | In-process stores | External source of truth (DB/Redis) |
| Signal fan-out | InProcessBackplane |
Shared bus (e.g. Redis — see signal RFC §12) |
| Secret | Dev fallback indevelopment |
RequiredCHIRP_SECRET_KEY |
Multi-worker production needs both external state and a shared backplane so
/_chirp/live connections and app.emitfan-out stay coherent across processes.
See the Lucky CatDESIGN.md §7 and backplane.pyseam for the worked example.
Advanced
Worker lifecycle hooks and async workers
Use@app.on_worker_startup and @app.on_worker_shutdownfor resources that must be created inside each production worker — async HTTP clients or event-loop-bound database pools.
config = AppConfig(debug=False, workers=4, worker_mode="async")
Worker lifecycle hooks require async workers in production. On free-threaded Python, worker_mode="auto" resolves to sync workers, which do not emit worker lifecycle scopes — so Chirp rejects production launch when worker hooks are registered and the effective mode is sync. If you do not register worker hooks, worker_mode="auto"stays valid.
Worker startup failure semantics
A worker-startup exception is logged and the worker keeps serving — startup failures do not currently abort the launch. Put must-succeed, application-wide checks in@app.on_startup, and use a health check for dependencies that can fail after startup.
pounce.toml — server-native config
pounce.toml is read by pounce serve and pounce check. It is not read by app.run() or chirp run, which use AppConfigplus Chirp CLI flags.
pounce config schema --output-format toml-template # generate a starting file
pounce config schema --output-format json # for tooling
pounce config show --config pounce.toml # inspect the resolved config
pounce serve --app myapp:app --config pounce.toml
Pounce introspection endpoint
Pounce ships a server-level introspection endpoint at/_pounce/info, disabled by default and Pounce-native — Chirp does not expose an AppConfigfield for it.
If you enable it throughpounce.toml or pounce serveflags, treat it as an operations endpoint. Pounce serves it before the Chirp app and before Chirp middleware, so Chirp auth, CSRF, sessions, and allowed-host middleware do not protect it. Bind it to loopback or a private admin network and reach it through a VPN, SSH tunnel, or port-forward. Do not expose it on a public internet interface.
Access logs vs. client IP can disagree
Two values can disagree behind a proxy, by design:
- Pounce access logs key on the raw socket peer IP (the proxy).
- The per-IP rate limiter and
request.clientuse the rewrittenX-Forwarded-Forclient IP — but only when the direct peer is a trusted proxy. Withtrusted_proxiesempty, both reflect the raw peer.
Do not build audit or correlation logic that assumes the access-log peer IP and the rate-limiter/request client IP are the same value.
Free a port without blocking on TIME_WAIT
If your own CLI checks whether a port is free before callingapp.run(), use SO_REUSEADDR in that check. Otherwise the check fails while the port sits in TIME_WAIT (30–120s after shutdown) even though the server would bind successfully. The server already sets SO_REUSEADDR; match it.
import socket
def is_port_in_use(host: str, port: int) -> bool:
"""Return True if another process is actively listening on host:port."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, port))
return False
except OSError:
return True