Matching socks: Nginx + php = WordPress (Part 3)

Photo by Alfred Rowe on Unsplash

Part 1: WordPress hosting, docker style
Part 2: Cron + LetsEncrypt, docker style
Part 3: Matching socks: Nginx + php = WordPress

Previously, we’ve covered terminating SSL connections and running cron jobs. Now it’s time to actually set up a wordpress installation. The two main ingredients are a web server, and a php server. All requests go through the web server (nginx, again in this case). If the filepath ends in a .php extension, then the request gets forwarded to the php-fpm (basically php with a FastCGI implementation) to do the server-side processing.

Static files – nginx style

The basic setup is pretty straightforward. First, set up a root directory, and then serve location / like so:

server {
    listen 8080 default_server;
    server_name localhost;
    root /var/www/html;
    location / {
    }

Combine that snippet with docker-compose to store the wordpress files in /var/www/html and that’s all the basic ingredients needed for hosting a static site

www:
    build:
      context: ./nginx
    restart: always
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - ./www:/var/www:ro

Now with more sockets

Now we need to forward our php files to the php container. To do so, I mostly followed this tutorial for setting up a unix socket. First, in the Dockerfile, set up the docker user, and give it access to the phpsocket file:

FROM nginx:1.19-alpine

RUN addgroup -g 1000 -S docker
RUN adduser -u 1000 -S -G docker docker

RUN mkdir -p /phpsocket
RUN touch /var/run/nginx.pid \
  && chown -Rf docker:docker \
  /var/run/nginx.pid \
  /var/cache/nginx \
  /var/log/nginx \
  /phpsocket

USER docker

We create a new docker user and group, and take ownership of some files. The /phpsocket folder doesn’t exist, yet. We’ll link it through docker-compose. However, in order to set the permissions correctly, we create a local folder and chown. When docker-compose links in the volume, it will keep the same permissions. Speaking of docker-compose, let’s create a volume, and then link it in to the container:

www:
    build:
      context: ./nginx
    restart: always
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - ./nginx/php_common.conf:/etc/nginx/snippets/php_common.conf:ro
      - ./www:/var/www:ro
      - phpsocket:/phpsocket
    networks:
      - www-network
volumes:
    phpsocket:

To summarize so far, docker-container creates a virtual volume called phpsocket. This is then mounted into /phpsocket in the docker container. We’ve modified our docker container to create a new user and run our code as this new user.

The last step is to consume this socket and pass data to it for php files. Let’s take a look at the above-referenced php_common.conf file to see how that works:

fastcgi_pass unix:/phpsocket/php-fpm.sock;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_intercept_errors on;

To be perfectly honest, I’m not entirely sure what all of these options do, or if they’re strictly needed. Long live the copy/paste! However, the important bit is the fastcgi_pass directive. This tells nginx to pass (forward) data to a unix socket hosted at /phpsocket/php-fpm.sock. We’ll see in the php container how to set that up… but for now, we need to consume these directives in our nginx default.conf

# any URI without extension is routed through PHP-FPM (WordPress controller)
location ~ ^[^.]*$ {
    fastcgi_param SCRIPT_FILENAME $document_root/index.php;
    include "/etc/nginx/snippets/php_common.conf";
}

For every matching location, we set the SCRIPT_FILENAME to match the .php file we want to run, and then just import the php settings.

Hello, php!

Now we need to set up the socket on the php side. Similarly, we create a new user with the same group and user name (important!)

RUN addgroup -g 1000 -S docker
RUN adduser -u 1000 -S -G docker docker

RUN mkdir -p /phpsocket
RUN chown -Rf docker:docker \
  /phpsocket

USER docker

And in the www.conf file, instruct php-fpm to listen from that socket, using the corresponding user

[www]
listen=/phpsocket/php-fpm.sock
listen.owner=docker
listen.group=docker
listen.mode=0660

That’s it!

Adding the database – ez mode

Of everything else in the tutorial, this is the straightforward part. I used mariaDB and just mounted the data folder from the host so it’s persistent. There’s not even a custom Dockerfile

db:
    image: mariadb:10.5-focal
    restart: 'on-failure'
    env_file:
      - ./secrets/db.env
    volumes:
      - ./db/data:/var/lib/mysql
    networks:
      - sql-network

Just make sure to set the right environment variables in the .env file and you’re all set

MYSQL_ROOT_PASSWORD=put_a_real_password_here
MYSQL_DATABASE=wordpress
MYSQL_USER=wordpress
MYSQL_PASSWORD=put_a_different_password_here

Okay, that’s not it there’s more

So that’s the basics, but getting everything to work took more trial and error. Among other things, I

  • Updated the php.ini as well as the Dockerfile to allow 5mb uploads (for larger images). See upload_max_filesize and client_max_body_size respectively.
  • Changed nginx.conf to remove the user nginx; directive
  • Added some security settings to nginx to limit exposure to some wordpress files. I followed a few different tutorials, including this one.
  • Added support to run wp-cron every 10 minutes. TBH, I’m not happy with my current implementation, so I won’t expand on how it works right now

Last thought: git

I went back and forth on the right way to track wordpress files in source control. I definitely wanted some tracking, since I was making changes to things like the theme. Where I landed was

  • Keep a secrets folder for things that shouldn’t be checked in (e.g. wp-config.php) and then mount those files individually
  • Save the rest of wordpress, except the uploads folder, as well as the backups folder. Instead, this is covered by the backups, just-in-case

My .gitignore looks like this

wp-config.php

# Don't save database files
/db/data/*
!/db/data/.keep

# Dont save wordpressbackup files
/www/html/wp-content/updraft/*
!/www/html/wp-content/updraft/web.config

# Don't save uploads folder (this is saved in backups)
/www/html/wp-content/uploads/*

# Don't save the folder used to download wordpress upgrades
/www/html/wp-content/upgrade/*

Default.conf in all its glory

server {
    listen 8080 default_server;

    server_name localhost;

    root /var/www/html;

    index index.php;

    location = /index.php {
        return 301 /;
    }

    location = /wp-admin {
        return 301 /wp-admin/;
    }

    location = /favicon.ico {
        log_not_found off;
        access_log off;
    }

    location = /robots.txt {
        log_not_found off;
        access_log off;
    }

    #Deny access to wp-content folders for suspicious files
    location ~* ^/(wp-content)/(.*?)\.(zip|gz|tar|bzip2|7z)\$ { deny all; }
    location ~ ^/wp-content/updraft { deny all; }

    location / {
        # any URI without extension is routed through PHP-FPM (WordPress controller)
        location ~ ^[^.]*$ {
            fastcgi_param SCRIPT_FILENAME $document_root/index.php;
            include "/etc/nginx/snippets/php_common.conf";
        }

        # allow only a handful of PHP files in root directory to be interpreted
        # wp-cron.php ommited on purpose as it should *not* be web accessible, see proper setup
        # https://www.getpagespeed.com/web-apps/wordpress/wordpress-cron-optimization
        location ~ ^/wp-(?:comments-post|links-opml|login|mail|signup|trackback)\.php$ {
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            include "/etc/nginx/snippets/php_common.conf";
        }

        location = /wp-cron.php {
            # This is directly called via cron to a php process, bypassing nginx
            return 403;
        }

        location = /uptime.php {
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            include "/etc/nginx/snippets/php_common.conf";
        }

        location ^~ /wp-json/ {
            fastcgi_param SCRIPT_FILENAME $document_root/index.php;
            include "/etc/nginx/snippets/php_common.conf";
        }

        # other PHP files "do not exist"
        location ~ \.php$ {
            return 404;
        }
    }

    location /wp-admin/ {
        client_max_body_size 5M;
        index index.html index.php;

        location = /wp-admin/admin-ajax.php {
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            include "/etc/nginx/snippets/php_common.conf";
        }

        # numerous files under wp-admin are allowed to be interpreted
        # no fancy filenames allowed (lowercase with hyphens are OK)
        # only /wp-admin/foo.php or /wp-admin/{network,user}/foo.php allowed
        location ~ ^/wp-admin/(?:network/|user/)?[\w-]+\.php$ {
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            include "/etc/nginx/snippets/php_common.conf";
        }

    }

    location /wp-content/ { 
        # hide and do not interpret internal plugin or user uploaded scripts
        location ~ \.php$ {
            return 404;
        }
    }

    # hide any hidden files
    location ~ /\. {
        deny all;
    }
}