WordPress deployment: super simple and super fast with nginx caching

WordPress (+plugins) is not exactly the most resource-efficient content management system. In order to go easy on CPU and memory, to guarantee short website load times, and to be able to stand up to page requests with high frequency, WordPress must be served from a cache rather than answering each request via PHP and from the database. In this post, you can find my nginx config for deploying PHP-FPM-based WordPress behind nginx whereas most of the requests are served from the nginx FastCGI cache. No changes to WordPress and no WordPress plugins are required.

On shared hosting platforms, caching on the WordPress application level is a reasonable solution (via plugins such as WP Super Cache). Application-independent caching (i.e. on the server level) however increases maintainability and performance. Numerous solutions are available. The FastCGI cache built into the nginx web server is one of them. It allows for caching any FastCGI application, such as PHP-FPM-based WordPress. Before activating the nginx FastCGI cache, my (weak) machine was able to answer about 10 WordPress page requests per second. With the cache, this number grew by 3 orders of magnitude to roundabout 7000 requests per second. Setting up PHP-FPM-based WordPress behind nginx is a simple task. If you are running Apache, I recommend not to fear the transition towards nginx. It is done quickly and you will like the nginx configuration system. It is well thought trough and easy to learn.

Contents:

1) Key points of the configuration we’re going to look at

  • Web application and environment:
    WordPress driven by PHP5 FPM and MySQL from the standard repositories of Ubuntu 12.04.
  • Web server and cache:
    Nginx (Brian’s nginx including the cache purge module). Installation:

    apt-get install python-software-properties
    add-apt-repository ppa:brianmercer/nginx
    apt-get update
    apt-get install nginx-custom
  • Caching details:

    • Generally, requests to the PHP5 FPM backend are cached and served from the cache via nginx’ FastCGI cache.
    • Caching parameters are configured based on return code (e.g. code 200 responses are generally served from the cache, whereas the cache is refreshed e.g. once per hour).
    • POST and HEAD requests as well as requests containing WordPress cookies are not cached. This way the cache does not interfer with the WordPress dashboard and commenters immediately see their comments.
    • The cache for certain request URIs can be manually deleted (and therefore refreshed on the next request) via calling http://domain.com/purgecache/request_uri, e.g. http://domain.com/purgecache/2013/01/blogpost-title/.

2) Nginx config

In this section, you can find my nginx config as of January 2013. I have enriched it with comments. However, I strongly recommend reading and understanding the nginx configuration documentation, in particular the caching options in the HttpFastcgiModule and this helpful discussion.

Note: Disclosing a web server configuration is a security risk. Therefore, I removed/changed certain parts irrelevant to the general setup. If you still find a problematic part in my config, please let me know rather than misusing it.

The main nginx config (/etc/nginx/nginx.cfg):

user www-data;
worker_processes 4;
pid /var/run/nginx.pid;
 
events {
    worker_connections 1024;
    }
 
http {
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 3;
    types_hash_max_size 2048;
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;
    gzip on;
    gzip_disable msie6;
    gzip_static on;
    gzip_comp_level 4;
    gzip_proxied any;
    gzip_types text/plain
               text/css
               application/x-javascript
               text/xml
               application/xml
               application/xml+rss
               text/javascript;
 
    index index.php index.html index.htm;
 
    # Define PHP FPM backend.
    upstream phpfpmbackend {
        server unix:/tmp/php5-fpm.sock;
        }
 
    # Define FastCGI cache.
    fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=wordpresscache:30m inactive=60m;
    fastcgi_temp_path /var/cache/nginx/tmp;
 
    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
    }

The PHP FPM backend is defined with the name phpfpmbackend. A FastCGI cache zone with the name wordpresscache is declared. These names are used in the website config below (/etc/nginx/sites-available/gehrckede.cfg):

# Redirect www.gehrcke.de to gehrcke.de.
server {
    server_name www.gehrcke.de;
    access_log off;
    return 301 $scheme://gehrcke.de$request_uri;
    }
 
# Redirect any other server name to gehrcke.de.
server {
    server_name  _;
    access_log off;
    rewrite ^ $scheme://gehrcke.de$request_uri redirect;
    }
 
