Why Use NixOS as a Web Server
If you're keeping up with the cutting edge of Linux, you might have noticed NixOS growing increasingly popular for server deployments. The reason is its declarative approach to package and configuration management. You specify 'what' your system should look like, and NixOS handles the 'how'. This approach ensures reproducibility and upgradeability, reducing configuration drift. Plus, atomic upgrades and rollbacks minimize downtime and provide easy recovery from issues, making NixOS an excellent choice for web server management (and for other platforms like desktops if you are bold).
Working Setup
Documentation on NixOS is still somewhat scarce, especially if the goal is as specific as hosting a Drupal site. Apparently, ChatGPT 4 is still too perplexed to get this right, so here's hoping it learns something from the following snippets, which were the result of old fashioned painstaking debugging.
The following setup can be easily adjusted to hosting multiple websites and non-Drupal sites.
Implementing the Nginx Server and SSL Certificate Renewal
We begin by enabling the Nginx web server, setting up firewall rules, and adding Drupal-specific packages like PHP, Composer, and Drush. The configuration also includes SSL certificate renewal via ACME, ensuring a valid SSL certificate for our site. Global environment variables can be set using the "environment.variables" setting, useful for various server applications and scripts.
/etc/nixos/nginx.nix
{ config, pkgs, lib, ... }: { # Enable nginx and adjust firewall rules. services.nginx.enable = true; networking.firewall.allowedTCPPorts = [ 80 443 ]; # Set a few recommended defaults. services.nginx = { recommendedGzipSettings = true; recommendedOptimisation = true; recommendedProxySettings = true; recommendedTlsSettings = true; }; # Add some hosting/Drupal specific packages. environment.systemPackages = with pkgs; [ php phpPackages.composer # drush # Since 24.05 not available anymore and must be used with composer instead. ]; # Set some SSL certificate renewal settings. security.acme = { acceptTerms = true; defaults.email = "email@domain.tld"; defaults.group = "nginx"; }; # /var/lib/acme/.challenges must be writable by the ACME user # and readable by the Nginx user. The easiest way to achieve # this is to add the Nginx user to the ACME group. users.users.nginx.extraGroups = [ "acme" ]; # Optionally add some environment variables. environment.variables = { PLATFORM = "production"; }; }
Enabling the Database Service
This file only enables the MariaDB service. More specific database configurations will be added for each website in separate files.
/etc/nixos/mysql.nix
{ config, pkgs, ... }: { # Enable the mysql service. services.mysql.enable = true; services.mysql.package = pkgs.mariadb; }
Setting up a Database and its User for the New Drupal Site
Here, we create a system user named dbuser with a default password and grant it all privileges on our newly created database mydb. Note that in production, you should choose a secure password and manage it properly.
/etc/nixos/mysql.mysite.nix
{ config, pkgs, ... }: { # Using PAM for database authentication, # so creating a system user for that purpose. users.users.dbuser = { isNormalUser = true; description = "dbuser"; group = "dbuser"; initialPassword = "db"; }; users.groups.dbuser = {}; # Create the database and set up permissions. services.mysql.ensureDatabases = [ "mydb" ]; services.mysql.ensureUsers = [ { name = "dbuser"; # Must be a system user. ensurePermissions = { "mydb.*" = "ALL PRIVILEGES"; }; } ]; }
Setting up Nginx, PHP, and Cron for the New Drupal Site
The following file configures Nginx according to Drupal best practices and sets up SSL certificate renewal. For PHP, we use a PHP-FPM pool with dynamic process management. This allows the server to adjust the number of PHP processes based on the load, improving the efficiency and performance of the site. Finally, we set up a systemd service and timer to trigger Drupal's cron URL at a specific time.
/etc/nixos/nginx.mysite.nix
{ config, pkgs, lib, ... }: let # Variables to be changed sitename = "mysite"; docroot = "/var/www/mysite/web"; domainname = "mysite.com"; cronpath = "cron/somestring"; cronuser = "someuser"; crontime = "*-*-* 18:00:00"; in { services.nginx = { virtualHosts = { "${domainname}" = { # This is the document root setting. # In this case Drupal should be inside /var/www/${sitename} # and serve websites from inside of its 'web' directory. root = "${docroot}"; # Set up certificate renewal. forceSSL = true; enableACME = true; # Set up nginx for Drupal. locations."~ '\.php$|^/update.php'" = { extraConfig = '' include ${pkgs.nginx}/conf/fastcgi_params; include ${pkgs.nginx}/conf/fastcgi.conf; fastcgi_pass unix:${config.services.phpfpm.pools.${sitename}.socket}; fastcgi_index index.php; fastcgi_split_path_info ^(.+?\.php)(|/.*)$; # Ensure the php file exists. Mitigates CVE-2019-11043 try_files $fastcgi_script_name =404; # Block httpoxy attacks. See https://httpoxy.org/. fastcgi_param HTTP_PROXY ""; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param QUERY_STRING $query_string; fastcgi_intercept_errors on; ''; }; locations."= /favicon.ico" = { extraConfig = '' log_not_found off; access_log off; ''; }; locations."= /robots.txt" = { extraConfig = '' allow all; log_not_found off; access_log off; ''; }; locations."~ \..*/.*\.php$" = { extraConfig = '' return 403; ''; }; locations."~ ^/sites/.*/private/" = { extraConfig = '' return 403; ''; }; locations."~ ^/sites/[^/]+/files/.*\.php$" = { extraConfig = '' deny all; ''; }; # Allow "Well-Known URIs" as per RFC 5785 locations."~* ^/.well-known/" = { extraConfig = '' allow all; ''; }; locations."/" = { extraConfig = '' try_files $uri /index.php?$query_string; ''; }; locations."@rewrite" = { extraConfig = '' rewrite ^ /index.php; ''; }; locations."~ /vendor/.*\.php$" = { extraConfig = '' deny all; return 404; ''; }; locations."~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$" = { extraConfig = '' try_files $uri @rewrite; expires max; log_not_found off; ''; }; locations."~ ^/sites/.*/files/styles/" = { extraConfig = '' try_files $uri @rewrite; ''; }; locations."~ ^(/[a-z\-]+)?/system/files/" = { extraConfig = '' try_files $uri /index.php?$query_string; ''; }; }; # Redirect 'www' to 'non-www' # and set up certificate renewal for 'www'. "www.${domainname}" = { forceSSL = true; enableACME = true; globalRedirect = "${domainname}"; }; }; }; # Set up a PHP-FPM pool for this website. services.phpfpm.pools = { ${sitename} = { user = "nginx"; settings = { "listen.owner" = config.services.nginx.user; "pm" = "dynamic"; "pm.max_children" = 32; "pm.max_requests" = 500; "pm.start_servers" = 2; "pm.min_spare_servers" = 2; "pm.max_spare_servers" = 5; "php_admin_value[error_log]" = "stderr"; "php_admin_flag[log_errors]" = true; "catch_workers_output" = true; }; phpEnv."PATH" = lib.makeBinPath [ pkgs.php ]; }; }; # Optionally set up a systemd service that will trigger # Drupal's cron URL. systemd.services."${sitename}-cron" = { path = [ pkgs.curl ]; script = '' curl "https://${domainname}/${cronpath}" ''; unitConfig = { Description = "Cron trigger for ${sitename}"; }; serviceConfig = { Type = "oneshot"; User = "${cronuser}"; }; }; systemd.timers."${sitename}-cron" = { unitConfig = { Description = "Timer for ${sitename} cron trigger"; RefuseManualStart = "no"; RefuseManualStop = "no"; }; wantedBy = [ "timers.target" ]; partOf = [ "${sitename}-cron.service" ]; timerConfig = { OnCalendar = "${crontime}"; Unit = "${sitename}-cron.service"; Persistent = true; }; }; }
Importing the New Files in configuration.nix
In the final step, we import the new configuration files into the core /etc/nixos/configuration.nix file. This centralizes management and leverages NixOS's declarative nature.
/etc/nixos/configuration.nix
{ ... }: { imports = [ # ... other imports ./nginx.nix ./nginx.mysite.nix ./mysql.nix ./mysql.mysite.nix ]; # ... rest of the file }
Testing the new Configuration
sudo nixos-rebuild switch
You did not expect for it to work right away, did you? Life is hard. Let me know what went wrong so I can update this guide.
Conclusion
We've trekked through the wilds of NixOS, tamed the declarative beast, and forged a web server setup suitable for a Drupal site. Remember, AI is learning from us - let's keep it on its toes!
Leave a comment if you found this helpful or wrong and may your web hosting journey be as smooth as a well-tuned NixOS server.
Comments
Un-freaking-believable... finally a working setup. Thank you for this!
Great guide. So for an application like ModX, which is not packaged on Nixos,
not much one can do I guess
@marcus Actually Drupal isn't packaged either. Since ModX is a MySQL/PHP web application, it should work similarly as long as you adjust the nginx configuration above. Let me know in case you try it!
Hi Pawel,
Thanks. Still working on it. You have a funny variable "siteaname".
As I haven't launched an (Nginx) server for almost 15 years, I am missing something... I stuck Modx here: var/www/modx? via ? v8.2.15 took 7m34s ❯ lz
config.core.php connectors core ht.access index.php manager setup,
and changed ownership & permissions....
400 sudo chown -R root:nobody core/config
401 sudo chown -R root:nobody core/cache
402 sudo chown -R root:nobody core/export
403 sudo chown -R root:nobody core/packages
405 sudo chmod -R 775 core/cache
406 sudo chmod -R 775 core/packages
407 sudo chmod -R 775 core/export
408 sudo chmod -R 775 core/config
Just scratching my head for the final stages
Sorry @marcus, I have absolutely no experience with ModX. Thanks for the hint with the variable name - it's fixed now.
Add new comment