Setting Up a Production Nginx Server on NixOS
In previous posts, I’ve mentioned that I maintain a stack using NixOS. This website you’re reading is included. The gateway from my backend to the world is Nginx. I’m going to walk through how I set up Nginx on NixOS for this setup so you can avoid some of the pitfalls I ran into.
Background
Nginx is a rock-solid web server that provides a ton of features out of the box. NixOS provides the Nginx service via its Nix packages. If you’re coming from working with Nginx configs like me, it might be a bit confusing and frankly frustrating to set up Nginx.
I initially thought that you can just bring your own Nginx configuration file and point the NixOS settings to that. This is not the case. The configurations from a regular Nginx file need to be converted to NixOS configuration syntax.
However, once you wrap your head around the configurations exposed, it all makes sense. Making updates is super easy, no having to FTP a file to the server.
Setting Up Our Webserver
We break this down into a few steps. We have a basic config to walk through the Nginx server, then we delve into TLS certs and subdomains to round it out. Jump to the end of the article if you want to just see the complete configuration.
Basic Web Server
We’re going to create the most basic Nginx we server. This will only serve HTTP traffic and will send a static message for a single domain.
Enabling The Webserver
We need to enable our Nginx web server. It’s built into Nix Packages, so we can just go ahead and enable it.
services.nginx = { enable = true; };
Creating our Virtual Host
We can go ahead and define a virtual host that will serve our domain. In this case, it’s static and will return a
200
response for every incoming request.services.nginx = { enable = true; virtualHosts = { "mywebsite.com" = { serverName = "mywebsite.com"; forceSSL = false; locations."/" = { return = "200 \"Hello from mywebsite!\""; }; }; }; };
Opening Up the Port
Lastly, we need to open up the port on our host. Since it’s an HTTP server, we open up port
80
.networking.firewall.allowedTCPPorts = [ 80 ];
Adding TLS Encryption
Websites and web serverices these days are pretty much useless if they do not support HTTPS traffic. We can get a cert for our domain and start serving encrypted traffic.
Create our acme user
Certs work by granting the Nginx user access to the
acme
group.users.users.nginx.extraGroups = [ "acme" ];
Define a certificate Using
security.acme
, we can go ahead and define our cert.security.acme = { acceptTerms = true; defaults.email = "burak@mywebsite.com"; # Staging server for testing configurations.... # defaults.server = "https://acme-staging-v02.api.letsencrypt.org/directory"; certs = { "mywebsite.com" = { webroot = "/var/lib/acme/challenges-mywebsite"; email = "burak@mywebsite.com"; group = "nginx"; }; }; };
Wire Up to Our Virtual Hosts
We now go ahead and add our certificate to our virtual host configuration.
services.nginx = { enable = true; virtualHosts = { "mywebsite.com" = { serverName = "mywebsite.com"; useACMEHost = "mywebsite.com"; acmeRoot = "/var/lib/acme/challenges-mywebsite"; addSSL = true; forceSSL = false; locations."/" = { return = "200 \"Hello from mywebsite!\""; }; }; }; };
Opening Up the Port
HTTPS requires a different port, so let’s go ahead and open that up.
networking.firewall.allowedTCPPorts = [ 80 443 ];
Forcing SSL
The last thing that I like to do is enable the
forceSSL
option. This means that all traffic will be redirected to the HTTPS.services.nginx = { enable = true; virtualHosts = { "mywebsite.com" = { serverName = "mywebsite.com"; useACMEHost = "mywebsite.com"; acmeRoot = "/var/lib/acme/challenges-mywebsite"; forceSSL = true; #<-- Change here locations."/" = { return = "200 \"Hello from mywebsite!\""; }; }; }; };
Adding Subdomains
Another big use case that I use is using subdomains with the main domain. There are a few gotchas, especially around the TLS certificate. We’re going to walk through the steps to add a subdomain.
Adding a Subdomain Virtual Host
Create a virtual host similar to above. In this case, we’re reverse proxying a service running locally on port
7000
.services.nginx = { enable = true; virtualHosts = { "mywebsite.com" = { ... }; "subdomain.mywebsite.com" = { serverName = "registry.mywebsite.com"; forceSSL = false; locations."/" = { recommendedProxySettings = true; proxyPass = "http://127.0.0.1:7000"; }; }; }; };
Adding Subdomain to the Certificate
Since it’s a subdomain, both vhosts can use the same cert. We can modify our existing cert by adding the configuration
extraDomainNames
to include this subdomain (and others) onto the same cert.security.acme = { acceptTerms = true; defaults.email = "burak@mywebsite.com"; certs = { "mywebsite.com" = { webroot = "/var/lib/acme/challenges-mywebsite"; email = "burak@mywebsite.com"; group = "nginx"; extraDomainNames = [ "subdomain.mywebsite.com" ]; }; }; };
Wiring Up Subdomain to Cert This is very similar to our first virtual host. Remember we’re using the same certificate as the root domain. So the certificate configs will be the same as our root Vhost.
services.nginx = { enable = true; virtualHosts = { "mywebsite.com" = { ... }; "subdomain.mywebsite.com" = { serverName = "registry.mywebsite.com"; useACMEHost = "mywebsite.com"; addSSL = true; forceSSL = false; acmeRoot = "/var/lib/acme/challenges-mywebsite"; locations."/" = { recommendedProxySettings = true; proxyPass = "http://127.0.0.1:7000"; }; }; }; };
Bonus Fun Configuration
One thing I like to do is define a default route. This is critical when working with multiple domains. This prevents issues with domains that are pointed here via DNS but not defined from causing problems. This is because the default branch catches these interactions that are unintended.
services.nginx = {
enable = true;
virtualHosts = {
default = {
serverName = "_";
default = true;
rejectSSL = true;
locations."/".return = "444";
};
"mywebsite.com" = {
...
};
"subdomain.mywebsite.com" = {
...
};
};
};
The Final Configuration
Putting all the pieces together, here is what our final configuration looks like:
{
# Enable the docker registry
networking.firewall.allowedTCPPorts = [
80
443
];
security.acme = {
acceptTerms = true;
defaults.email = "burak@mywebsite.com";
certs = {
"mywebsite.com" = {
webroot = "/var/lib/acme/challenges-mywebsite";
email = "burak@mywebsite.com";
group = "nginx";
extraDomainNames = [
"subdomain.mywebsite.com"
];
};
};
};
users.users.nginx.extraGroups = [ "acme" ];
services.nginx = {
enable = true;
virtualHosts = {
default = {
serverName = "_";
default = true;
rejectSSL = true;
locations."/".return = "444";
};
"mywebsite.com" = {
serverName = "mywebsite.com";
useACMEHost = "mywebsite.com";
acmeRoot = "/var/lib/acme/challenges-mywebsite";
forceSSL = true;
locations."/" = {
return = "200 \"Hello from mywebsite!\"";
};
};
"subdomain.mywebsite.com" = {
serverName = "registry.mywebsite.com";
useACMEHost = "mywebsite.com";
forceSSL = true;
acmeRoot = "/var/lib/acme/challenges-mywebsite";
locations."/" = {
recommendedProxySettings = true;
proxyPass = "http://127.0.0.1:7000";
};
};
};
};
}
What’s Next
Now that you’ve got your web server configured, you can go ahead and start building out the rest of your applications. I have written extensively about running containers and setting up continuous deployment on NixOS. Hopefully, I have helped you take the leap into the NixOS world or improved your NixOS setup.