HTTP configuration
HttpConfig — host, port, TLS, CORS, the framework header — settable both via NESTRS_HTTP__* env vars and pinned in code.
Importing HttpModule::for_root(...) in AppModule.imports attaches the
HTTP transport at boot. Pass None to read every option from the
environment, or pin HttpConfig in code:
use nest_rs::core::module;use nest_rs::http::{HttpConfig, HttpModule};
use features::hello::HelloHttpModule;
#[module( imports = [ HttpModule::for_root(HttpConfig { port: 3000, ..Default::default() }), HelloHttpModule, ],)]pub struct HelloModule;use nest_rs::core::module;use nest_rs::http::{HttpConfig, HttpModule};
#[module(imports = [ HttpModule::for_root(HttpConfig { port: 3002, ..Default::default() }),])]pub struct ApiModule;Default values: host 0.0.0.0, port 3000, no TLS (plain HTTP), no CORS,
no framework Server header, a 2 MiB request-body cap, a 30-second
request timeout, fail-secure boot on, and the default security headers on.
HttpConfig fields
Section titled “HttpConfig fields”Every field of HttpConfig is settable both by env var and in the pinned
struct — the same dual-path rule every config in the framework follows.
Real env always wins over the .env cascade.
| Field | Env variable | Default | Pinned form |
|---|---|---|---|
host | NESTRS_HTTP__HOST | 0.0.0.0 | HttpConfig { host: "127.0.0.1".into(), ..Default::default() } |
port | NESTRS_HTTP__PORT | 3000 | HttpConfig { port: 3002, ..Default::default() } |
tls.cert | NESTRS_HTTP__TLS_CERT (inline PEM) or NESTRS_HTTP__TLS_CERT_FILE (path) | unset ⇒ plain HTTP | HttpConfig { tls: Some(TlsConfig::new(cert, key)), ..Default::default() } |
tls.key | NESTRS_HTTP__TLS_KEY or NESTRS_HTTP__TLS_KEY_FILE | unset ⇒ plain HTTP | (set together with tls.cert) |
cors.origins | NESTRS_HTTP__CORS_ORIGINS (comma list) | unset ⇒ CORS off | CorsConfig { origins: vec!["https://app.example.com".into()], ..Default::default() } |
cors.methods | NESTRS_HTTP__CORS_METHODS | empty | methods: vec!["GET".into(), "POST".into()] |
cors.headers | NESTRS_HTTP__CORS_HEADERS | empty | headers: vec!["Content-Type".into()] |
cors.exposed_headers | NESTRS_HTTP__CORS_EXPOSED | empty | exposed_headers: vec!["X-Total-Count".into()] |
cors.credentials | NESTRS_HTTP__CORS_CREDENTIALS (true/false) | false | credentials: true |
cors.max_age | NESTRS_HTTP__CORS_MAX_AGE (seconds) | unset | max_age: Some(Duration::from_secs(3600)) |
server_header | NESTRS_HTTP__SERVER_HEADER (true/false) | false | HttpConfig { server_header: true, ..Default::default() } |
global_prefix | NESTRS_HTTP__GLOBAL_PREFIX | unset ⇒ no prefix | HttpConfig::default().with_global_prefix("/api") |
max_body_bytes | NESTRS_HTTP__MAX_BODY_BYTES (bytes) | 2 MiB | HttpConfig::default().with_max_body_bytes(4 * 1024 * 1024) |
request_timeout | NESTRS_HTTP__REQUEST_TIMEOUT_SECS (seconds, 0 ⇒ off) | 30 s | HttpConfig { request_timeout: Some(Duration::from_secs(15)), ..Default::default() } |
fail_secure_strict | NESTRS_HTTP__FAIL_SECURE_STRICT (true/false) | true | HttpConfig { fail_secure_strict: false, ..Default::default() } |
security_headers | NESTRS_HTTP__SECURITY_HEADERS (master), __CONTENT_TYPE_OPTIONS, __FRAME_OPTIONS, __HSTS, __REFERRER_POLICY, __CROSS_ORIGIN_OPENER_POLICY, __CROSS_ORIGIN_RESOURCE_POLICY, __CROSS_ORIGIN_EMBEDDER_POLICY, __PERMISSIONS_POLICY, __CONTENT_SECURITY_POLICY | on (safe values) | HttpConfig { security_headers: SecurityHeadersConfig { enabled: false, ..Default::default() }, ..Default::default() } |
compression | NESTRS_HTTP__COMPRESSION (true/false) | false | HttpConfig { compression: true, ..Default::default() } |
See Compression for what the flag negotiates and when to leave it off.
Serve HTTPS
Section titled “Serve HTTPS”Setting both NESTRS_HTTP__TLS_CERT[_FILE] and NESTRS_HTTP__TLS_KEY[_FILE]
makes the transport serve over rustls (through
poem’s listener) instead of plain HTTP. Setting only one of the pair fails
the boot — a half-configured TLS is a deployment mistake, not a silent
fall back to plaintext.
# Inline (suits k8s secrets, systemd EnvironmentFile, …)NESTRS_HTTP__TLS_CERT="$(cat fullchain.pem)" \NESTRS_HTTP__TLS_KEY="$(cat privkey.pem)" \nestrs run dev api
# Or by path — the transport reads the pair and keeps watching itNESTRS_HTTP__TLS_CERT_FILE=/etc/letsencrypt/.../fullchain.pem \NESTRS_HTTP__TLS_KEY_FILE=/etc/letsencrypt/.../privkey.pem \nestrs run dev apiRenewal without a restart
Section titled “Renewal without a restart”The two forms differ in one way that matters in production: material read
from files is watched. Every NESTRS_HTTP__TLS_RELOAD_SECS seconds (60 by
default, 0 to turn it off) the pair is re-read, and a renewed pair is swapped
into the running rustls config. The listener is not rebuilt — the port stays
bound, in-flight connections finish, and certbot’s --deploy-hook needs no
systemctl restart.
Two things it deliberately will not do:
- A pair it cannot read leaves the current certificate serving, with a
warnonnest_rs::http. A renewal tool that unlinks before it writes would otherwise take the listener down between two syscalls. - A pair is installed only once it reads back identical twice, so a file caught mid-flush is never the one that gets served.
That second rule has a limit worth knowing, because polling cannot see past it:
a renewal that writes the certificate and its key more than one interval
apart looks settled in between, and that pair is installed. Certbot and
cert-manager both swap atomically (a new lineage directory plus a symlink, a
remounted Secret), so this does not arise with either — but a hand-rolled hook
that writes the two files in sequence should write them together, or set
NESTRS_HTTP__TLS_RELOAD_SECS longer than the gap.
Inline PEM has no source to watch, so it is loaded once, as before.
Pinning TLS material in code uses TlsConfig::new — rarely useful outside
tests (production deploys carry secrets in the environment):
use nest_rs::core::module;use nest_rs::http::{HttpConfig, HttpModule, TlsConfig};
let cert = std::fs::read("fullchain.pem")?;let key = std::fs::read("privkey.pem")?;
#[module(imports = [ HttpModule::for_root(HttpConfig { port: 3002, tls: Some(TlsConfig::new(cert, key)), ..Default::default() }),])]pub struct ApiModule;CORS uses poem’s Cors middleware
under the hood. The transport installs it outermost, so a preflight
(OPTIONS) is answered before any guard or interceptor runs.
CORS activates only when cors.origins is non-empty — the default is no
CORS layer. Set the origins (and any other knob you need) via either path:
NESTRS_HTTP__CORS_ORIGINS=https://app.example.com,https://admin.example.comNESTRS_HTTP__CORS_METHODS=GET,POST,PUT,DELETENESTRS_HTTP__CORS_HEADERS=Content-Type,AuthorizationNESTRS_HTTP__CORS_CREDENTIALS=trueNESTRS_HTTP__CORS_MAX_AGE=3600use std::time::Duration;
use nest_rs::core::module;use nest_rs::http::{CorsConfig, HttpConfig, HttpModule};
#[module(imports = [ HttpModule::for_root(HttpConfig { port: 3002, cors: Some(CorsConfig { origins: vec!["https://app.example.com".into()], methods: vec!["GET".into(), "POST".into()], headers: vec!["Content-Type".into(), "Authorization".into()], credentials: true, max_age: Some(Duration::from_secs(3600)), ..Default::default() }), ..Default::default() }),])]pub struct ApiModule;origins: vec!["*".into()] is allowed for fully open APIs (the wildcard
is passed straight through to poem).
Mounting under a shared prefix
Section titled “Mounting under a shared prefix”Behind a reverse proxy that hands off a sub-path (/api/*), every
controller can be mounted under one prefix without touching path = "…"
on each. Like every other field it follows the dual path — set it in the
environment:
NESTRS_HTTP__GLOBAL_PREFIX=/apior pin it in code with with_global_prefix:
use nest_rs::core::module;use nest_rs::http::{HttpConfig, HttpModule};
#[module(imports = [ HttpModule::for_root(HttpConfig::default().with_global_prefix("/api")),])]pub struct ApiModule;The prefix is normalized ("api", "/api", "/api/" all yield
Some("/api"); empty / "/" collapse to no-op), then prepended to every
route at mount time — #[get("/users")] ends up at GET /api/users. The
boot log and the OpenAPI document reflect the prefix.
Framework Server: header
Section titled “Framework Server: header”Off by default — a production-safe choice: no fingerprint of the
framework or its version is exposed. Flip on for local development to see
Server: nestrs/<crate version> on every response (the same shape Apache
and nginx use):
NESTRS_HTTP__SERVER_HEADER=trueHttpModule::for_root(HttpConfig { server_header: true, ..Default::default()})The value is sourced from the nest-rs-http crate’s CARGO_PKG_VERSION at
build time — it tracks the framework, not your app version.
Request-body size limit
Section titled “Request-body size limit”RawBody (and every extractor built on it) accepts at most
max_body_bytes — 2 MiB by default, so a runaway upload can’t exhaust
memory before a handler ever sees it. Raise or lower it globally:
NESTRS_HTTP__MAX_BODY_BYTES=4194304 # 4 MiBA single route that needs a different cap overrides it per call with
RawBody::extract_with_limit; the config value is the default for
everything else.
Request timeout
Section titled “Request timeout”request_timeout bounds how long one request may run, so a slow or stuck
request can’t tie up a connection indefinitely. Default 30 seconds;
NESTRS_HTTP__REQUEST_TIMEOUT_SECS=0 turns it off — the framework-wide
spelling every duration ceiling uses, so 0 never means “zero seconds”:
NESTRS_HTTP__REQUEST_TIMEOUT_SECS=15NESTRS_HTTP__REQUEST_TIMEOUT_SECS=0 # no timeout at allA handler that exceeds it is aborted and the client gets 503 Service Unavailable with a Retry-After naming the budget — not 504. RFC 9110
§15.6.5 scopes 504 Gateway Timeout to a server “acting as a gateway or
proxy” whose upstream was slow; this transport is the origin, and the
handler that overran is its own work. The distinction is not cosmetic: a
client SDK or a load balancer that retries a 504 on another node is acting
on “that node’s upstream is flaky”, and re-runs a handler that will overrun
again. §15.6.4 is the origin’s own sentence — explicitly temporary, and
paired with Retry-After.
Default security headers
Section titled “Default security headers”On by default — a freshly-scaffolded app ships safe headers without having to remember them:
X-Content-Type-Options: nosniff— defeats MIME sniffing.X-Frame-Options: DENY— no framing (clickjacking).Referrer-Policy: strict-origin-when-cross-origin— a cross-origin request carries the origin, never the path or the query. It matters here and not only in theory:nest-rs-socialredirects through URLs carryingstate, and the Swagger UI atGET /apiis a real page whose outbound links would otherwise name the API paths a reader was browsing.Cross-Origin-Opener-Policy: same-origin— a document this server serves keeps nowindowreference back to a cross-origin opener.Cross-Origin-Resource-Policy: same-origin— a no-cors cross-origin load of a response from this server is blocked. It does not touch CORS requests, which is why an API can carry it; setcross-originon a server whose images or downloads are meant to be embedded elsewhere.Strict-Transport-Security: max-age=31536000; includeSubDomains— emitted only when TLS is active (HSTS over plain HTTP is meaningless and a footgun on localhost).
Three more are settable and off by default, each because a default would be the framework guessing about a document it does not serve:
| Header | Field / env | Why it ships off |
|---|---|---|
Content-Security-Policy | content_security_policy / __CONTENT_SECURITY_POLICY | A policy strict enough to be worth having (default-src 'none') breaks the Swagger UI and every page an app serves; one loose enough to ship safely states nothing. The sources are the app’s. |
Cross-Origin-Embedder-Policy | cross_origin_embedder_policy / __CROSS_ORIGIN_EMBEDDER_POLICY | require-corp is how a page opts into cross-origin isolation, and it breaks every cross-origin subresource that does not carry CORP. |
Permissions-Policy | permissions_policy / __PERMISSIONS_POLICY | It governs camera / geolocation / payment in a document; a restrictive guess silently disables a feature the app’s own front end asked for. |
Every value is tunable through the dual path. Disable the whole set with the master switch, or drop one header by setting its value to an empty string:
NESTRS_HTTP__SECURITY_HEADERS=false # all offNESTRS_HTTP__FRAME_OPTIONS=SAMEORIGIN # override one valueNESTRS_HTTP__HSTS= # empty ⇒ drop just HSTSNESTRS_HTTP__CONTENT_SECURITY_POLICY="default-src 'self'"use nest_rs::http::{HttpConfig, HttpModule, SecurityHeadersConfig};
HttpModule::for_root(HttpConfig { security_headers: SecurityHeadersConfig { frame_options: Some("SAMEORIGIN".into()), ..Default::default() }, ..Default::default()});Fail-secure boot
Section titled “Fail-secure boot”fail_secure_strict is true by default: when global guards are
registered and an endpoint the transport can’t shape (an imperative
mount(...)) would bypass the guard pool, the boot fails naming the
offending mount rather than silently serving it unguarded. Setting it to
false downgrades that failure to a warn — a deliberate opt-out, not
the default:
An imperative mount is HttpTransport::mount(path, |container| …):
a raw poem endpoint handed to the transport, which can neither shape it
(no #[routes] to run the guard pool through) nor introspect it (no
EdgePosture to read). Writing an application route is not how you get
one — #[controller] covers that, and a capability contributing its own
surface (GraphQL, WebSockets, MCP, OpenAPI) self-mounts with a posture.
It is the escape hatch for a hand-built endpoint, and the check exists
because that hatch is the only hole a global guard pool cannot reach.
The boot error names the path:
Error: fail-secure: imperative mount(...) endpoints bypass the global guardpool: /raw — route them through a #[controller], guard them explicitly, oropt out with HttpTransport::fail_secure_strict(false) /NESTRS_HTTP__FAIL_SECURE_STRICT=falseNESTRS_HTTP__FAIL_SECURE_STRICT=falseGoing further
Section titled “Going further”- Controllers & routes — write the handlers this transport serves.
- Configuration — the framework-wide
NESTRS_<NS>__*scheme and the.envcascade.