Hosting Websites on NixOS - A Comprehensive Drupal 9 & 10 Configuration Example

12 May 2023

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

  1. { config, pkgs, lib, ... }: {
  2.  
  3. # Enable nginx and adjust firewall rules.
  4. services.nginx.enable = true;
  5. networking.firewall.allowedTCPPorts = [ 80 443 ];
  6.  
  7. # Set a few recommended defaults.
  8. services.nginx = {
  9. recommendedGzipSettings = true;
  10. recommendedOptimisation = true;
  11. recommendedProxySettings = true;
  12. recommendedTlsSettings = true;
  13. };
  14.  
  15. # Add some hosting/Drupal specific packages.
  16. environment.systemPackages = with pkgs; [
  17. php
  18. phpPackages.composer
  19. drush
  20. ];
  21.  
  22. # Set some SSL certificate renewal settings.
  23. security.acme = {
  24. acceptTerms = true;
  25. defaults.email = "email@domain.tld";
  26. defaults.group = "nginx";
  27. };
  28.  
  29. # /var/lib/acme/.challenges must be writable by the ACME user
  30. # and readable by the Nginx user. The easiest way to achieve
  31. # this is to add the Nginx user to the ACME group.
  32. users.users.nginx.extraGroups = [ "acme" ];
  33.  
  34. # Optionally add some environment variables.
  35. environment.variables = {
  36. PLATFORM = "production";
  37. };
  38. }

 

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

  1. { config, pkgs, ... }: {
  2.  
  3. # Enable the mysql service.
  4. services.mysql.enable = true;
  5. services.mysql.package = pkgs.mariadb;
  6. }

 

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

  1. { config, pkgs, ... }: {
  2.  
  3. # Using PAM for database authentication,
  4. # so creating a system user for that purpose.
  5. users.users.dbuser = {
  6. isNormalUser = true;
  7. description = "dbuser";
  8. group = "dbuser";
  9. initialPassword = "db";
  10. };
  11. users.groups.dbuser = {};
  12.  
  13. # Create the database and set up permissions.
  14. services.mysql.ensureDatabases = [ "mydb" ];
  15. services.mysql.ensureUsers = [
  16. {
  17. name = "dbuser"; # Must be a system user.
  18. ensurePermissions = { "mydb.*" = "ALL PRIVILEGES"; };
  19. }
  20. ];
  21. }

 

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

  1. { config, pkgs, lib, ... }:
  2. let
  3. # Variables to be changed
  4. sitename = "mysite";
  5. docroot = "/var/www/mysite/web";
  6. domainname = "mysite.com";
  7. cronpath = "cron/somestring";
  8. cronuser = "someuser";
  9. crontime = "*-*-* 18:00:00";
  10. in {
  11. services.nginx = {
  12. virtualHosts = {
  13. "${domainname}" = {
  14.  
  15. # This is the document root setting.
  16. # In this case Drupal should be inside /var/www/${sitename}
  17. # and serve websites from inside of its 'web' directory.
  18. root = "${docroot}";
  19.  
  20. # Set up certificate renewal.
  21. forceSSL = true;
  22. enableACME = true;
  23.  
  24. # Set up nginx for Drupal.
  25. locations."~ '\.php$|^/update.php'" = {
  26. extraConfig = ''
  27. include ${pkgs.nginx}/conf/fastcgi_params;
  28. include ${pkgs.nginx}/conf/fastcgi.conf;
  29. fastcgi_pass unix:${config.services.phpfpm.pools.${sitename}.socket};
  30. fastcgi_index index.php;
  31.  
  32. fastcgi_split_path_info ^(.+?\.php)(|/.*)$;
  33. # Ensure the php file exists. Mitigates CVE-2019-11043
  34. try_files $fastcgi_script_name =404;
  35.  
  36. # Block httpoxy attacks. See https://httpoxy.org/.
  37. fastcgi_param HTTP_PROXY "";
  38. fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
  39. fastcgi_param PATH_INFO $fastcgi_path_info;
  40. fastcgi_param QUERY_STRING $query_string;
  41. fastcgi_intercept_errors on;
  42. '';
  43. };
  44. locations."= /favicon.ico" = {
  45. extraConfig = ''
  46. log_not_found off;
  47. access_log off;
  48. '';
  49. };
  50. locations."= /robots.txt" = {
  51. extraConfig = ''
  52. allow all;
  53. log_not_found off;
  54. access_log off;
  55. '';
  56. };
  57. locations."~ \..*/.*\.php$" = {
  58. extraConfig = ''
  59. return 403;
  60. '';
  61. };
  62. locations."~ ^/sites/.*/private/" = {
  63. extraConfig = ''
  64. return 403;
  65. '';
  66. };
  67. locations."~ ^/sites/[^/]+/files/.*\.php$" = {
  68. extraConfig = ''
  69. deny all;
  70. '';
  71. };
  72. # Allow "Well-Known URIs" as per RFC 5785
  73. locations."~* ^/.well-known/" = {
  74. extraConfig = ''
  75. allow all;
  76. '';
  77. };
  78. locations."/" = {
  79. extraConfig = ''
  80. try_files $uri /index.php?$query_string;
  81. '';
  82. };
  83. locations."@rewrite" = {
  84. extraConfig = ''
  85. rewrite ^ /index.php;
  86. '';
  87. };
  88. locations."~ /vendor/.*\.php$" = {
  89. extraConfig = ''
  90. deny all;
  91. return 404;
  92. '';
  93. };
  94. locations."~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$" = {
  95. extraConfig = ''
  96. try_files $uri @rewrite;
  97. expires max;
  98. log_not_found off;
  99. '';
  100. };
  101. locations."~ ^/sites/.*/files/styles/" = {
  102. extraConfig = ''
  103. try_files $uri @rewrite;
  104. '';
  105. };
  106. locations."~ ^(/[a-z\-]+)?/system/files/" = {
  107. extraConfig = ''
  108. try_files $uri /index.php?$query_string;
  109. '';
  110. };
  111. };
  112.  
  113. # Redirect 'www' to 'non-www'
  114. # and set up certificate renewal for 'www'.
  115. "www.${domainname}" = {
  116. forceSSL = true;
  117. enableACME = true;
  118. globalRedirect = "${domainname}";
  119. };
  120. };
  121. };
  122.  
  123. # Set up a PHP-FPM pool for this website.
  124. services.phpfpm.pools = {
  125. ${sitename} = {
  126. user = "nginx";
  127. settings = {
  128. "listen.owner" = config.services.nginx.user;
  129. "pm" = "dynamic";
  130. "pm.max_children" = 32;
  131. "pm.max_requests" = 500;
  132. "pm.start_servers" = 2;
  133. "pm.min_spare_servers" = 2;
  134. "pm.max_spare_servers" = 5;
  135. "php_admin_value[error_log]" = "stderr";
  136. "php_admin_flag[log_errors]" = true;
  137. "catch_workers_output" = true;
  138. };
  139. phpEnv."PATH" = lib.makeBinPath [ pkgs.php ];
  140. };
  141. };
  142.  
  143. # Optionally set up a systemd service that will trigger
  144. # Drupal's cron URL.
  145. systemd.services."${sitename}-cron" = {
  146. path = [ pkgs.curl ];
  147. script = ''
  148. curl "https://${domainname}/${cronpath}"
  149. '';
  150. unitConfig = {
  151. Description = "Cron trigger for ${sitename}";
  152. };
  153. serviceConfig = {
  154. Type = "oneshot";
  155. User = "${cronuser}";
  156. };
  157. };
  158.  
  159. systemd.timers."${sitename}-cron" = {
  160. unitConfig = {
  161. Description = "Timer for ${sitename} cron trigger";
  162. RefuseManualStart = "no";
  163. RefuseManualStop = "no";
  164. };
  165. wantedBy = [ "timers.target" ];
  166. partOf = [ "${sitename}-cron.service" ];
  167. timerConfig = {
  168. OnCalendar = "${crontime}";
  169. Unit = "${sitename}-cron.service";
  170. Persistent = true;
  171. };
  172. };
  173. }

 

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

  1. { ... }: {
  2. imports = [
  3. # ... other imports
  4. ./nginx.nix
  5. ./nginx.mysite.nix
  6. ./mysql.nix
  7. ./mysql.mysite.nix
  8. ];
  9. # ... rest of the file
  10. }

 

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

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

Add new comment

The content of this field is kept private and will not be shown publicly.

Restricted HTML

  • Allowed HTML tags: <a href hreflang target> <em> <strong> <cite> <blockquote cite> <pre> <ul type> <ol start type> <li> <dl> <dt> <dd> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.

Get a quote in 24 hours

Wether a huge commerce system, or a small business website, we will quote the project within 24h of you pressing the following button: Get quote