DigressEd.net

Ocasional Digressions

NixOS/nginx/acme with mutliple vhosts

(TL;DR: skip to the config)

I recently migrated my VPS from arch to nixos which also meant migrating my apache2 conf to nginx since it's what seems to be most commonly used. I got 90% of the way with my various domains (all of static content) but the last bit took a lot of patience as I couldn't find examples that handled all my cases:

I thought tweaking the conf a bit would be trivial but, while trying suggestions from other posts and even NixOS's Wiki all I ended up doing was :

I got all frustrated and commented out the entire acme / nginx config and focusing on other things while the Let's Encrypt restriction passed.

I determined the main source of my problems were the catch-all vhosts suggested on the Wiki and other places plus some missing forceSSL = true which turns out are really needed. They catch-all hosts seem ideal but it is not straightforward to follow as soon as you have different sub-domains that should not all be redirected to the same base host. I finally started re-enabling one by one, using staging certificates, and got it all working. Surely it could be simplified but that's for another time.

Sample Config

# set up files / permissions
systemd = {
  # for nginx to write logs
  services.nginx.serviceConfig.ReadWritePaths = [ "/var/log/nginx" ];

  # where all files for the sites are in the filesystem - referenced
  # later in the nginx config
  tmpfiles.settings = {
    www = {
      "/var/www/domain-1.net" = {
        f = { user = "<your user id>"; group = "nginx"; };
      };
      "/var/www/domain-2.com" = {
        f = { user = "<your user id>; group = "nginx"; };
      };
    };
  };
};

# let's encrypt certificates
security = {
  acme = {
    acceptTerms = true;
    defaults = {
      email = "<your contact email>";
      group = "nginx";

      # Be aware there are rate-limits so too many changes to your
      # certs config may result in rejected requests for 24-hours.
      # Instead, uncomment the server option to enable use of the
      # staging environment
      #server = "https://acme-staging-v02.api.letsencrypt.org/directory";
      #
      # If you need to force-regenerate certificates, you can do so
      # with:
      #   sudo systemctl clean --what=state acme-domain-1.net.service
      #
    };

    certs = {
      "domain-1.net" = {
        webroot = "/var/lib/acme/acme-challenge";
        # list all subdomains that must share the certificate
        extraDomainNames = [ "www.domain-1.net" "sub.domain-1.net" "redir.domain-1.net" ];
      };
      "domain-2.com" = {
        webroot = "/var/lib/acme/acme-challenge";
        extraDomainNames = [ "www.domain-2.com" ];
      };
      "domain-3.com" = {
        webroot = "/var/lib/acme/acme-challenge";
        extraDomainNames = [ "www.domain-3.com" ];
      };
    };
  };
};

# finally the web server config
services.nginx = {
  enable = true;
  logError = "stderr emerg";
  package = pkgs.nginxStable.override { openssl = pkgs.libressl; };

  virtualHosts = {

    domain-1 = {
      serverName = "domain-1.net";         # matches domain used in security.acme.certs
      forceSSL = true;                     # needed to handle http://domain-1.net -> https://domain-1.net redirection
      enableACME = true;
      root = "/var/www/domain-1.net/html"; # where the site's files are found

      # acme path for request validation. `root` must match
      # `webroot` in security.acme.certs.<domain>
      locations."/.well-known/acme-challenge" = {
        root = "/var/lib/acme/acme-challenge";
      };

      extraConfig = ''
         charset utf-8;
         # error_log /var/log/nginx/domain-1_error_log emerg; # uncomment if you want separate error log file
         access_log /var/log/nginx/domain-1_access_log;
      '';
    };

    # www version of domain-1.net. Needs the acme location for cert
    # validation. All other requests are dedirected to https://domain-1.net/<path>
    "www.domain-1.net" = {
      serverName = "www.domain-1.net";
      forceSSL = true;
      useACMEHost = "domain-1.net";

      locations = {
        "/.well-known/acme-challenge" = {
          root = "/var/lib/acme/acme-challenge";
        };

        "/" = {
          return = "301 https://domain-1.net$request_uri";
        };
      };
    };

    # separate subdomain served from a different location
    blog = {
      serverName = "blog.domain-1.net";
      forceSSL = true;
      useACMEHost = "domain-1.net";
      root = "/var/www/domain-1.net/blog";

      locations."/.well-known/acme-challenge" = {
        root = "/var/lib/acme/acme-challenge";
      };

      # optional, if you want separate logs
      extraConfig = ''
         charset utf-8;
         # error_log /var/log/nginx/blog_domain-1_error_log emerg; # uncomment if you want separate error log file
         access_log /var/log/nginx/blog_domain-1_access_log;
      '';
    };

    # separate subdomain, redirected to external host
    sub = {
      serverName = "sub.domain-1.net";
      forceSSL = true;
      useACMEHost = "domain-1.net";

      locations = {
        "/.well-known/acme-challenge" = {
          root = "/var/lib/acme/acme-challenge";
        };
        "/" = {
          return = "301 https://externalhost.ext;
        };
      };
    };

    # second vhost, with only www. and non-www. versions. www. is
    # redirected to non-www. Defines aliases for resources outside
    #  of root path
    domain-2 = {
      serverName = "domain-2.com";
      forceSSL = true;
      enableACME = true;
      root = "/var/www/domain-2.com/html"; # / is served from .../html/ path

      locations = {
        "/.well-known/acme-challenge" = {
          root = "/var/lib/acme/acme-challenge";
        };
        # point to a resources/ not within /html/
        "/css/" = {
          alias = "/var/www/domain-2.com/resources/css/";
        };
        "/js/" = {
          alias = "/var/www/domain-2.com/resources/js/";
        };
        # redirect domain-2.com/info to domain-2.com/about
        "/info" = {
          return = "301 https://domain-2.com/about/";
        };
      };

      extraConfig = ''
         charset utf-8;
         # error_log /var/log/nginx/domain-2_error_log emerg; # uncomment if you want separate error log file
         access_log /var/log/nginx/domain-2_access_log;
      '';
    };

    # www version of domain-2.com. Needs the acme location for cert
    # validation. All other requests are dedirected to https://domain-2.com/<path>
    "www.domain-2.com" = {
      serverName = "www.domain-2.com";
      forceSSL = true;
      useACMEHost = "domain-2.com";

      locations = {
        "/.well-known/acme-challenge" = {
          root = "/var/lib/acme/acme-challenge";
        };

        "/" = {
          return = "301 https://domain-2.com$request_uri";
        };
      };
    };

    # a third domain - No files are hosted.
    # only here to handle ssl certificate and redirect both
    # domain-3.com and www.domain-3.com to an external site (e.g., soundcloud)
    "domain-3.com" = {
      serverName = "domain-3.com";
      serverAliases = [ "www.domain-3.com" ];
      forceSSL = true;
      enableACME = true;

      locations."/.well-known/acme-challenge" = {
        root = "/var/lib/acme/acme-challenge";
      };

      locations."/" = {
         return = "301 https://soundcloud.com/myband";
      };
    };
  };
};

Published: 2024-09-07

Tagged: nixos vps vhosts acme nginx

Archive