nginx-opinionated-defaults¶
Modules
A small NixOS module that layers a set of opinionated defaults onto
services.nginx, plus a few per-virtual-host knobs. It augments the stock nginx
module in place — import it and keep configuring services.nginx as usual.
The problems it solves¶
The stock log format is blind to Host. nginx's built-in combined log
format does not record the request Host header. On a box serving one vhost
that's fine; on a box serving many, every log line looks identical at the server
level and you cannot tell which site a request hit. That breaks per-vhost
analytics (goaccess), per-vhost intrusion detection (fail2ban filters keyed on
host), and plain grep. This module defines a combined_with_host format that
appends "$host" and points access_log at it.
A note on why the TLS block is hand-rolled — and the cost of that. An
earlier version of this recipe justified its own TLS block as a way to dodge
OCSP stapling that upstream recommendedTlsSettings supposedly forced on. That
premise was simply wrong: ssl_stapling appears nowhere in the nixpkgs nginx
module, so there was never anything to dodge. Worse, upstream's block had since
gained a post-quantum hybrid key-exchange group (ssl_conf_command Groups
"X25519MLKEM768:X25519:P-256:P-384";) that the hand-rolled one lacked, leaving
this recipe weaker than upstream on that axis. The block now carries the same
Groups directive, restoring parity.
We still keep our own block (rather than flipping recommendedTlsSettings back
on) so the TLS choices are a single explicit source of truth that cannot drift
underneath us — but the flip side is real: whenever upstream's
recommendedTlsSettings changes, re-diff it against
customRecommendedTlsSettings here. Parity is manual now.
Session/cipher tuning, kept as our own block. This module supplies its own
TLS session-cache/tickets/cipher-preference settings (customRecommendedTlsSettings)
instead of upstream's recommendedTlsSettings, purely to keep them declared in
one place we control rather than depending on upstream's block staying
unchanged underneath us.
HSTS should be set once, everywhere. Rather than repeat the header per
vhost, the module defines a single $scheme → $hsts_header map in the http
block, so every https response carries HSTS.
Cloudflare hides the real client IP. When traffic arrives through
Cloudflare, $remote_addr is a Cloudflare edge IP. The real client IP is in the
CF-Connecting-IP header, but nginx will only trust it from known-Cloudflare
sources. This module reads Cloudflare's officially published IP-range list and
emits the set_real_ip_from directives for you — so the trust list stays
correct as Cloudflare's ranges change, instead of being hand-copied and going
stale.
Key trap: extraSecurity is off by default on purpose¶
extraSecurity emits a baseline security-header set. One of those headers,
Cross-Origin-Embedder-Policy: require-corp, is aggressive: it breaks any page
that loads cross-origin subresources that don't send CORP/CORS headers. Real
apps hit this — Immich is a known example. So the security headers are opt-in
per vhost, and you should only enable them for vhosts you know are
self-contained.
A second trap lives in those headers: they reference $hsts_header, which is an
nginx variable. It must be defined in the http block or nginx refuses to
start. Keep enableHSTSEverywhere = true (the default) whenever any vhost uses
extraSecurity.
Usage¶
Add the module to your host's imports:
{
imports = [ ./nginx-opinionated-defaults ];
services.nginx = {
enable = true;
# Global toggles (all shown at their defaults):
# defaultTweaks = true; # gzip/optimisation/proxy on, hardened ciphers
# enableHSTSEverywhere = true; # define $hsts_header + HSTS on https
# customRecommendedTlsSettings = true; # our own TLS session/cipher/PQ-group block
# enableCloudflareRealIP = false; # global real-IP from CF-Connecting-IP
# extraLogBodies = false; # load the Lua module for body logging
virtualHosts."app.example.com" = {
# ... your usual locations / proxyPass ...
# Per-vhost knobs added by this module:
extraSecurity = true; # baseline security headers (see trap above)
proxyTimeout = "60s"; # connect/send/read timeout
clientMaxBodySize = "100m";
useCloudflareRealIP = true;
};
};
}
Wiring the Cloudflare IP-range file¶
The real-IP features (enableCloudflareRealIP globally, useCloudflareRealIP
per vhost) need Cloudflare's published range list. Add the upstream repo as a
flake input and point the module at its raw list:
# flake.nix
inputs.cloudflare-ip-ranges = {
url = "github:<the-cloudflare-ip-ranges-repo>";
flake = false;
};
# host config
services.nginx.cloudflareIPRangesFile =
inputs.cloudflare-ip-ranges + "/lists/cloudflare_ips_raw.txt";
The file is any list of CIDRs, one per line (v4 and v6 both accepted); each
becomes a set_real_ip_from directive. If you enable a real-IP feature without
setting this path, the module's assertion fails at build time rather than
silently doing nothing.
Options¶
Global (services.nginx.*):
| Option | Default | Effect |
|---|---|---|
defaultTweaks |
true |
gzip + optimisation + proxy recommended settings on; upstream recommendedTlsSettings off; hardened sslCiphers. |
enableHSTSEverywhere |
true |
Defines $hsts_header and sends HSTS on https. Required by extraSecurity. |
hstsHeader |
max-age=31536000; includeSubdomains; preload |
HSTS policy value; override to drop preload/includeSubdomains. |
customRecommendedTlsSettings |
true |
TLS session cache/tickets/cipher-preference tuning plus the X25519MLKEM768 PQ key-exchange group, kept at parity with upstream's recommendedTlsSettings. |
enableCloudflareRealIP |
false |
Global real-client-IP from CF-Connecting-IP. Needs cloudflareIPRangesFile. |
cloudflareIPRangesFile |
null |
Path to Cloudflare's CIDR list (one per line). |
securityHeaders |
baseline set | The header block extraSecurity emits; override to customise. |
extraLogBodies |
false |
Load the Lua module (for body-logging snippets). |
Per virtual host (services.nginx.virtualHosts.<name>.*):
| Option | Default | Effect |
|---|---|---|
extraSecurity |
false |
Emit securityHeaders (+ safe proxy params). Can break cross-origin apps — see trap. |
safeProxyParameters |
true |
With extraSecurity, also emit conservative proxy timeouts / body size. |
useCloudflareRealIP |
false |
Per-vhost real-IP from Cloudflare. Needs cloudflareIPRangesFile. |
proxyTimeout |
null |
Override connect/send/read timeout, e.g. "60s". |
clientMaxBodySize |
null |
Override client_max_body_size, e.g. "100m". |
Caveats¶
- The custom log format writes to
/var/log/nginx/access.log. If you also configure per-vhostaccess_log, be aware the http-level directive here is the default sink. enableCloudflareRealIPonly makes sense when traffic really does arrive through Cloudflare. Behind a different proxy, trust that proxy's ranges instead (this module doesn't do that for you).- The hardened
sslCiphersstring drops older cipher suites; ancient clients may fail to connect. That's intended.
Security notes¶
- The default HSTS header is
max-age=31536000; includeSubdomains; preload, and it is hard to undo.includeSubdomainsforces every current and future subdomain of the served domain to be HTTPS-only, so a later plain-HTTP subdomain (e.g. a legacy internal service) will be refused by any browser that saw the header.preloadasserts the domain may be added to browsers' built-in HSTS preload list; getting removed from that list takes months. If you are not ready to commit every subdomain to HTTPS forever, set thehstsHeaderoption to a weaker policy (e.g."max-age=31536000"), or setenableHSTSEverywhere = falseentirely — before the first request goes out.
Source¶
modules/nginx-opinionated-defaults/default.nix
# nginx-opinionated-defaults
#
# A NixOS module that layers opinionated defaults onto `services.nginx`, plus a
# handful of per-virtual-host knobs. Drop it into `imports` and it augments the
# stock nginx module in place (it does not replace it).
#
# What it gives you:
# * A custom access-log format that captures the `Host` header. The stock
# `combined` format omits it, which blinds any multi-vhost log analysis
# (goaccess, fail2ban, ad-hoc grep) because every request looks like it hit
# the same server.
# * HSTS on every https response, defined once as an http-block `map`.
# * Custom "recommended" TLS session/cipher settings, kept as our own block
# rather than upstream's `recommendedTlsSettings` so this module's cipher
# choices stay decoupled from upstream churn. Kept at parity with upstream's
# post-quantum key-exchange group (`X25519MLKEM768`).
# * Real-client-IP extraction from Cloudflare's officially published IP-range
# list, both globally and per-vhost.
# * Per-vhost overrides: `proxyTimeout`, `clientMaxBodySize`, `extraSecurity`
# (a baseline security-header set, off by default because the aggressive
# COEP header breaks apps that embed cross-origin resources, e.g. Immich).
#
# This module has no private wiring. The only thing you must supply from outside
# is a path to Cloudflare's IP-range file if you want the real-IP feature (see
# `cloudflareIPRangesFile` below and the README).
{ lib, config, pkgs, ... }:
let
cfg = config.services.nginx;
# Baseline security headers prepended to any vhost that opts into
# `extraSecurity`. Two traps live here:
# * `$hsts_header` is an nginx *variable*; it must be defined in the http
# block (see `enableHSTSEverywhere`) or nginx refuses to start.
# * `Cross-Origin-Embedder-Policy: require-corp` is aggressive: it breaks any
# page that loads cross-origin subresources without CORP/CORS headers.
# That is exactly why `extraSecurity` is off by default per vhost — turn it
# on only for vhosts you know are self-contained.
defaultSecurityHeaders = ''
add_header Strict-Transport-Security $hsts_header;
add_header 'Referrer-Policy' 'origin-when-cross-origin';
add_header X-Content-Type-Options nosniff;
add_header Cross-Origin-Opener-Policy "same-origin";
add_header Cross-Origin-Embedder-Policy "require-corp";
'';
# Turn Cloudflare's published IP-range list (one CIDR per line) into a block of
# `set_real_ip_from` directives plus the CF-Connecting-IP wiring. Emits nothing
# if no file was configured (guarded by an assertion below when a feature that
# needs it is enabled).
cloudflareRealIPConfig = lib.optionalString (cfg.cloudflareIPRangesFile != null) ''
${builtins.concatStringsSep "\n" (
map (ip: "set_real_ip_from ${ip};") (
lib.splitString "\n" (
lib.removeSuffix "\n" (builtins.readFile cfg.cloudflareIPRangesFile)
)
)
)}
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
'';
# Any per-vhost use of the Cloudflare real-IP feature also needs the file.
anyVhostUsesCloudflareRealIP =
lib.any (vh: vh.useCloudflareRealIP) (lib.attrValues cfg.virtualHosts);
vhostOptions =
{ config, ... }:
{
options = {
safeProxyParameters = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Add proxy parameters that are safe for internal use (only applied when extraSecurity is on).";
};
extraSecurity = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the baseline security-header set. Off by default because the aggressive COEP header can break apps that embed cross-origin resources (e.g. Immich).";
};
useCloudflareRealIP = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Extract the real client IP from Cloudflare's CF-Connecting-IP header for this vhost. Requires services.nginx.cloudflareIPRangesFile.";
};
proxyTimeout = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Override the proxy connect/send/read timeout for this vhost.";
example = "60s";
apply =
value:
if value == null then
null
else
assert lib.strings.match "^[0-9]+[smhd]?$" value != null;
value;
};
clientMaxBodySize = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Override client_max_body_size for this vhost.";
example = "100m";
apply =
value:
if value == null then
null
else
assert lib.strings.match "^[0-9]+[kmgtKMGT]?$" value != null;
value;
};
};
config = {
extraConfig = lib.mkMerge [
(lib.mkIf config.extraSecurity (
cfg.securityHeaders
+ (
if config.safeProxyParameters then
''
client_max_body_size 500m;
proxy_read_timeout 30;
proxy_connect_timeout 30;
proxy_send_timeout 30;
proxy_headers_hash_max_size 4096;
''
else
""
)
))
(lib.mkIf config.useCloudflareRealIP cloudflareRealIPConfig)
(lib.mkIf (config.proxyTimeout != null) ''
proxy_connect_timeout ${config.proxyTimeout};
proxy_send_timeout ${config.proxyTimeout};
proxy_read_timeout ${config.proxyTimeout};
'')
(lib.mkIf (config.clientMaxBodySize != null) ''
client_max_body_size ${config.clientMaxBodySize};
'')
];
};
};
in
{
options.services.nginx = {
defaultTweaks = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Apply the opinionated global defaults (gzip/optimisation/proxy on, upstream recommendedTlsSettings off in favour of customRecommendedTlsSettings, hardened ciphers).";
};
enableHSTSEverywhere = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Define the \$hsts_header map so every https response carries HSTS. Required if any vhost uses extraSecurity, which references \$hsts_header.";
};
hstsHeader = lib.mkOption {
type = lib.types.str;
default = "max-age=31536000; includeSubdomains; preload";
description = "HSTS policy value sent on https responses when enableHSTSEverywhere is on. The default is hard to undo: includeSubdomains commits every current and future subdomain to HTTPS-only, and preload asserts eligibility for browsers' built-in preload list (removal takes months). Drop those tokens here if you cannot commit to that.";
};
extraLogBodies = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Load the nginx Lua module (for request/response body logging in your own snippets). Debug aid, off by default.";
};
customRecommendedTlsSettings = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Apply this module's own TLS session/cipher settings (kept at parity with upstream recommendedTlsSettings, including its post-quantum key-exchange group) instead of upstream's recommendedTlsSettings block.";
};
enableCloudflareRealIP = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Globally extract the real client IP from Cloudflare's CF-Connecting-IP header. Requires cloudflareIPRangesFile.";
};
cloudflareIPRangesFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to Cloudflare's published IP-range list, one CIDR per line (both
v4 and v6 are accepted). Wire the upstream repo as a flake input and
point at its raw list, e.g.
`inputs.cloudflare-ip-ranges + "/lists/cloudflare_ips_raw.txt"`.
Required when any Cloudflare real-IP feature is enabled.
'';
example = lib.literalExpression ''inputs.cloudflare-ip-ranges + "/lists/cloudflare_ips_raw.txt"'';
};
securityHeaders = lib.mkOption {
type = lib.types.lines;
default = defaultSecurityHeaders;
description = "The header block emitted by a vhost's extraSecurity. Override to change or extend the baseline set.";
};
virtualHosts = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule vhostOptions);
};
};
config = {
assertions = [
{
assertion =
(cfg.enableCloudflareRealIP || anyVhostUsesCloudflareRealIP)
-> (cfg.cloudflareIPRangesFile != null);
message = "services.nginx: a Cloudflare real-IP feature is enabled but cloudflareIPRangesFile is unset.";
}
];
services.nginx = lib.mkMerge [
(lib.mkIf cfg.defaultTweaks {
recommendedGzipSettings = lib.mkDefault true;
recommendedOptimisation = lib.mkDefault true;
recommendedProxySettings = lib.mkDefault true;
# Disabled on purpose: we supply our own TLS block (customRecommendedTlsSettings)
# kept at parity with this block, decoupled from upstream churn.
recommendedTlsSettings = lib.mkDefault false;
sslCiphers = "AES256+EECDH:AES256+EDH:!aNULL";
})
{
additionalModules = lib.mkIf cfg.extraLogBodies [ pkgs.nginxModules.lua ];
commonHttpConfig = lib.mkIf cfg.enableHSTSEverywhere ''
map $scheme $hsts_header {
https "${cfg.hstsHeader}";
}
'';
appendHttpConfig = lib.mkMerge [
# mkBefore so the custom log_format is defined before anything that
# might reference it, and so our access_log wins.
(lib.mkBefore ''
log_format combined_with_host '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" "$host"';
access_log /var/log/nginx/access.log combined_with_host;
'')
''
proxy_headers_hash_max_size 4096;
proxy_headers_hash_bucket_size 1024;
''
(lib.mkIf cfg.customRecommendedTlsSettings ''
ssl_conf_command Groups "X25519MLKEM768:X25519:P-256:P-384";
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_prefer_server_ciphers off;
'')
(lib.mkIf cfg.enableCloudflareRealIP cloudflareRealIPConfig)
];
}
];
};
}