Multi-Version PHP with Nginx on NixOS: Containerized and Not

title image of blog post \
📆
September 4, 2023 by Jacek Galowicz

Two weeks ago I gave a class about server security at the linuxhotel. Although the class had nothing to do with NixOS, one of the participants asked me during a coffee break, how hard it would be to configure a web server with NixOS that provides multiple versions of PHP at the same time. Let’s have a look at how this is simple in NixOS. As an extra, let’s run the whole setup in a declarative systemd-nspawn container to fulfill last week’s promise!

Our Scenario: Nginx + Multiple PHP Versions

The scenario we are looking for looks like the following: We have an nginx webserver that forwards certain paths to certain versions of PHP engines. The user will then be able to access https://webserver/phpXY/foo.php and get their foo.php result page processed by a PHP engine of version X.Y:

Nginx webserver running with multiple PHP versions

All the code from this article is on GitHub: https://github.com/tfc/nixos-nginx-multiple-php-versions

Default Scenario: Nginx + One PHP Version

To see how to configure NixOS for our example scenario, let’s see first how to configure the default case. The NixOS Wiki shows how to set up Nginx with PHP: Nginx delegates the PHP processing to a PHP FastCGI Process Manager Process via a UNIX socket:

{ pkgs, lib, config, ... }:
{
  services.phpfpm.pools.phpdemo = {
    user = "phpdemo";
    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;
    };
  };
  services.nginx = {
    enable = true;
    virtualHosts."phpdemo.example.com".locations."/" = {
      root = dataDir;
      extraConfig = ''
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:${config.services.phpfpm.pools.phpdemo.socket};
        include ${pkgs.nginx}/conf/fastcgi_params;
        include ${pkgs.nginx}/conf/fastcgi.conf;
      '';
     };
  };
  users.users.phpdemo = {
    isSystemUser = true;
    createHome = true;
    home = "/var/www/root";
    group  = app;
  };
  users.groups.phpdemo = {};
}

In this configuration snippet, the services.phpfpm.pools.phpdemo attribute configures a PHP FPM process and the services.nginx attribute creates the nginx web service. Both run under the control of systemd. The interesting part here is where we explain nginx to connect to the PHP FPM process via the config.services.phpfpm.pools.phpdemo.socket attribute, which contains the path in the file system where the PHP FPM UNIX socket will reside.

Another sensible part of the PHP-FPM pool design in NixOS is, that this NixOS module already puts all PHP-FPM processes into the same systemd slice. This way, they reside in the same cgroup, which in turn gives them the same system resource limits as if it was just one PHP engine.

Beefing Up the Standard Scenario to Our Multi-PHP-Version Scenario

To support our desired scenario, we need to define multiple PHP FPM pools where the default scenario created just one. Each PHP FPM will run with its own PHP version and provide its own UNIX socket. Then, we need to explain nginx when to use which of those.

Nixpkgs provides multiple PHP versions in the same checkout. At the time of this writing, versions 8.0, 8.1, and 8.2 are provided. We could combine even older versions by overriding nixpkgs’s package definitions. Alternatively, we could provide multiple checkouts of older and newer nixpkgs definitions to get at different PHP versions. To not blow up the scope of this article, let’s just select the 3 PHP versions that are available in the current nixpkgs checkout.

How do we tell nginx to use different PHP versions? Let’s simply define 3 different path prefixes so that the user can select the PHP version via the URL. Nginx then drops the prefix and forwards to the same PHP script, which simply contains this:

<?php
// file: index.php

phpinfo();
phpinfo(INFO_MODULES);

?>

The complete NixOS config module can now be written like this:

# file: nginx-php.nix
{ config, lib, pkgs, ... }:

let
  wwwRoot = ./wwwroot; # contains index.php
  phpPkgNames = [ "php80" "php81" "php82" ];

  phpPools =
    let
      f = phpPkgName: {
        name = phpPkgName;
        value = {
          user = "php";
          phpPackage = pkgs.${phpPkgName};
          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" = 1;
            "pm.max_spare_servers" = 5;
          };
        };
      };
    in
      builtins.listToAttrs (map f phpPkgNames);

  nginxLocations =
    let
      f = phpPkgName: {
        name = "/${phpPkgName}";
        value = {
          root = wwwRoot;
          extraConfig = ''
            # drop the /phpXY/... prefix before pointing to the same WWW root
            rewrite ^/${phpPkgName}(.*)$ /$1 break;

            include ${pkgs.nginx}/conf/fastcgi_params;
            include ${pkgs.nginx}/conf/fastcgi.conf;
            fastcgi_pass  unix:${config.services.phpfpm.pools.${phpPkgName}.socket};
            fastcgi_index index.php;
            fastcgi_split_path_info ^(.+?\.php)(|/.*)$;
            fastcgi_param SCRIPT_FILENAME ${wwwRoot}$fastcgi_script_name;
          '';
        };
      };
    in
      builtins.listToAttrs (map f phpPkgNames);