server {
    server_name gehrcke.de;
    root /path_to/gehrcke_de_website/;
 
    error_log /path_to/nginx_logs/nginx_error_log.log error;
    access_log /path_to/nginx_logs/nginx_access_log.log;
 
    # Security: deny access to any files with a .php extension in
    # WP upload directory.
    location ~* /(?:uploads|files)/.*\.php$ {
        deny all;
        }
 
    # Try if static file exists. If not, hand over to WordPress index.php.
    location / {
        try_files $uri $uri/ /index.php?$args;   
        }
 
    # Deactivate access_log for WordPress monitoring site. Do not cache.
    location /status {
        access_log off;
        include fastcgi_params;
        fastcgi_pass phpfpmbackend;
        fastcgi_param SCRIPT_FILENAME $document_root/index.php;
        }
 
    # Add trailing slash to */wp-admin requests.
    rewrite /wp-admin$ $scheme://$host$uri/ permanent;
 
    location ~ \.php$ {
        fastcgi_pass phpfpmbackend;
        include fastcgi_params;
 
        set $wordpress_nocache "";
        if ($http_cookie ~ (comment_author_.*|wordpress_logged_in.*|wp-postpass_.*)) {
            set $wordpress_nocache "true";
            }
        if ($request_method ~ ^(HEAD|POST)$) {
            set $wordpress_nocache "true";
            }
 
        fastcgi_cache_use_stale error timeout invalid_header http_500;
        fastcgi_cache_key "$scheme$request_method$host$request_uri";
        fastcgi_cache wordpresscache;
 
        # Logging configuration. Log to two files:
        #   1) caching log, indicating whether a request was answered from
        #      cache or via PHP backend.
        #   2) normal access log for e.g. evaluation via AWStats.
        # The 'cache' logging definition prints warning on nginx restart (saying
        # it should be defined in HTTP block). However, there it does not work
        # properly. Here, it shows all information correctly.
        log_format cache '***[$time_local] '
            'upstream_cache_status: "$upstream_cache_status" '
            'Cache-Control: "$upstream_http_cache_control" '
            'Expires: "$upstream_http_expires" '
            'Request: "$request" Status: "$status" '
            'User agent: "$http_user_agent" ';
        access_log /home/gehrckede/nginx_logs/nginx_cache.log cache;
        access_log /home/gehrckede/nginx_logs/nginx_access_log.log;
 
        # These parameters have to be chosen for each project individually.
        fastcgi_cache_valid  200 302 304 1h;
        fastcgi_cache_valid  301 1h;
        fastcgi_cache_valid  any 5m;
        fastcgi_no_cache $wordpress_nocache;
        fastcgi_cache_bypass $wordpress_nocache;        
        }
 
    # Activate cache purge feature.
    # Request e.g. http://domain.com/purgecache/2013/01/blogpost-title/
    location ~ /purgecache(/.*) {
        fastcgi_cache_purge wordpresscache "$scheme$request_method$host$1";
        }   
 
    # Don't show favicon requests in access log.
    location = /favicon.ico {
        log_not_found off;
        access_log off;
        }
 
    # Deny all attempts to access hidden files such as .htaccess.
    location ~ /\. {
        deny all;
        }
 
    # Hide certain locations.
    location = /privatearea {
        return 404;
        }
 
    # Allow directory listing for certain locations.
    location /files/stud  {
        autoindex on;
        }   
    location /awsac/permstuff {
        autoindex on;
        }
 
    # Explicit redirects.
    location ^~ /gsoc {
        rewrite ^ $scheme://gehrcke.de/projects/google-summer-of-code redirect;
        }
 
    # PHP Monitoring.
    # These monitoring sites are configured in the php-fpm config.
    location ~ ^/(status_php5fpm|ping_php5fpm)$ {
        include fastcgi_params;
        fastcgi_pass phpfpmbackend;
        }
 
    # nginx monitoring.
    location /nginxstatus {
        stub_status on;
        access_log off;
        }
    }

3) Comparison: benchmark with and without caching

The following benchmark was executed with ab running on the same machine as the web stack (nginx, PHP, …). The machine has only 2 (virtual) CPUs, so the benchmark results have to be interpreted qualitatively.

