Multi-Version PHP with Nginx on NixOS: Containerized and Not
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
:
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:
- We have multiple
services.phpfpm.pools...
definitions now, one for each PHP version. To implement this, we mapped the list of PHP package names as they exist in nixpkgs over a function that accepts the package and returns a whole PHP FPM pool definition for it. In each of them, thephpPackage
attribute is the only difference. - For every path prefix
/phpXY/...
, we create its own nginx location attributeservices.nginx.virtualHosts.localhost.locations...
. Again we map all PHP package names over a function that accepts a PHP package name and returns us the whole nginx location configuration as a nix attribute set. Each of these locations only differs in its path prefix and the selected UNIX socket of the PHP engine.
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;
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "yes";
PermitEmptyPasswords = "yes";
};
};
security.pam.services.sshd.allowNullPassword = true;
system.stateVersion = "23.05";
}
This module does:
- Import the default NixOS module for Qemu VMs
- Forward the host TCP port
8080
to the guest port80
for HTTP, as well as the host’s2222
to the guest’s22
for SSH. - Disable the firewall completely
- Enable SSH server, set an empty root password, and enable root logins via SSH.
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:
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:
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:
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:
- We didn’t need to install Docker or Podman, not even Qemu for the VM.
- We didn’t need to worry about where the Docker containers come from or if they are reproducible. (i.e. if we put this on GitHub, will it still work in 2 years?)
- We could easily re-use the same NixOS configuration snippet for a real-life setup, a VM, a declarative systemd-nspawn container, a systemd-nspawn container tarball, and whatnot.
- The different PHP-FPM processes already run within the same systemd slice, so no matter how many PHP versions we run, they get the same system resource limits as only one instance.
What were the hard parts about it?
- NixOS module knowledge: How to set up nginx? How to combine it with PHP-FPM?
Where do the UNIX sockets come from? How do NixOS containers work?
- I knew my way around here from experience and my experience comes from reading the NixOS documentation and the NixOS Wiki.
- Modeling the NixOS module in Nix, which is a functional language with its peculiarities and thinking.
If you find this useful and could need it at work, consider booking Nixcademy’s Nix & NixOS 101 class!