in
{
  nixpkgs.config.permittedInsecurePackages = [
    "openssl-1.1.1v"
  ];

  services = {
    phpfpm.pools = phpPools;
    nginx = {
      enable = true;
      virtualHosts.localhost.locations = nginxLocations;
    };
  };
  users.users.php = {
    isSystemUser = true;
    group  = "php";
  };
  users.groups.php = {};
}

There’s not much difference to the original scenario apart from:

Let’s Build a VM from it

In addition to the nginx+PHP NixOS module, which already describes very well how we intend to have nginx and PHP running as systemd services, we need another NixOS module that describes the rest of the system. It is very nice that such implementation details which are mostly orthogonal to each other, can be split up in multiple files. This way we can reuse the same nginx+PHP NixOS module in a VM, on a bare-metal server, a container, or whatever can be expressed with NixOS configurations.

Of course, we could simply import this module on a running NixOS system and then run nixos-rebuild switch, but we don’t assume that all readers are already running NixOS.

So what’s the fastest way to try our NixOS module out, for both NixOS and non-NixOS users? It’s a VM that can be built and run in a minute, which forwards the nginx port to the host that runs the VM, which will work on any GNU/Linux machine with KVM. This could look like the following:

# file: qemu-vm.nix
{ config, modulesPath, ... }:

{
  imports = [ (modulesPath + "/virtualisation/qemu-vm.nix") ];

  virtualisation.forwardPorts = [
    { from = "host"; host.port = 8080; guest.port = 80; }
    { from = "host"; host.port = 2222; guest.port = 22; }
  ];

  networking.firewall.enable = false;

  users.users.root.initialPassword = "";

  services.openssh.enable = true;
  services.openssh.settings.PermitRootLogin = "yes";

  system.stateVersion = "23.05";
}

This module does:

We can now combine both these NixOS modules in a Nix flake to build and run a VM, as it happens in the flake.nix of this project.

We can now simply run:

# run from local checkout:
nix run .#vm

# run without cloning the repo:
nix run github:tfc/nixos-nginx-multiple-php-versions#vm

Logging in and running systemctl status reveals:

NixOS VM with multiple versions of PHP running

Wow, it seems to work! We can now check if we get the same index.php processed by different versions of PHP by running multiple queries from the browser:

Multiple Browser Queries with different PHP prefixes on Our Webserver Show Different PHP Engine Version Numbers

It seems so flawless and polished from here. We’re done!

Containerize It with systemd-nspawn

We could now build a NixOS systemd-nspawn container image as we did in last week’s article, but let’s see this time how to do it declaratively without creating a container tarball in between.

NixOS provides declarative containers that feel exactly like any other service in a NixOS configuration module:

{ ... }: {
  # NixOS config of within the VM
  containers.webserver = {
    autoStart = true;
    privateNetwork = false;
    config = ./nginx-php.nix
  };
}

This config snippet creates an attribute containers.webserver which is configured to be run automatically after boot and use the host’s network. The configuration of the container in turn imports the configuration file nginx-php.nix that we defined earlier. That’s it!

It’s also already defined in the same flake.nix file, so we can run it from the attribute name vm-containered:

# run from local checkout:
nix run .#vm-containered

# run without cloning the repo:
nix run github:tfc/nixos-nginx-multiple-php-versions#vm-containered

Logging in and running systemctl status shows us a similar picture as before, but this time we see that the whole scenario is wrapped inside a systemd-nspawn container:

NixOS VM where systemctl status Show the Same Scenario Inside a systemd-nspawn Container

It’s wonderful, how composable NixOS modules are.

Summary

So the question “How simple is it to run a web server on NixOS with different versions of PHP?” can be answered with “Yes. It’s simple”, although 80% of this article was about peripheral knowledge (how to test it in a VM, how to containerize, etc.).

Is it better than running on Docker/Podman? It depends on what we need, but I would argue - Yes, because:

What were the hard parts about it?

If you find this useful and could need it at work, consider booking Nixcademy’s Nix & NixOS 101 class!