Without caching:

$ ab -n 100 -n 10 http://gehrcke.de/
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
[...]
Server Software:        nginx/1.2.4
Server Hostname:        gehrcke.de
Server Port:            80
Document Path:          /
Document Length:        91079 bytes
Concurrency Level:      10
Time taken for tests:   8.228 seconds
Complete requests:      100
[...]
Requests per second:    12.15 [#/sec] (mean)
Time per request:       822.823 [ms] (mean)
Time per request:       82.282 [ms] (mean, across all concurrent requests)
Transfer rate:          1083.11 [Kbytes/sec] received
 
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       0
Processing:   548  791 180.7    793    1366
Waiting:      379  591 164.5    586    1136
Total:        548  791 180.7    793    1366
 
Percentage of the requests served within a certain time (ms)
  50%    793
  66%    860
  75%    896
  80%    910
  90%   1024
  95%   1150
  98%   1228
  99%   1366
 100%   1366 (longest request)

With caching:

$ ab -n 20000 -c 200 http://gehrcke.de/
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
[...]
Server Software:        nginx/1.2.4
Server Hostname:        gehrcke.de
Server Port:            80
Document Path:          /
Document Length:        91079 bytes
Concurrency Level:      200
Time taken for tests:   2.916 seconds
[...]
Requests per second:    6858.12 [#/sec] (mean)
Time per request:       29.163 [ms] (mean)
Time per request:       0.146 [ms] (mean, across all concurrent requests)
Transfer rate:          611202.76 [Kbytes/sec] received
 
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    3   1.0      3      11
Processing:     4   22   2.4     21      30
Waiting:        1    3   2.0      3      23
Total:          8   25   2.3     24      33
 
Percentage of the requests served within a certain time (ms)
  50%     24
  66%     25
  75%     26
  80%     27
  90%     28
  95%     30
  98%     31
  99%     31
 100%     33 (longest request)

~10 vs. ~7000 page requests per second: That’s why WordPress should be cached.

2 Pingbacks/Trackbacks

  • Alchemi.st

    Nice work! I’m going to add your cache logging stuff to my config as well. I also like the bit denying access to files that end with php in the uploads section.

  • Ovidiu

    I was also impressed by that idea and tried the above snippet but it doesn’t seem to stop it from happening. Can someone confirm that the above location works?
    Any idea why it doesn’t work on my system?

    • Ovidiu

      sorry for the confusion, I meant to reply to Alchemi.st’s comment about blocking execution of .php files from within the uploads folder.

      • Ovidiu, I will have a look into that. Actually, I also took that snippet from somewhere and have to re-validate.

        • Ovidiu

          This works:

          # Disallow PHP in upload folder single

          location /wp-content/uploads/{
          location ~ .php$ {
          deny all;
          }
          }

          location ~ /readme.(txt|html)$ {
          deny all;
          access_log off;
          log_not_found off;
          }

  • Ovidiu

    I need to ask you another question:
    I’m currently setting up a new server with almsot exactly the same configuration as yourself but when I do an ab test from my old server, I seem to somehow hit a socket limit although the requests should be served from fastcgi_cache.
    I don’t want to clutter your comment section so here is the link to my complete thread with all info: http://www.howtoforge.com/forums/showthread.php?t=60497

    I’d love to hear any ideas you might have.

  • Bryan

    So how do you automate flushing cache after new post, comment, or scheduled post?

    • Bryan, I am actually purging manually with the location implementing the fastcgi_cache_purge directive, i.e. /purgecache in the config abive. This could also be automated upon certain events (such as post publish) with the nginx-helper plugin: http://wordpress.org/extend/plugins/nginx-helper

      • Bryan

        Okay I see. I guess the other option is to set fastcgi_cache_valid 200 10s; and set the cache path to RAM (tmpfs) for speed efficiencies.

        • On a highly frequented site this might make sense, yes. Not true for my site :-)

  • Pingback: sectio aurea » Speed! Moaar Speed! – WordPress mit Nginx + fastcgi_cache + optional Domain Mapping()

  • Pingback: Configure FastCGI_Cache for WordPress and Nginx | Query Admin()