Still blogging like a confused hacker!

My PHP/WordPress Application Server Stack in 2024

DevOps | WordPress

This post was inspired by a conversation I was having with a friend. I was debugging an issue in their htaccess file when I casually mentioned that I haven’t actually used Apache on my own projects for about 15 years, so the only time I see it is to fix something that’s broken. It does leave me with a somewhat tainted view, but they were interested in my current stack.

Every few years, I like to sit and rethink what my site is, how it’s developed and how I write content for it. This means the site, its architecture and content have been through many iterations. For a long period the site had been statically generated. You can read about the approach on Blog Like a Confused Hacker, whose post title, in turn, came from an older post with the same name, which was an homage to the opening post on the Jekyll blog, Blog Like a Hacker. 

So, is this still a static site? 

Spoiler, no

The advantages of static sites are that they are very fast and should be quite secure; after all, they are just HTML. It’s hard to attack a site with no dynamic content, no logins, etc. Don’t get me wrong; it’s doable but for limited gains. I was able to run the site as a static site as it was effectively a brochureware/blog site. It had a contact form, and that was it in terms of dynamic content.

However, over the last two years, the site has moved back to a more traditional WordPress site, sort of.

So, what powers this site in 2024, and should you consider this stack for yourself?

Under the hood

  • Caddy – HTTP Server
  • PHP-FPM
  • Memcached
  • MariaDB (External cluster)
  • Tailscale – SSH/VPN

A few of those won’t surprise you, but a couple might, so let’s dive into each and why I chose this particular application stack in 2024.

Caddy

If I had to guess the two things most people reading this won’t be too familiar with it would be Caddy and Tailscale (which we will get to later). 

Caddy is an HTTP server (though it does a lot more) like Apache and Nginx. In terms of features and the way it works, it’s closer to Nginx than Apache. However, if you are familiar with OpenResty (an Nginx on steroids), this is probably an even closer match.

Its big selling features are its speed and that it has a bunch of niceties that we all use every day out of the box. For example, it auto-provisions SSL certificates, so no more certbot and weird cron jobs and trying to remember what folder and file permissions everything should have. Now you can auto-provision with Nginx if you are using the right modules or something like OpenResty, but Caddy just does it out of the box.

It has a very simple config system, with an all-in-one config file called the Caddyfile (though I actually split mine into multiple files; old habits die hard) and lots of flexibility in how you set up a server.

While it does support multiple hosts and domains, so you can run your mega 1000-site hosting company off it, it is very much aimed at the one-site model. While I have a separate server with 10 or so sites on it for small side projects, most of the time, I use Caddy as a one-site. 

So, what does this look like? Well, here is an example of what a site in a Caddyfile might look like:

timnash.co.uk {
    	# Set this path to your site's directory.
    	root * /var/www/timnash.co.uk/public
    # Enable PHP FPM
    	php_fastcgi unix//run/php/php8.2-fpm.sock
    #Zip it to make it faster… maybe
    encode zstd gzip
    	# Enable the static file server.
    	file_server

    # Headers
    header {
   	 Strict-Transport-Security    "max-age=31536000; includeSubDomains; preload"

    }

    @cache {
   	 not header_regexp Cookie "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_logged_in"
   	 not path_regexp "(/wp-admin/|/xmlrpc.php|/wp-(app|cron|login|register|mail).php|wp-.*.php|/feed/|index.php|wp-comments-popup.php|wp-links-opml.php|wp-locations.php|sitemap(index)?.xml|[a-z0-9-]+-sitemap([0-9]+)?.xml)"
   	 not method POST
   	 not expression {query} != ''
   	 }
    header @cache X-CACHE "STATIC"
    route @cache {
   	 try_files /wp-content/cache/cache-enabler/{host}{uri}/https-index.html /wp-content/cache/cache-enabler/{host}{uri}/index.html {path} {path}/index.php?{query}
    }
    # Set content to be blocked this will be expanded
    	@disallowed {
   	 not path /wp-includes/ms-files.php
   		 path /wp-admin/includes/*.php
      path /wp-includes/*.php
            	path *.sql
            	path /wp-content/uploads/*.php
		path /wp-content/uploads/cache/
    	}
    # Set content to be blocked in public interface
    @authed {
   	 not path /wp-admin/admin-ajax.php
   	 path /wp-admin/
   	 path /wp-login.php
   	 path /xmlrpc.php
    }
    respond @authed "<h1>Access Denied</h1>" 401
    # Set Static assets Cache Header
    @static {
      file
      path *.ico *.css *.js *.gif *.webp *.avif *.jpg *.jpeg *.png *.svg *.woff *.woff2
    }
    header @static Cache-Control max-age=5184000

    	# Set Pretty Permalink
    	rewrite @disallowed '/index.php'
    # Access logging in the combined format
    log {
   	 output file /var/log/sites/timnash.co.uk/caddy-access.log {
   		 roll_size 20mb
   		 roll_keep 5
   		 roll_keep_for 720h
   	 } 
    }
}

That’s a lot!

A lot of that file is taken up with my caching solution, which we will talk about in a bit, but if we wanted to strip it down to just the essentials:

timnash.co.uk {
     	# Set this path to your site's directory.
    	root * /var/www/timnash.co.uk/public
    # Enable PHP FPM
    	php_fastcgi unix//run/php/php8.2-fpm.sock
    	# Set content to be blocked this will be expanded

    	@disallowed {
            	path /xmlrpc.php
            	path *.sql
            	path /wp-content/uploads/*.php
    	}

    	# Set Pretty Permalink
    	rewrite @disallowed '/index.php'

       # Access logging in the combined format
    log {
   	 output file /var/log/sites/timnash.co.uk/caddy-access.log {
   		 roll_size 20mb
   		 roll_keep 5
   		 roll_keep_for 720h
   	 }
    }
}

You technically don’t have to keep logs… please keep logs.

A couple of things I have done, if you take a look at my full file, is that I have a cache system and some extra security steps to access my admin area.

Caching and Caddy

By default, and unlike Nginx, Caddy doesn’t have an inbuilt CGI cache; this is one of the biggest out-of-the-box features of Nginx. Point the fast-CGI cache at a RAM disk, and you have a very fast caching system. Caddy does have a third-party module that supports this sort of thing, but this brings us to a second issue: Caddy modules can be clunky.  Installing them is similar to Nginx requiring recompiling of Caddy, though it does have a helper system called xcaddy. But keeping modules up to date (especially if you are building from operating system repos) is not trivial as such because I am lazy I try to avoid installing modules that are not bundled.

So, for my own site, I have gone old school, and I use *gasp* a plugin!

Well, a plugin and some HTTP server wizardry. For the plugin, I use Cache Enabler, which generates HTML versions of pages on the front end of the site (just like a static site would). Then Caddy checks to see if the file exists and serves from there instead of making a call to WordPress at all. This means that 99% of visits to the site from non-logged-in users are hitting the file cache and being served static HTML.

 @cache {
   	 not header_regexp Cookie "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_logged_in"
   	 not path_regexp "(/wp-admin/|/xmlrpc.php|/wp-(app|cron|login|register|mail).php|wp-.*.php|/feed/|index.php|wp-comments-popup.php|wp-links-opml.php|wp-locations.php|sitemap(index)?.xml|[a-z0-9-]+-sitemap([0-9]+)?.xml)"
   	 not method POST
   	 not expression {query} != ''
   	 }
    header @cache X-CACHE "STATIC"
    route @cache {
   	 try_files /wp-content/cache/cache-enabler/{host}{uri}/https-index.html /wp-content/cache/cache-enabler/{host}{uri}/index.html {path} {path}/index.php?{query}
    }

Now, there are times we don’t want the cache, such as if you are making a POST request or if the user has commented/logged in. For my own site, I actually could tighten the above up a lot as this config is never used by a logged-in user, and locations like wp-admin/xmlrpc/wp-login are just not accessible, but why is that?

Tailscale & Caddy

I have actually written fairly extensively about Tailscale and Caddy and how I use the two together. The TL;DR: Tailscale is a VPN mesh network that allows you to treat machines like they are on their own subnet. By binding different Caddy configs to the public or Tailscale IPs, respectively, I serve different versions of the site.

So, if you are logged on to my Tailnet, you can access the wp-admin, and if you are not, you can’t. 

So, within the Caddyfile, you can see:

 # Set content to be blocked in public interface
    @authed {
   	 not path /wp-admin/admin-ajax.php
   	 path /wp-admin/
   	 path /wp-login.php
   	 path /xmlrpc.php
    }
    respond @authed "<h1>Access Denied</h1>" 401

which denies access to those locations. However, the Tailscale version of the Caddyfile does not have this and so if you are on the Telnet, you can access wp-admin and make xmlrpc requests. 

In addition, the Tailnet version points to a different PHP socket; talk about that in a minute.

So I think that’s Caddy, let’s talk PHP…

PHP-FPM, still the CGI king?

While I have been playing with FrankenPHP, and I wouldn’t be surprised if the next version of this article, I’ll have moved over to using it over PHP-FPM on the site, right now, it’s PHP-FPM running PHP8.2. 

There isn’t much to say other than I have two separate pools. The pool visitors’ access is dynamic, uses a relatively small amount of memory per worker, and has very low timeouts. PHP is very rarely hit on my site, and when it is, it shouldn’t be doing anything other than handling a post request or putting a page into memory.  

The second config has a lot more memory, higher timeouts, and different logging levels. It is also a single static worker, so it is always available. This means that even if the site is struggling, I should always have access to the backend. 

The second worker also sets a predefined constant, allowing PHP and therefore WordPress to know it is being accessed via that PHP-FPM pool. I use this in combination with my Tame Sessions Defaults Plugin, and if it detects that it’s not on a Tailnet session, it will destroy the session. This combination of tools should mean you have to always be on the Tailnet to have access as a user as you have to have an IP from the subnet, and you have to have come through the subnet to hit this PHP pool.

I have some plans for making changes to my PHP setup, mainly around this dual config setup, which I think is a really nice way to separate concerns. One current experiment (not on this site yet) is to run different database users for each PHP setup, effectively making the non-Tailnet version of the site truly read-only. Likewise, the PHP users are already different, and the non-Tailscale PHP user only has read access to the site. 

Memcached

I’m not going into too much detail. I use Memcache as my caching mechanism, and the WordPress object cache is stored here. Memcache might seem out of place, given that most recommendations you see are for Redis. But honestly, for what I use it for, which is to act as a memory layer for the object cache, Redis is just more effort. It has lots of really useful features if you are working on distributed solutions.

The other reason is I am working on a fairly memory-constrained Linux container, so I want something that has as little footprint for itself as possible. 

I have considered something like Dragonfly, which provides a drop-in API interface for Redis and Memcache and claims improved performance and a lower memory footprint. However, these tests are against larger instances, and on a micro instance like this, there seemed to be a larger, not smaller, footprint.

The second route I would have taken is to throw everything into the PHP OPcache using the drop-in OPcache cache plugin. However, while I have very successfully got this working in the past, it just refused to work, and I never had time to revisit it. This is very much on my to-do list to look at, and this is most likely the route I will take, removing memcache from my stack entirely.

MariaDB, I hate being a DB admin, so I outsourced!

Database Administration is like Mail Administration: a dark art that anyone who sysadmins knows a little about but also recognises as a very special profession. Recently, I decided that just like I outsource my email, I should outsource my database management.

As such, my database setup is a 3-node cluster on Linode Managed Databases. Behind the scenes, it’s a Galera cluster, and it supports all the sites I run on Linode in that cluster, each in individually provisioned databases. The cluster is locked down and can only be accessed by the nodes I want inside the Linode network. Linode handles all the management, and beyond taking backups after the initial setting up of a database, I do nothing.

It is glorious.

The only changes needed to WordPress are to make sure it knows it’s running over an SSL connection. So, in the wp-config.php, the following needs to be added.

/** Enable connection over SSL */
define('DB_SSL', true);
define( 'MYSQL_CLIENT_FLAGS', MYSQLI_CLIENT_SSL );

Beyond that, it’s just a normal MySQL server. Latency is not too bad, though it can be noticeable, and I would strongly recommend putting object cache and transients locally. For my site and other sites I manage, it’s fine, especially with the relatively heavy use of static caching I use.

Other Stuff?

Let’s see, we talked about Tailscale as a VPN but I also use a couple of other features: Tailscale SSH and Tailscale share. Tailscale SSH not only locks down my SSH to only being on my Tailnet but also provides ACL (access control) and a bunch of other nice features. Tailscale share let’s me point to a location and create a WebDAV file share of that path. This is what I use to provide temporary access to the filesystem for backups. Again, this has access controls, so only my backup server can access it.

Beyond that, the site is on an Ubuntu 20.04LTS release. It is provisioned with Ansible and updated daily. Backups use Borgbackup.

What else, secret management? I’ll leave that for another post.   

So, a couple of questions I’m often asked

How much does it cost?

timnash.co.uk runs on a single nano instance Linode at about $10/month. It does make use of a few other services shared between multiple sites, and these are:

  • Managed Database – $70/month but is shared by about 20 sites, so let’s say $5/month
  • DNS is DNS Made Easy – it is an annual subscription (and coming up for renewal, and I’m strongly considering moving), but it’s about $70/year, though has multiple sites on it, so let’s say $1/month
  • SendGrid – provides transaction email and, you guessed it, is shared by multiple sites, but again, let’s call it $1
  • Updown.io for external uptime monitoring the cost is negligible as I buy in credits once a year for again multiple sites.
  • My own infrastructure, which is a hodgepodge of at-home Hertzner box and VPS on Linode, but they are part of my broader infrastructure.

So the total is probably around $17-18 a month.

I also make use of a couple of free services I want to shout out:

  • Cabin provides analytics: I’m on a grandfathered plan, but they provide really nice analytics options for privacy and sustainability enthusiasts.
  • For the newsletter Buttondown, again, I’m grandfathered onto a tier. While I had some issues setting it up (https://timnash.co.uk/lessons-learnt-migrating-from-mailchimp-to-buttondown/), I really like the minimal interface of Buttondown. 

What I don’t have is a CDN setup. Over the years, I have used folks like KeyCDN and CloudFront and dabbled with CloudFlare, but honestly, my site is fast. It serves a UK-centric audience on the whole (big hello, if you are from elsewhere) and mostly static content. A proxy server wouldn’t benefit and probably harm performance, and for files, potentially, there would be some benefits in a CDN, but it’s marginal and not worth the cost and management pain.

Is it worth it?

Timnash.co.uk is a pet. It’s my site, and it will always be a little bit special. Sometimes, it will be broken. It’s the place to experiment and innovate, so yes, it’s worth it. I probably spend less than an hour a month doing maintenance on the site and server, so there is not much time sink. 

I really like Caddy and would strongly recommend you check it out. You are unlikely to find a managed host that supports it, but I suspect that if you give it a couple of years, you might. 

So now it’s over to you!

Tell me what you would do differently, what changes I could make and about your setups!

I like writing these posts because I always learn new and interesting things and ways to do things, and I love reading about other people’s setups.

Helping you and your customers stay safe


WordPress Security Consulting Services

Power Hour Consulting

Want to get expert advice on your site's security? Whether you're dealing with a hacked site or looking to future-proof your security, Tim will provide personalised guidance and answer any questions you may have. A power hour call is an ideal starting place for a project or a way to break deadlocks in complex problems.

Learn more

Site Reviews

Want to feel confident about your site's security and performance? A website review from Tim has got you covered. Using a powerful combination of automated and manual testing to analyse your site for any potential vulnerabilities or performance issues. With a comprehensive report and, importantly, recommendations for each action required.

Learn more

Code Reviews

Is your plugin or theme code secure and performing at its best? Tim provides a comprehensive code review, that combine the power of manual and automated testing, as well as a line-by-line analysis of your code base. With actionable insights, to help you optimise your code's security and performance.

Learn more

Or let's chat about your security?

Book a FREE 20 minute call with me to see how you can improve your WordPress Security.

(No Strings Attached, honest!)