Nginx: Difference between revisions

From NixOS Wiki
imported>Ledettwy
Add entry for serving static files from /nix/store over nginx and having caching work correctly
It was missing the "virtualHosts.localhost" part, making the config file invalid.
 
(50 intermediate revisions by 31 users not shown)
Line 1: Line 1:
Nginx is a lightweight webserver. Configuration is handled using the {{nixos:option|services.nginx.}} options.
[https://nginx.org/ {{PAGENAME}}] ([[wikipedia:en:{{PAGENAME}}]]) is a lightweight webserver. Configuration is handled using the {{nixos:option|services.nginx.}} options.


== Let's Encrypt certificates ==
== Sample setups ==


The nginx module for NixOS has native support for Let's encrypt certificates;  {{nixos:option|services.nginx.+acme}}. The {{manual:nixos|sec=#module-security-acme|chapter=Chapter 20. SSL/TLS Certificates with ACME}} explains it in detail.
==== Minimal raw html dummy page for testing <code>configuration.nix</code> ====


=== Troubleshooting ===
<syntaxhighlight lang="nix">
services.nginx = {
  enable = true;
  virtualHosts.localhost = {
    locations."/" = {
      return = "200 '<html><body>It works</body></html>'";
      extraConfig = ''
        default_type text/html;
      '';
    };
  };
};


==== Rate limiting ====
</syntaxhighlight>


The ACME server for Let's encrypt has rate limits. There is a known issue<ref>{{issue|38144}}</ref> with how NixOS handles automatic certificate generation wherein it is trivial to hit the limits when enabling multiple domains or sub-domains at once.
==== Static blog with ssl enforced in <code>configuration.nix</code> ====
 
When hitting the limit, the logs will show as follows:
 
<pre>
Mar 30 14:07:38 HOSTNAME systemd[1]: Failed to start Renew ACME Certificate for example.com.
...
Mar 30 14:08:10 HOSTNAME acme-example.com-start[25915]: 2018-03-30 18:08:10,566:DEBUG:acme.client:540: JWS payload:
Mar 30 14:08:10 HOSTNAME acme-example.com-start[25915]: {
Mar 30 14:08:10 HOSTNAME acme-example.com-start[25915]:  "resource": "new-reg"
Mar 30 14:08:10 HOSTNAME acme-example.com-start[25915]: }
...
Mar 30 14:08:10 HOSTNAME acme-example.com-start[25915]: Connection: close
Mar 30 14:08:10 HOSTNAME acme-example.com-start[25915]: {
Mar 30 14:08:10 HOSTNAME acme-example.com-start[25915]:  "type": "urn:acme:error:rateLimited",
Mar 30 14:08:10 HOSTNAME acme-example.com-start[25915]:  "detail": "Error creating new registration :: too many registrations for this IP: see https://letsencrypt.org/docs/rate-limits/",
Mar 30 14:08:10 HOSTNAME acme-example.com-start[25915]:  "status": 429
Mar 30 14:08:10 HOSTNAME acme-example.com-start[25915]: }
</pre>
 
See {{issue|38144}} for the current status.
 
== Sample setups ==
 
Static blog with ssl enforced in <code>configuration.nix</code>


<syntaxhighlight lang="nix">
<syntaxhighlight lang="nix">
Line 51: Line 38:
</syntaxhighlight>
</syntaxhighlight>


LEMP stack (Nginx/MySQL/PHP) in <code>configuration.nix</code>
==== LEMP stack ====
 
(Nginx/MySQL/PHP) in <code>configuration.nix</code>


<syntaxhighlight lang="nix">
<syntaxhighlight lang="nix">
{ config, ...}: {
services.nginx = {
services.nginx = {
   enable = true;
   enable = true;
Line 60: Line 50:
     forceSSL = true;
     forceSSL = true;
     root = "/var/www/blog";
     root = "/var/www/blog";
     locations."~ \.php$".extraConfig = ''
     locations."~ \\.php$".extraConfig = ''
       fastcgi_pass 127.0.0.1:9000;
       fastcgi_pass unix:${config.services.phpfpm.pools.mypool.socket};
       fastcgi_index index.php;
       fastcgi_index index.php;
     '';
     '';
Line 70: Line 60:
   package = pkgs.mariadb;
   package = pkgs.mariadb;
};
};
services.phpfpm.poolConfigs.mypool = ''
services.phpfpm.pools.mypool = {                                                                                                                                                                                                           
   listen = 127.0.0.1:9000
   user = "nobody";                                                                                                                                                                                                                         
   user = nobody
   settings = {                                                                                                                                                                                                                             
  pm = dynamic
    "pm" = "dynamic";           
  pm.max_children = 5
    "listen.owner" = config.services.nginx.user;                                                                                                                                                                                                             
  pm.start_servers = 2  
    "pm.max_children" = 5;                                                                                                                                                                                                                 
  pm.min_spare_servers = 1  
    "pm.start_servers" = 2;                                                                                                                                                                                                                 
  pm.max_spare_servers = 3
    "pm.min_spare_servers" = 1;                                                                                                                                                                                                             
  pm.max_requests = 500
    "pm.max_spare_servers" = 3;                                                                                                                                                                                                             
'';
    "pm.max_requests" = 500;                                                                                                                                                                                                               
  };                                                                                                                                                                                                                                       
};
</syntaxhighlight>
 
'''Robots.txt'''
 
If you want to set a robots.txt for your domain (or any subdomains), add this:
 
<syntaxhighlight lang="nix">
locations."/robots.txt" = {
  extraConfig = ''
    rewrite ^/(.*)  $1;
    return 200 "User-agent: *\nDisallow: /";
  '';
};
 
</syntaxhighlight>
</syntaxhighlight>


==== HTTP Authentication ====


Hardened setup with TLS and HSTS preloading:
===== Basic Authentication =====
 
Nginx can require users to login using HTTP Basic Authentication. In NixOS, this is set using the `basicAuth` option:
 
<syntaxhighlight lang="nix">
services.nginx = {
    virtualHosts."example.com" =  {
      basicAuth = { user = "password"; anotherUser = "..."; };
      ...
    };
};
</syntaxhighlight>
 
===== Authentication via PAM =====
 
It is also possible to authenticate system users, e.g. users in the /etc/passwd file, by using the PAM module.
 
<syntaxhighlight lang="nix">
 
  security.pam.services.nginx.setEnvironment = false;
  systemd.services.nginx.serviceConfig = {
    SupplementaryGroups = [ "shadow" ];
  };
 
  services.nginx = {
    enable = true;
    additionalModules = [ pkgs.nginxModules.pam ];
    ...
    virtualHosts."example.com".extraConfig = ''
      auth_pam  "Password Required";
      auth_pam_service_name "nginx";
    '';
    ...
    };
  };
 
</syntaxhighlight>
 
==== TLS reverse proxy ====
 
This is a "minimal" example in terms of security, see below for more tips.
 
<syntaxhighlight lang="nix">
services.nginx = {
    enable = true;
    recommendedProxySettings = true;
    recommendedTlsSettings = true;
    # other Nginx options
    virtualHosts."example.com" =  {
      enableACME = true;
      forceSSL = true;
      locations."/" = {
        proxyPass = "http://127.0.0.1:12345";
        proxyWebsockets = true; # needed if you need to use WebSocket
        extraConfig =
          # required when the target is also TLS server with multiple hosts
          "proxy_ssl_server_name on;" +
          # required when the server wants to use HTTP Authentication
          "proxy_pass_header Authorization;"
          ;
      };
    };
};
</syntaxhighlight>
{{Note|ACME won't be able to authenticate your domain if ports 80 & 443 aren't open in your firewall.}}
 
==== Hardened setup with TLS and HSTS preloading ====


For testing your TLS configuration, you might want to visit [https://www.ssllabs.com/ssltest/index.html].
For testing your TLS configuration, you might want to visit [https://www.ssllabs.com/ssltest/index.html].
Line 102: Line 175:
     sslCiphers = "AES256+EECDH:AES256+EDH:!aNULL";
     sslCiphers = "AES256+EECDH:AES256+EDH:!aNULL";
      
      
     commonHttpConfig = ''
     appendHttpConfig = ''
       # Add HSTS header with preloading to HTTPS requests.
       # Add HSTS header with preloading to HTTPS requests.
       # Adding this header to HTTP requests is discouraged
       # Adding this header to HTTP requests is discouraged
Line 121: Line 194:
       # Prevent injection of code in other mime types (XSS Attacks)
       # Prevent injection of code in other mime types (XSS Attacks)
       add_header X-Content-Type-Options nosniff;
       add_header X-Content-Type-Options nosniff;
      # Enable XSS protection of the browser.
      # May be unnecessary when CSP is configured properly (see above)
      add_header X-XSS-Protection "1; mode=block";


       # This might create errors
       # This might create errors
Line 133: Line 202:
     virtualHosts = let
     virtualHosts = let
       base = locations: {
       base = locations: {
      inherit locations;
        inherit locations;
     
 
      forceSSL = true;
        forceSSL = true;
      enableACME = true;
        enableACME = true;
    };
      };
    proxy = port: base {
      proxy = port: base {
      "/".proxyPass = "http://127.0.0.1:" + toString(port) + "/";
        "/".proxyPass = "http://127.0.0.1:" + toString(port) + "/";
    };
      };
     in {
     in {
       # Define example.com as reverse-proxied service on 127.0.0.1:3000
       # Define example.com as reverse-proxied service on 127.0.0.1:3000
Line 148: Line 217:
</syntaxhighlight>
</syntaxhighlight>


<hr />
==== Using realIP when behind CloudFlare or other CDN ====


== Correct Caching when Serving Static Files from /nix/store ==
When Nginx is behind another proxy it won't know the true IP address of clients hitting it. It will then pass down those the proxy's IP address instead of the client IP address. By using the nginx realip module, we can ensure nginx knows the real client IP, and we can further inform nginx to only trust the HTTP header from valid upstream proxies.


Since the dates for all files in /nix/store are set to 1 second after the unix epoch, attempting to serve them over nginx can result in caching issues. etags can be used to resolve this, but nginx's built-in etags depend on the file modification time and size, which isn't good enough for us. To have caching work reliably, we'll construct our own etag:
In the following example, we are fetching the list of IPs directly from cloudflare and including a hash. This has some pros and cons. Nix will not attempt to download or update that file while it is in a nix store it trusts, but after a nix garbage collection, it will error if the list of proxies has changed informing you of that when you apply the config.


<syntaxhighlight lang="nix">
<syntaxhighlight lang="nix">
locations."/my/static/file.txt" =
  services.nginx.commonHttpConfig =
   let file = pkgs.writeText "file.txt" "this is a static file!";
    let
  in  
      realIpsFromList = lib.strings.concatMapStringsSep "\n" (x: "set_real_ip_from  ${x};");
   { alias = file;
      fileToList = x: lib.strings.splitString "\n" (builtins.readFile x);
     extraConfig = ''
      cfipv4 = fileToList (pkgs.fetchurl {
       etag off;
        url = "https://www.cloudflare.com/ips-v4";
      add_header etag "\"${builtins.substring 11 32 file.outPath}\"";
        sha256 = "0ywy9sg7spafi3gm9q5wb59lbiq0swvf0q3iazl0maq1pj1nsb7h";
      '';
      });
  }
      cfipv6 = fileToList (pkgs.fetchurl {
        url = "https://www.cloudflare.com/ips-v6";
        sha256 = "1ad09hijignj6zlqvdjxv7rjj8567z357zfavv201b9vx3ikk7cy";
      });
    in
    ''
      ${realIpsFromList cfipv4}
      ${realIpsFromList cfipv6}
      real_ip_header CF-Connecting-IP;
    '';
</syntaxhighlight>
 
==== UNIX socket reverse proxy ====
 
In order for nginx to be able to access UNIX sockets, you have to do some permission modifications.
 
<syntaxhighlight lang="nix">
# Example service that supports listening to UNIX sockets
services.hedgedoc = {
  enable = true;
  settings.path = "/run/hedgedoc/hedgedoc.sock"
};
 
services.nginx = {
  enable = true;
  virtualHosts."example.com" = {
    enableACME = true;
    forceSSL = true;
    locations."/".proxyPass = "http://unix:/run/hedgedoc/hedgedoc.sock";
  };
};
 
# This is needed for nginx to be able to read other processes
# directories in `/run`. Else it will fail with (13: Permission denied)
systemd.services.nginx.serviceConfig.ProtectHome = false;
 
# Most services will create sockets with 660 permissions.
# This means you have to add nginx to their group.
users.groups.hedgedoc.members = [ "nginx" ];
 
# Alternatively, you can try to force the unit to create the socket with
# different permissions, if you have a reason for not wanting to add nginx
# to their group. This might not work, depending on how the program sets
# its permissions for the socket.
systemd.services.hedgedoc.serviceConfig.UMask = "0000";
</syntaxhighlight>
== Let's Encrypt certificates ==
 
The nginx module for NixOS has native support for Let's Encrypt certificates;  {{nixos:option|services.nginx.+acme}}. The {{manual:nixos|sec=#module-security-acme-nginx|chapter=Chapter 20. SSL/TLS Certificates with ACME}} explains it in detail.
 
=== Minimal Internet Exposed Server Example ===
 
Assuming that <code>myhost.org</code> resolves to the IP address of your host and port 80 and 443 have been opened.
<syntaxhighlight lang="nix">
services.nginx.enable = true;
services.nginx.virtualHosts."myhost.org" = {
    addSSL = true;
    enableACME = true;
    root = "/var/www/myhost.org";
};
security.acme = {
   acceptTerms = true;
  defaults.email = "foo@bar.com";
};
</syntaxhighlight>
This will set up nginx to serve files for <code>myhost.org</code>, automatically request an ACME SSL Certificate using a "'''HTTP-01'''" challenge (meaning your server must be exposed to the internet) and will configure systemd timers to renew the certificate if required.
 
=== Minimal Private Local LAN Server Example ===
 
We can also have a private server running in our local network (including VPN), that isn't reachable from the internet, but that still can get valid Let's Encrypt certificates that are accepted in a browser.
 
1. We have to '''modify DNS such that our domain''' like <code>myhost.org</code> '''resolves to the local IP address of our private server''' and port 80 and 443 have been opened. [https://www.youtube.com/watch?v=qlcVx-k-02E See this video tutorial] for an example on how to do that. Hint: You might need to '''add an exception to your router''' (definitely on Fritzboxes), because resolving to local IP address is usually blocked to prevent '''"DNS rebind attacks"'''.
 
2. We have to setup the Let's Encrypt NixOS ACME services such that it uses an '''API token in a secrets file''' ([https://github.com/ryantm/agenix secrets for a server can be conveniently and securely deployed in NixOS with agenix]; just follow the tutorial) against our DNS provider to '''prove from our server that we own the domain'''. This way our server doesn't need to be exposed and reachable from the internet. NixOS ACME uses the [https://go-acme.github.io/lego/ LEGO library] to communicate to DNS providers (it supports a lot) and therefore we have to provide the token(s) in that library's secrets file format.
 
In the example we use Hetzner as our "dnsProvider" that only needs a single API token environment in our secrets file:
<syntaxhighlight lang="nix">
HETZNER_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
</syntaxhighlight>Other [https://carjorvaz.com/posts/setting-up-wildcard-lets-encrypt-certificates-on-nixos/ DNS providers need like OVH] require more environment variables.
 
See the section "'''Credentials'''" on what you have to specify in the secrets file: https://go-acme.github.io/lego/dns/hetzner/
 
See Hetzner guide on how to get an API token for its "DNS console": https://docs.hetzner.com/dns-console/dns/general/api-access-token/
 
3. Point our virtualHost to the ACME entry.
<syntaxhighlight lang="nix">
{
   services.nginx = {
    enable = true;
     # we can use the main domain or any subdomain that's mentioned by
    # "extraDomainNames" in the acme certificate.
    virtualHosts."subdomain.example.org" = {
      # 3. Instead of "enableACME = true;" we
      # reuse the certificate from "security.acme.certs."example.org"
      # down below
      useACMEHost = "example.org";
      forceSSL = true;
      locations."/" = {
        return = "200 '<html><body>It works</body></html>'";
        extraConfig = ''
          default_type text/html;
        '';
       };
    };
  };
 
  security.acme.acceptTerms = true;
  security.acme.defaults.email = "info@example.org";
 
  # 2. Let NixOS generate a Let's Encrypt certificate that we can reuse
  # above for several virtualhosts above.
  security.acme.certs."example.org" = {
    domain = "example.org";
    extraDomainNames = [ "subdomain.example.org" ];
    # The LEGO DNS provider name. Depending on the provider, need different
    # contents in the credentialsFile below.
    dnsProvider = "hetzner";
    dnsPropagationCheck = true;
    # agenix will decrypt our secrets file (below) on the server and make it available
    # under /run/agenix/secrets/hetzner-dns-token (by default):
    # credentialsFile = "/run/agenix/secrets/hetzner-dns-token";
    credentialsFile = config.age.secrets."hetzner-dns-token.age".path;
  };
 
  # Let agenix know about and copy our (encrypted) DNS API token secrets file
  # (containing "HETZNER_API_KEY=...") to the server an decrypt it there.
  # Follow the agenix tutorial on how to encrypt a secrets file
  # to a .age file and how to setup your Nix flake to use it.
  age.secrets."hetzner-dns-token.age".file = .../hetzner-dns-token.age;
 
  users.users.nginx.extraGroups = [ "acme" ];
}
 
</syntaxhighlight>
This will set up nginx to serve files for example.org, automatically request an ACME SSL Certificate using a "DNS-01" challenge (meaning '''your server doesn't need to be exposed to the internet''', which is great for self-hosting) and will configure systemd timers to renew the certificate if required.
 
== Troubleshooting ==
 
=== Read-only Filesystem for nginx upgrade to 20.09 ===
 
With the upgrade to nixos-20.09 the nginx comes with extra hardening parameters, most prominently the restriction of write access to the Operating System Disk.
When you see errors like <code>[emerg] open() "/var/spool/nginx/logs/binaergewitter.access.log" failed (30: Read-only file system)</code> you can add extra paths to nginx service like this:
 
<syntaxhighlight lang="nix">
  systemd.services.nginx.serviceConfig.ReadWritePaths = [ "/var/spool/nginx/logs/" ];
</syntaxhighlight>
 
=== SIGTERM received from 1 ===
 
If you turn debug logging on:
<pre>
services.nginx.logError = "stderr debug";
</pre>
 
You may see this:
<pre>
[notice] 12383#12383: signal 15 (SIGTERM) received from 1, exiting
</pre>
 
This means systemd is killing nginx for you, but systemd (in nixOS 20.09) isn't nice enough to tell you why it's happening. Chances are it's because your nginx config has daemon mode turned on, turn off daemon mode in your nginx config like so:
 
<pre>
daemon off;
</pre>
And it should fix nginx so systemd won't go killing your nginx anymore.
 
=== Escape special chars in Regular Expressions ===
Some nginx configuration options like <code>locations</code> allows the use of Regular Expressions.
Be ware that you [https://nixos.org/manual/nix/stable/language/values.html#type-string need to escape some special chars] like <code>\</code>, if provided by a double quoted <code>" "</code> string.
 
A common example found on the internet is:
<syntaxhighlight lang="nix">
locations."~ ^(.+\.php)(.*)$" = {
    ...
};
</syntaxhighlight>
But in this case the <code>\.php</code> part will be parsed by Nix to <code>.php</code>. In RegEx the dot represents any character instead of the dot character itself.
Thus the path /gly'''php'''ro.css will be matched, too. Additionaly to the intended  match of <code>/somephpfile.php?param=value</code>.
 
To circumvent this error <code>\.php</code> has to be double escaped as <code>\\.php</code>
<syntaxhighlight lang="nix">
locations."~ ^(.+\\.php)(.*)$"  = {
    ...
};
</syntaxhighlight>
</syntaxhighlight>
=== General ===
Nginx is run as the systemd service nginx, so <code>systemctl status nginx</code> may say something useful. If you have a problem with configuration, you can find the configuration location in the <code>systemctl status</code>, it should be at <code>/nix/store/*-nginx.conf</code>.
== Replace dependencies like openssl ==
In wake of the 2022 OpenSSL library, Nix can support in mitigating the library by downgrading (or replacing) the SSL library. For this, the [[Overlay|overlay]] facility of nixpkgs can be used:
<syntaxHighlight lang=nix>
nixpkgs.overlays = [
  (final: super: {
        nginxStable = super.nginxStable.override { openssl = super.pkgs.libressl; };
    } )
];
</syntaxHighlight>
When utilizing NixOS options the following configuration will also work:
<syntaxHighlight lang=nix>
services.nginx.package = pkgs.nginxStable.override { openssl = pkgs.libressl; };
</syntaxHighlight>
== See more ==
* [http://nginx.org/en/docs/ Official Documentation]
[[Category:Applications]]
[[Category:Server]]
[[Category:Networking]]

Latest revision as of 00:54, 22 September 2024

Nginx (wikipedia:en:Nginx) is a lightweight webserver. Configuration is handled using the services.nginx. options.

Sample setups

Minimal raw html dummy page for testing configuration.nix

services.nginx = {
  enable = true;
  virtualHosts.localhost = {
    locations."/" = {
      return = "200 '<html><body>It works</body></html>'";
      extraConfig = ''
        default_type text/html;
      '';
    };
  };
};

Static blog with ssl enforced in configuration.nix

services.nginx = {
  enable = true;
  virtualHosts."blog.example.com" = {
    enableACME = true;
    forceSSL = true;
    root = "/var/www/blog";
  };
};
# Optional: You can configure the email address used with Let's Encrypt.
# This way you get renewal reminders (automated by NixOS) as well as expiration emails.
security.acme.certs = {
  "blog.example.com".email = "youremail@address.com";
};

LEMP stack

(Nginx/MySQL/PHP) in configuration.nix

{ config, ...}: {
services.nginx = {
  enable = true;
  virtualHosts."blog.example.com" = {
    enableACME = true;
    forceSSL = true;
    root = "/var/www/blog";
    locations."~ \\.php$".extraConfig = ''
      fastcgi_pass  unix:${config.services.phpfpm.pools.mypool.socket};
      fastcgi_index index.php;
    '';
  };
};
services.mysql = {
  enable = true;
  package = pkgs.mariadb;
};
services.phpfpm.pools.mypool = {                                                                                                                                                                                                             
  user = "nobody";                                                                                                                                                                                                                           
  settings = {                                                                                                                                                                                                                               
    "pm" = "dynamic";            
    "listen.owner" = config.services.nginx.user;                                                                                                                                                                                                              
    "pm.max_children" = 5;                                                                                                                                                                                                                   
    "pm.start_servers" = 2;                                                                                                                                                                                                                  
    "pm.min_spare_servers" = 1;                                                                                                                                                                                                              
    "pm.max_spare_servers" = 3;                                                                                                                                                                                                              
    "pm.max_requests" = 500;                                                                                                                                                                                                                 
  };                                                                                                                                                                                                                                         
};

Robots.txt

If you want to set a robots.txt for your domain (or any subdomains), add this:

locations."/robots.txt" = {
  extraConfig = ''
    rewrite ^/(.*)  $1;
    return 200 "User-agent: *\nDisallow: /";
  '';
};

HTTP Authentication

Basic Authentication

Nginx can require users to login using HTTP Basic Authentication. In NixOS, this is set using the `basicAuth` option:

services.nginx = {
    virtualHosts."example.com" =  {
      basicAuth = { user = "password"; anotherUser = "..."; };
      ...
    };
};
Authentication via PAM

It is also possible to authenticate system users, e.g. users in the /etc/passwd file, by using the PAM module.

  security.pam.services.nginx.setEnvironment = false;
  systemd.services.nginx.serviceConfig = {
    SupplementaryGroups = [ "shadow" ];
  };

  services.nginx = {
    enable = true;
    additionalModules = [ pkgs.nginxModules.pam ];
    ...
    virtualHosts."example.com".extraConfig = ''
      auth_pam  "Password Required";
      auth_pam_service_name "nginx";
    '';
    ...
    };
  };

TLS reverse proxy

This is a "minimal" example in terms of security, see below for more tips.

services.nginx = {
    enable = true;
    recommendedProxySettings = true;
    recommendedTlsSettings = true;
    # other Nginx options
    virtualHosts."example.com" =  {
      enableACME = true;
      forceSSL = true;
      locations."/" = {
        proxyPass = "http://127.0.0.1:12345";
        proxyWebsockets = true; # needed if you need to use WebSocket
        extraConfig =
          # required when the target is also TLS server with multiple hosts
          "proxy_ssl_server_name on;" +
          # required when the server wants to use HTTP Authentication
          "proxy_pass_header Authorization;"
          ;
      };
    };
};
Note: ACME won't be able to authenticate your domain if ports 80 & 443 aren't open in your firewall.

Hardened setup with TLS and HSTS preloading

For testing your TLS configuration, you might want to visit [1]. If you configured preloading and want to apply for being included in the preloading list, check out [2]. Please read enough about preloading to understand the consequences, as it takes some effort to be removed from the list.

services.nginx = {
    enable = true;

    # Use recommended settings
    recommendedGzipSettings = true;
    recommendedOptimisation = true;
    recommendedProxySettings = true;
    recommendedTlsSettings = true;

    # Only allow PFS-enabled ciphers with AES256
    sslCiphers = "AES256+EECDH:AES256+EDH:!aNULL";
    
    appendHttpConfig = ''
      # Add HSTS header with preloading to HTTPS requests.
      # Adding this header to HTTP requests is discouraged
      map $scheme $hsts_header {
          https   "max-age=31536000; includeSubdomains; preload";
      }
      add_header Strict-Transport-Security $hsts_header;

      # Enable CSP for your services.
      #add_header Content-Security-Policy "script-src 'self'; object-src 'none'; base-uri 'none';" always;

      # Minimize information leaked to other domains
      add_header 'Referrer-Policy' 'origin-when-cross-origin';

      # Disable embedding as a frame
      add_header X-Frame-Options DENY;

      # Prevent injection of code in other mime types (XSS Attacks)
      add_header X-Content-Type-Options nosniff;

      # This might create errors
      proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict";
    '';

    # Add any further config to match your needs, e.g.:
    virtualHosts = let
      base = locations: {
        inherit locations;

        forceSSL = true;
        enableACME = true;
      };
      proxy = port: base {
        "/".proxyPass = "http://127.0.0.1:" + toString(port) + "/";
      };
    in {
      # Define example.com as reverse-proxied service on 127.0.0.1:3000
      "example.com" = proxy 3000 // { default = true; };
    };
};

Using realIP when behind CloudFlare or other CDN

When Nginx is behind another proxy it won't know the true IP address of clients hitting it. It will then pass down those the proxy's IP address instead of the client IP address. By using the nginx realip module, we can ensure nginx knows the real client IP, and we can further inform nginx to only trust the HTTP header from valid upstream proxies.

In the following example, we are fetching the list of IPs directly from cloudflare and including a hash. This has some pros and cons. Nix will not attempt to download or update that file while it is in a nix store it trusts, but after a nix garbage collection, it will error if the list of proxies has changed informing you of that when you apply the config.

  services.nginx.commonHttpConfig =
    let
      realIpsFromList = lib.strings.concatMapStringsSep "\n" (x: "set_real_ip_from  ${x};");
      fileToList = x: lib.strings.splitString "\n" (builtins.readFile x);
      cfipv4 = fileToList (pkgs.fetchurl {
        url = "https://www.cloudflare.com/ips-v4";
        sha256 = "0ywy9sg7spafi3gm9q5wb59lbiq0swvf0q3iazl0maq1pj1nsb7h";
      });
      cfipv6 = fileToList (pkgs.fetchurl {
        url = "https://www.cloudflare.com/ips-v6";
        sha256 = "1ad09hijignj6zlqvdjxv7rjj8567z357zfavv201b9vx3ikk7cy";
      });
    in
    ''
      ${realIpsFromList cfipv4}
      ${realIpsFromList cfipv6}
      real_ip_header CF-Connecting-IP;
    '';

UNIX socket reverse proxy

In order for nginx to be able to access UNIX sockets, you have to do some permission modifications.

# Example service that supports listening to UNIX sockets
services.hedgedoc = {
  enable = true;
  settings.path = "/run/hedgedoc/hedgedoc.sock"
};

services.nginx = {
  enable = true;
  virtualHosts."example.com" = {
    enableACME = true;
    forceSSL = true;
    locations."/".proxyPass = "http://unix:/run/hedgedoc/hedgedoc.sock";
  };
};

# This is needed for nginx to be able to read other processes
# directories in `/run`. Else it will fail with (13: Permission denied)
systemd.services.nginx.serviceConfig.ProtectHome = false;

# Most services will create sockets with 660 permissions.
# This means you have to add nginx to their group.
users.groups.hedgedoc.members = [ "nginx" ];

# Alternatively, you can try to force the unit to create the socket with
# different permissions, if you have a reason for not wanting to add nginx
# to their group. This might not work, depending on how the program sets
# its permissions for the socket.
systemd.services.hedgedoc.serviceConfig.UMask = "0000";

Let's Encrypt certificates

The nginx module for NixOS has native support for Let's Encrypt certificates; services.nginx.+acme. The NixOS Manual, Chapter 20. SSL/TLS Certificates with ACME explains it in detail.

Minimal Internet Exposed Server Example

Assuming that myhost.org resolves to the IP address of your host and port 80 and 443 have been opened.

services.nginx.enable = true;
services.nginx.virtualHosts."myhost.org" = {
    addSSL = true;
    enableACME = true;
    root = "/var/www/myhost.org";
};
security.acme = {
  acceptTerms = true;
  defaults.email = "foo@bar.com";
};

This will set up nginx to serve files for myhost.org, automatically request an ACME SSL Certificate using a "HTTP-01" challenge (meaning your server must be exposed to the internet) and will configure systemd timers to renew the certificate if required.

Minimal Private Local LAN Server Example

We can also have a private server running in our local network (including VPN), that isn't reachable from the internet, but that still can get valid Let's Encrypt certificates that are accepted in a browser.

1. We have to modify DNS such that our domain like myhost.org resolves to the local IP address of our private server and port 80 and 443 have been opened. See this video tutorial for an example on how to do that. Hint: You might need to add an exception to your router (definitely on Fritzboxes), because resolving to local IP address is usually blocked to prevent "DNS rebind attacks".

2. We have to setup the Let's Encrypt NixOS ACME services such that it uses an API token in a secrets file (secrets for a server can be conveniently and securely deployed in NixOS with agenix; just follow the tutorial) against our DNS provider to prove from our server that we own the domain. This way our server doesn't need to be exposed and reachable from the internet. NixOS ACME uses the LEGO library to communicate to DNS providers (it supports a lot) and therefore we have to provide the token(s) in that library's secrets file format.

In the example we use Hetzner as our "dnsProvider" that only needs a single API token environment in our secrets file:

HETZNER_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

Other DNS providers need like OVH require more environment variables.

See the section "Credentials" on what you have to specify in the secrets file: https://go-acme.github.io/lego/dns/hetzner/

See Hetzner guide on how to get an API token for its "DNS console": https://docs.hetzner.com/dns-console/dns/general/api-access-token/

3. Point our virtualHost to the ACME entry.

{
  services.nginx = {
    enable = true;
    # we can use the main domain or any subdomain that's mentioned by 
    # "extraDomainNames" in the acme certificate.
    virtualHosts."subdomain.example.org" = {
      # 3. Instead of "enableACME = true;" we
      # reuse the certificate from "security.acme.certs."example.org"
      # down below
      useACMEHost = "example.org";
      forceSSL = true;
      locations."/" = {
        return = "200 '<html><body>It works</body></html>'";
        extraConfig = ''
          default_type text/html;
        '';
      };
    };
  };

  security.acme.acceptTerms = true;
  security.acme.defaults.email = "info@example.org";

  # 2. Let NixOS generate a Let's Encrypt certificate that we can reuse
  # above for several virtualhosts above.
  security.acme.certs."example.org" = {
    domain = "example.org";
    extraDomainNames = [ "subdomain.example.org" ];
    # The LEGO DNS provider name. Depending on the provider, need different
    # contents in the credentialsFile below.
    dnsProvider = "hetzner";
    dnsPropagationCheck = true;
    # agenix will decrypt our secrets file (below) on the server and make it available
    # under /run/agenix/secrets/hetzner-dns-token (by default):
    # credentialsFile = "/run/agenix/secrets/hetzner-dns-token";
    credentialsFile = config.age.secrets."hetzner-dns-token.age".path;
  };
  
  # Let agenix know about and copy our (encrypted) DNS API token secrets file
  # (containing "HETZNER_API_KEY=...") to the server an decrypt it there.
  # Follow the agenix tutorial on how to encrypt a secrets file
  # to a .age file and how to setup your Nix flake to use it.
  age.secrets."hetzner-dns-token.age".file = .../hetzner-dns-token.age;

  users.users.nginx.extraGroups = [ "acme" ];
}

This will set up nginx to serve files for example.org, automatically request an ACME SSL Certificate using a "DNS-01" challenge (meaning your server doesn't need to be exposed to the internet, which is great for self-hosting) and will configure systemd timers to renew the certificate if required.

Troubleshooting

Read-only Filesystem for nginx upgrade to 20.09

With the upgrade to nixos-20.09 the nginx comes with extra hardening parameters, most prominently the restriction of write access to the Operating System Disk. When you see errors like [emerg] open() "/var/spool/nginx/logs/binaergewitter.access.log" failed (30: Read-only file system) you can add extra paths to nginx service like this:

  systemd.services.nginx.serviceConfig.ReadWritePaths = [ "/var/spool/nginx/logs/" ];

SIGTERM received from 1

If you turn debug logging on:

services.nginx.logError = "stderr debug";

You may see this:

[notice] 12383#12383: signal 15 (SIGTERM) received from 1, exiting

This means systemd is killing nginx for you, but systemd (in nixOS 20.09) isn't nice enough to tell you why it's happening. Chances are it's because your nginx config has daemon mode turned on, turn off daemon mode in your nginx config like so:

daemon off;

And it should fix nginx so systemd won't go killing your nginx anymore.

Escape special chars in Regular Expressions

Some nginx configuration options like locations allows the use of Regular Expressions. Be ware that you need to escape some special chars like \, if provided by a double quoted " " string.

A common example found on the internet is:

locations."~ ^(.+\.php)(.*)$"  = {
    ...
};

But in this case the \.php part will be parsed by Nix to .php. In RegEx the dot represents any character instead of the dot character itself. Thus the path /glyphpro.css will be matched, too. Additionaly to the intended match of /somephpfile.php?param=value.

To circumvent this error \.php has to be double escaped as \\.php

locations."~ ^(.+\\.php)(.*)$"  = {
    ...
};

General

Nginx is run as the systemd service nginx, so systemctl status nginx may say something useful. If you have a problem with configuration, you can find the configuration location in the systemctl status, it should be at /nix/store/*-nginx.conf.

Replace dependencies like openssl

In wake of the 2022 OpenSSL library, Nix can support in mitigating the library by downgrading (or replacing) the SSL library. For this, the overlay facility of nixpkgs can be used:

nixpkgs.overlays = [ 
   (final: super: { 
        nginxStable = super.nginxStable.override { openssl = super.pkgs.libressl; }; 
    } ) 
];

When utilizing NixOS options the following configuration will also work:

services.nginx.package = pkgs.nginxStable.override { openssl = pkgs.libressl; };

See more