Skip to content

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-vhost access_log, be aware the http-level directive here is the default sink.
  • enableCloudflareRealIP only 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 sslCiphers string 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. includeSubdomains forces 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. preload asserts 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 the hstsHeader option to a weaker policy (e.g. "max-age=31536000"), or set enableHSTSEverywhere = false entirely — 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)
        ];
      }
    ];
  };
}