Skip to content

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:

apps/hello/src/module.rs
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;
apps/api/src/module.rs (from the demo)
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.

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.

FieldEnv variableDefaultPinned form
hostNESTRS_HTTP__HOST0.0.0.0HttpConfig { host: "127.0.0.1".into(), ..Default::default() }
portNESTRS_HTTP__PORT3000HttpConfig { port: 3002, ..Default::default() }
tls.certNESTRS_HTTP__TLS_CERT (inline PEM) or NESTRS_HTTP__TLS_CERT_FILE (path)unset ⇒ plain HTTPHttpConfig { tls: Some(TlsConfig::new(cert, key)), ..Default::default() }
tls.keyNESTRS_HTTP__TLS_KEY or NESTRS_HTTP__TLS_KEY_FILEunset ⇒ plain HTTP(set together with tls.cert)
cors.originsNESTRS_HTTP__CORS_ORIGINS (comma list)unset ⇒ CORS offCorsConfig { origins: vec!["https://app.example.com".into()], ..Default::default() }
cors.methodsNESTRS_HTTP__CORS_METHODSemptymethods: vec!["GET".into(), "POST".into()]
cors.headersNESTRS_HTTP__CORS_HEADERSemptyheaders: vec!["Content-Type".into()]
cors.exposed_headersNESTRS_HTTP__CORS_EXPOSEDemptyexposed_headers: vec!["X-Total-Count".into()]
cors.credentialsNESTRS_HTTP__CORS_CREDENTIALS (true/false)falsecredentials: true
cors.max_ageNESTRS_HTTP__CORS_MAX_AGE (seconds)unsetmax_age: Some(Duration::from_secs(3600))
server_headerNESTRS_HTTP__SERVER_HEADER (true/false)falseHttpConfig { server_header: true, ..Default::default() }
global_prefixNESTRS_HTTP__GLOBAL_PREFIXunset ⇒ no prefixHttpConfig::default().with_global_prefix("/api")
max_body_bytesNESTRS_HTTP__MAX_BODY_BYTES (bytes)2 MiBHttpConfig::default().with_max_body_bytes(4 * 1024 * 1024)
request_timeoutNESTRS_HTTP__REQUEST_TIMEOUT_SECS (seconds, 0 ⇒ off)30 sHttpConfig { request_timeout: Some(Duration::from_secs(15)), ..Default::default() }
fail_secure_strictNESTRS_HTTP__FAIL_SECURE_STRICT (true/false)trueHttpConfig { fail_secure_strict: false, ..Default::default() }
security_headersNESTRS_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_POLICYon (safe values)HttpConfig { security_headers: SecurityHeadersConfig { enabled: false, ..Default::default() }, ..Default::default() }
compressionNESTRS_HTTP__COMPRESSION (true/false)falseHttpConfig { compression: true, ..Default::default() }

See Compression for what the flag negotiates and when to leave it off.

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.

Terminal window
# 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 it
NESTRS_HTTP__TLS_CERT_FILE=/etc/letsencrypt/.../fullchain.pem \
NESTRS_HTTP__TLS_KEY_FILE=/etc/letsencrypt/.../privkey.pem \
nestrs run dev api

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 warn on nest_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):

apps/api/src/module.rs
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:

.env.production
NESTRS_HTTP__CORS_ORIGINS=https://app.example.com,https://admin.example.com
NESTRS_HTTP__CORS_METHODS=GET,POST,PUT,DELETE
NESTRS_HTTP__CORS_HEADERS=Content-Type,Authorization
NESTRS_HTTP__CORS_CREDENTIALS=true
NESTRS_HTTP__CORS_MAX_AGE=3600
apps/api/src/module.rs (from the demo)
use 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).

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:

.env.production
NESTRS_HTTP__GLOBAL_PREFIX=/api

or pin it in code with with_global_prefix:

apps/api/src/module.rs (from the demo)
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.

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):

.env.development
NESTRS_HTTP__SERVER_HEADER=true
apps/api/src/module.rs
HttpModule::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.

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:

.env.production
NESTRS_HTTP__MAX_BODY_BYTES=4194304 # 4 MiB

A 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 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”:

.env.production
NESTRS_HTTP__REQUEST_TIMEOUT_SECS=15
NESTRS_HTTP__REQUEST_TIMEOUT_SECS=0 # no timeout at all

A 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.

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-social redirects through URLs carrying state, and the Swagger UI at GET /api is 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 no window reference 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; set cross-origin on 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:

HeaderField / envWhy it ships off
Content-Security-Policycontent_security_policy / __CONTENT_SECURITY_POLICYA 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-Policycross_origin_embedder_policy / __CROSS_ORIGIN_EMBEDDER_POLICYrequire-corp is how a page opts into cross-origin isolation, and it breaks every cross-origin subresource that does not carry CORP.
Permissions-Policypermissions_policy / __PERMISSIONS_POLICYIt 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:

.env
NESTRS_HTTP__SECURITY_HEADERS=false # all off
NESTRS_HTTP__FRAME_OPTIONS=SAMEORIGIN # override one value
NESTRS_HTTP__HSTS= # empty ⇒ drop just HSTS
NESTRS_HTTP__CONTENT_SECURITY_POLICY="default-src 'self'"
apps/api/src/module.rs
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_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:

Terminal window
Error: fail-secure: imperative mount(...) endpoints bypass the global guard
pool: /raw — route them through a #[controller], guard them explicitly, or
opt out with HttpTransport::fail_secure_strict(false) /
NESTRS_HTTP__FAIL_SECURE_STRICT=false
.env
NESTRS_HTTP__FAIL_SECURE_STRICT=false