Matching socks: Nginx + php = WordPress (Part 3)
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
andclient_max_body_size
respectively. - Changed
nginx.conf
to remove theuser 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;
}
}