Deployment¶
The built-in server is for development¶
app.run() starts Veloce's own HTTP server. It is convenient for local
development — one call, no extra dependency — but it is not intended
to face production traffic, and run() logs a reminder to that effect
on startup.
For production, run the app under a hardened ASGI server. Veloce is a
plain ASGI application (it implements __call__(scope, receive, send)),
so any ASGI server works:
Uvicorn (or Hypercorn, Granian, …) brings battle-tested HTTP/1.1 and WebSocket handling, worker management, graceful reloads, and TLS. The built-in server covers the development inner loop; production should not depend on it.
What the built-in server does and doesn't do¶
The development server does apply a slowloris guard — once a
request's bytes start arriving, the whole request must complete within
REQUEST_TIMEOUT seconds (default 30) or the connection is dropped with
408 — and an idle keep-alive timeout that closes a connection after
KEEP_ALIVE_TIMEOUT seconds (default 75) with no request.
Both timeouts are tunable through app.config; the values above are the
defaults (the same numbers are exposed as the HttpProtocol.REQUEST_TIMEOUT
and HttpProtocol.KEEP_ALIVE_TIMEOUT class attributes):
from veloce import Veloce
app = Veloce()
app.config["REQUEST_TIMEOUT"] = 15 # drop a half-sent request after 15s
app.config["KEEP_ALIVE_TIMEOUT"] = 30 # close an idle connection after 30s
app.config["MAX_CONCURRENT_CONNECTIONS"] = 1000 # reject beyond this (default 1000)
app.config["MAX_PIPELINED_REQUESTS"] = 64 # queue depth per connection
An over-MAX_CONTENT_LENGTH upload that announces its size up front is refused
with a 413 that runs through the app's response phase, so it carries the same
CORS and security headers a served response would — a cross-origin upload that
trips the limit reaches the client as a 413, not as an opaque CORS failure.
Refusals that happen before a request exists — a malformed request line, an over-long request line or header block, the concurrent-connection reject — are framed directly and carry no middleware headers. There is nothing to run middleware against at that point, and this matches what an ASGI server does with the same conditions.
Changed in version 0.13
The built-in server's 413 runs the response phase. It previously carried
only a Content-Type, so app.run() and VeloceWorker served a different
refusal surface than uvicorn for the same app.
A connection also stops reading its socket once MAX_PIPELINED_REQUESTS
(default 64) parsed requests are waiting to be served, and resumes as the
queue drains. Requests are throttled, never dropped: every one still gets its
response, in order. Transport flow control is otherwise driven by the request
body buffer, and a pipelined bodiless GET has no body — so without this a peer
could queue one request object per 27 bytes it wrote.
Added in version 0.13
MAX_PIPELINED_REQUESTS, and the pipelining depth bound it configures.
Concurrent-connection cap
The built-in server admits at most MAX_CONCURRENT_CONNECTIONS simultaneous
connections (default 1000) and rejects further ones with 503
immediately rather than queueing them — a deliberate memory guard. Raise it
for workloads holding many long-lived connections (for example a large
WebSocket fan-out). Uvicorn does not cap concurrency by default; under it
this setting has no effect.
It serves HTTP/1.1 and WebSocket.
- It performs the RFC 6455 upgrade handshake itself, so WebSocket routes
run under
app.run()without an ASGI server. - It does not implement HTTP/2. For HTTP/2, either serve the app under an HTTP/2-capable ASGI server such as Hypercorn, or — as most deployments do — terminate HTTP/2 at a reverse proxy (nginx, Caddy, a cloud load balancer) that forwards HTTP/1.1 to the app; uvicorn itself is HTTP/1.1-only.
- Native subprotocol negotiation is unsupported —
accept(subprotocol=...)raises on the built-in server, because the101 Switching Protocolsresponse is written beforeaccept()runs — so use an ASGI server if you need to negotiate a subprotocol.
Why no built-in HTTP/2
Not shipping a from-scratch HTTP/2 stack is a deliberate scope line: it is a large, security-sensitive binary protocol, and in practice HTTP/2 is almost always terminated at a proxy that speaks HTTP/1.1 to the app, so the app server rarely needs to implement it.
Running with multiple workers¶
uvicorn --workers N (or several app.run() processes) forks N
independent processes. They share no Python memory, which has a direct
consequence for any state Veloce keeps in-process:
| State | Shared across workers? | Notes |
|---|---|---|
Signed session cookies (SessionMiddleware) |
Yes | The session lives in the client's cookie; any worker can verify it with the shared secret. Safe across workers. |
RateLimitMiddleware buckets |
No | Each worker counts only the requests it served, so the effective limit is N × the configured value. |
g / request.state |
n/a | Per-request, never shared — correct by construction. |
app.state / app.config |
No (after fork) | Mutating app.state at runtime affects only the worker that did it. |
Veloce.mount-ed in-memory data, module globals |
No | Per process. |
Guidance:
- Sessions — signed cookies are stateless, so multi-worker is fine.
For server-side, revocable sessions use
ServerSessionMiddlewarewith aSessionStorebackend. The bundledInMemorySessionStoreis per-process, so a multi-worker deployment needs a shared store (Redis, a database) implementing theSessionStoreinterface. - Rate limiting —
RateLimitMiddlewareis in-memory and therefore per-worker. For an accurate global limit across workers, put the limiter in a reverse proxy (nginxlimit_req) or back it with Redis. - Any in-memory cache or counter — assume it is per-worker. Move anything that must be globally consistent into an external store.
A single-worker deployment behind a reverse proxy sidesteps all of the above; scale out with more workers only once shared state is externalised.
Advanced: serving under gunicorn with VeloceWorker¶
POSIX / gunicorn only
VeloceWorker is an optional, advanced alternative for stacks that
already use gunicorn for process supervision. gunicorn is POSIX-only and
is not a Veloce dependency. uvicorn remains the recommended default —
reach for this worker only when gunicorn is already part of your
deployment.
Veloce ships an optional gunicorn worker class,
veloce.workers.VeloceWorker, that lets gunicorn manage the process pool
(forking, restarts, signal handling) while each worker drives Veloce's own
HttpProtocol directly on an asyncio event loop — no uvicorn and no ASGI
shim in the request path.
Install gunicorn via the optional extra (POSIX only):
Then point gunicorn at the worker class by its import path:
gunicorn binds the listening socket in the master and hands it to each
forked worker, so all workers share one kernel accept queue — the standard
pre-fork model. The worker runs the app's startup hooks when it boots and
its shutdown hooks when gunicorn stops it, draining in-flight requests
within the configured --timeout before cancelling stragglers.
The per-worker state caveats in the table above apply unchanged: each
gunicorn worker is a separate process with its own memory, so in-memory
rate-limit buckets, caches, and app.state mutations are per-worker.
This path serves HTTP/1.1 and WebSocket through the same built-in
protocol as app.run() — for HTTP/2, put it behind a reverse proxy or
use an HTTP/2-capable ASGI server, as above. As on the built-in server,
native subprotocol negotiation is unsupported.
New — runtime-verified, not yet battle-tested at scale
VeloceWorker has been exercised end-to-end on Linux (Ubuntu 24.04,
Python 3.12, gunicorn 26.0.0): worker boot (init_process → run),
request serving, TLS termination via --certfile/--keyfile (HTTPS
served; plain HTTP to the TLS port refused — no cleartext downgrade),
--max-requests worker recycling, graceful SIGTERM shutdown with no
orphaned processes, and worker self-exit when the master is killed
(arbiter-death detection). It is still new and has not been run under
sustained production load or at multi-worker scale — load-test it for
your workload before relying on it, and note uvicorn remains the
recommended default for most deployments.
Serving over HTTP/2¶
Veloce's built-in server and uvicorn both speak HTTP/1.1 only. Because the app is ASGI-native, HTTP/2 needs no application changes — it is a serving concern, reached two ways.
Behind a reverse proxy (the common path). Terminate HTTP/2 and TLS at nginx / Caddy / a cloud load balancer, and proxy HTTP/1.1 to the app. The HTTP/2 wins (multiplexing, header compression) apply on the client-to-edge hop; the edge-to-app hop stays HTTP/1.1. An nginx server block:
server {
listen 443 ssl;
http2 on; # nginx < 1.25.1: `listen 443 ssl http2;`
server_name example.com;
ssl_certificate /etc/ssl/example.crt;
ssl_certificate_key /etc/ssl/example.key;
location / {
proxy_pass http://127.0.0.1:8000; # the app, on HTTP/1.1
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket upgrade pass-through (HTTP/1.1 only).
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Add ProxyFix so the app trusts the proxy's
X-Forwarded-* headers (correct client IP and https scheme):
from veloce import ProxyFix
# One proxy hop in front: trust the last X-Forwarded-For / -Proto entry.
app.add_middleware(ProxyFix, x_for=1, x_proto=1)
Direct HTTP/2 with Hypercorn. Hypercorn is an ASGI server that speaks HTTP/1.1, HTTP/2, and HTTP/3; it serves the same Veloce app with no code change. Browsers require HTTP/2 over TLS (negotiated via ALPN), so pass a certificate:
Over TLS, Hypercorn negotiates HTTP/2 automatically; an HTTP/1.1 client on the same port still works. WebSocket routes are served on both paths.
Extending the CLI with plugins¶
The veloce command ships with new, generate (g), run, mcp, routes, check, shell, and
custom. A distribution can add its own subcommands by advertising a
callable under the veloce.commands entry-point group — useful for
deployment helpers, database migrations, or any operational task you want
alongside the built-in commands.
In your package's pyproject.toml:
The target is handed the argparse subparsers action and adds exactly one
subcommand, setting a func default that receives the parsed arguments and
returns an exit code:
# mypkg/cli.py
def register(subparsers):
parser = subparsers.add_parser("deploy", help="Deploy the app.")
parser.add_argument("target")
parser.set_defaults(func=_deploy)
def _deploy(args):
print(f"Deploying to {args.target}")
return 0
Once the package is installed, veloce deploy prod runs your command.
Plugins cannot break the core
A plugin that fails to import, does not resolve to a callable, raises while registering, or whose name collides with a built-in (or another plugin) is reported with a warning and skipped. The built-in commands always remain usable.
Security considerations¶
Hardening checklist¶
- Pass
secure=Trueto your session middleware, and registerSecurityHeadersMiddleware(hsts_max_age=31536000), for any internet-facing deployment. - Run
veloce check your_module:appbefore deploying — it flagsDEBUG, a missingSECRET_KEY, insecure session cookies, and missing security headers. - Set
MAX_CONTENT_LENGTHso oversized uploads are refused. - Keep
debug=Falsefor anything reachable beyondlocalhost— debug tracebacks leak source and internals.
What the audit can and cannot see¶
Middleware reports on itself. The audit walks the registered middleware and asks each one, so a middleware written outside Veloce is audited on the same terms as a built-in. Severity decides what a finding does:
| severity | at startup | veloce check |
|---|---|---|
error |
refuses to serve (AuditFailed) |
exits 1 |
warning |
logged in debug | exits 1 |
info |
logged in debug | reported, exits 0 |
from veloce import Finding, Middleware
class TenantAuthMiddleware(Middleware):
def audit(self, ctx):
if not ctx.app.config.get("TENANT_SIGNING_KEY"):
yield Finding(
"TENANT_SIGNING_KEY is not set - tenant headers are unverified.",
severity="error",
fix="set TENANT_SIGNING_KEY",
id="tenant-signing-key-missing",
)
Anything adding hardening headers declares it, and satisfies the headers check
without being a SecurityHeadersMiddleware:
A session backend gets its cookie check by subclassing
SessionMiddlewareBase, which also carries the
session.permanent lifetime rule.
Checks that read the route table
veloce check imports your app without starting it, so routes registered
during startup do not exist yet. A check that reads ctx.app's routes must
set audit_needs_routes = True; it is then skipped until startup rather
than reporting a live route as missing.
Silencing an accepted finding¶
Turn off one finding by id, so the audit stays on for everything else:
A clean audit is not a proof
The audit reports on this app's middleware. Hardening the app does not own — TLS terminated at a reverse proxy, headers added by a CDN — is invisible to it, as is a middleware that reports nothing. Verify those separately.
Regex constraints and ReDoS¶
A pattern= (or regex=) constraint on Query, Path, Header,
Cookie, or Form is compiled once and then matched synchronously on
the event loop against the request value. A pattern with catastrophic
backtracking — typically nested quantifiers like (a+)+, (.*)*, or
overlapping alternations — can take super-linear time on a crafted
input, stalling the loop and every other request on that worker
(a regular-expression denial of service, ReDoS).
Guidance for developer-supplied patterns:
- Prefer simple, linear-time patterns; avoid nested quantifiers and quantified groups that can match the same text more than one way.
- Anchor patterns (
^…$) so the engine does not retry at every offset. - Pair a pattern with a
max_lengthconstraint so the input the regex runs against is bounded. - Test any non-trivial pattern against a deliberately adversarial input before shipping it.
Veloce compiles each pattern once at route registration, so the compile cost is paid up front — but the match cost is still the developer's responsibility to keep linear.