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:
- domain 1 (this blog):
- http redirected to https.
www.
redirected to root domain, hosting the about page.- pages served from a separate location for the
blog.
subdomain. - a redirect for another subdomain to an external site.
- domain 2, another personal site, nothing unexpected:
- http redirected to https.
www.
redirected to root domain.
- domain 3, a domain not hosted on the VPS, just a redirect to an external site.
- needs http and https redirected to the external site.
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 :
- arbitrary subdomains redirected to another vhost,
- broken https redirects,
- getting rate-limited by Let's Encrypt because I didn't realize the implications of changing the acme config (newbie error).
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