January 20, 2016

How to Set Up Nginx Cache For Your Laravel / PHP App In 5 Mins

If you have been developing Laravel apps, then you should be quite familiar with the Nginx + PHP-FPM stack as they are part of the default Homestead/Valet stack as provided by Laravel. One of the cool features with Nginx that you may not know is that, you can easily configure it to cache your dynamic content to speed up your website by saving a few database hits and disk i/o operations.

Now you may use Nginx to cache dynamic pages or in my case, I use it to cache my images that are resized dynamically. I don’t want to manage the hard copies of different image sizes as it can get out of control really quickly. So I use PHP to resize the image dynamically based on GET params, and have Nginx cache it and serve from cache instead.

Let’s get started!


Setting Up The Nginx Conf File

Your default /etc/nginx/sites-available/domain.com conf file should look like this:

server {
    listen 80;
    server_name domain.com www.domain.com;
    root "/var/www/domain/public";

    index index.html index.htm index.php;

    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

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

    access_log off;
    error_log /var/log/nginx/domain.com-error.log error;

    sendfile off;

    client_max_body_size 100m;

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/var/run/php5-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_intercept_errors off;
        fastcgi_buffer_size 16k;
        fastcgi_buffers 4 16k;
    }

    location ~ /\.ht {
        deny all;
    }
}

Your new /etc/nginx/sites-available/domain.com conf file should look like this:

# Add these 2 lines
fastcgi_cache_path /var/www/domain.com/storage/nginx/cache levels=1:2 keys_zone=DOMAIN_CACHE:10m max_size=100m inactive=1h;
add_header X-Cache $upstream_cache_status;

server {
    listen 80;
    server_name domain.com www.domain.com;
    root "/var/www/domain/public";

    index index.html index.htm index.php;

    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

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

    access_log off;
    error_log  /var/log/nginx/domain.com-error.log error;

    sendfile off;

    client_max_body_size 100m;

    # Add these 4 lines
    set $no_cache 1;
    if ($request_uri ~* "/get/image/") {
        set $no_cache 1;
    }

    location ~ \.php$ {
        # Add these 9 lines
    	fastcgi_cache_key $scheme$host$request_uri$request_method;
    	fastcgi_cache DOMAIN_CACHE;
    	fastcgi_cache_valid 200 1h;
    	fastcgi_cache_use_stale updating error timeout invalid_header http_500 http_503 http_404;
    	fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
    	fastcgi_cache_bypass $no_cache;
    	fastcgi_no_cache $no_cache;

        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/var/run/php5-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_intercept_errors off;
        fastcgi_buffer_size 16k;
        fastcgi_buffers 4 16k;
    }

    location ~ /\.ht {
        deny all;
    }
}

Nginx Configurations Explained

Core Nginx Cache Settings

fastcgi_cache_path /var/www/domain.com/storage/nginx/cache levels=1:2 keys_zone=DOMAIN_CACHE:10m max_size=100m inactive=1h;

First we tell Nginx that, look, please set up our cache under this folder /var/www/domain.com/storage/nginx/cache. This can be any folder as long as Nginx has write access to it. Following Laravel 5+’s convention, I decided to put it under the storage folder as part of the app. Some may prefer to put it somewhere else like /usr/share/nginx/cache/ or /tmp/nginx/cache. Ultimately, choose a path that makes sense to you.

Then, we tell Nginx that the folder structures would have 2 levels. Essentially, this means that the cache would be saved as /storage/nginx/cache/level1/level2/cached_files. If you foresee yourself caching a lot of different files, that it makes sense to increase the folder level. Generally, a rule of thumb is that a folder should contain not more than 2,000 files. So do increase the levels as you see fit.

Next, we tell Nginx that, we will be saving the keys for the cached contents under this zone called DOMAIN_CACHE, and it should have a maximum size of 10 Megabytes. Think of this as a small in-memory datastore where Nginx checks if it has the requested content cached, and if it does, where is the cached data located at... which will be located in one of the folders under /var/www/domain.com/storage/nginx/cache. Again, if you are dealing with a lot of files, increase this limit as you see fit.

Finally, we tell Nginx that our cached data should not exceed 100 Megabytes, and please expires the cached content if it has not been accessed for more than 1 hour.

Set Up A Header

add_header X-Cache $upstream_cache_status;

Adding this allows us to see if we are hitting cache via the response header. It is optional but it helps a great deal in troubleshooting any cache-related issues. Keep it there.

Define The Matching Paths for Caches

set $no_cache 1;
if ($request_uri ~* "/get/image/") {
    set $no_cache 0;
}

Here, I do not want all of my PHP pages to be cached. I only want to set up the cache for any path that starts with /get/image as that is the route to my dynamic image resizer. So I set the default $no_cache value to 1, aka true. And when a request matches the image resizer’s route, I then set the value to 0, aka false.

You may want to do this the other way around, ie... cache all pages and leave certain pages like authentication pages, account pages etc uncached. So then you may flip the default value to be 0 and define individual rules to set $no_cache to 1.

Define the Cache Key Format

fastcgi_cache_key $scheme$host$request_uri$request_method;
fastcgi_cache DOMAIN_CACHE;

Here, you can define the fastcgi_cache_key format as desired. Keep it as the default value would be fine. Having it as $scheme$host$request_uri$request_method; would result in a key that looks like http::domain.com::/get/image/?file=pic.jpg&width=100&height=100::GET without the colons :: (colons are used here to differentiate between the Nginx variables). You can define your own cache_key format with different Nginx’s variables.

Next, we tell Nginx that the cache keys are to be saved under DOMAIN_CACHE, which we have already defined previously.

Define Cache Behaviours

fastcgi_cache_valid 200 301 302 1h;
#fastcgi_cache_valid 404 1m;
fastcgi_cache_use_stale updating error timeout invalid_header http_500 http_503 http_404;

Here, we define that we should only cache pages that have 200, 301 or 302 responses, and will cache them for 1 hour. You may also have a second entry for example, to define pages with 404 responses to be cached for 1m only.

Then, we tell Nginx that it’s okay to use outdated cache if we are not able to retrieve the latest copy from the PHP-FPM / FastCGI server, regardless if it’s because Nginx is updating the cache or if it is caused by other errors as specified.

Cache!

fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
fastcgi_cache_bypass $no_cache;
fastcgi_no_cache $no_cache;

Here, we want Nginx to ignore these caching-related headers from FastCGI and instead follow our own caching rules defined in the config file. Otherwise, it is possible to change these headers from your PHP pages and as such affect the final caching outcomes. Depending on your use case, this may or may not be what you want to achieve. For now, we will just stick with the simpler Nginx controls all caching method.

Next, we define if we should cache this page. Remember previously how we set a default rule and a custom route-matching rule that trigger if $no_cache should be 0 or 1? The value is used here to determine if a certain page should be cached or bypassed.


Check Your Nginx Config File And Reload

And that’s it! Get Nginx to check your configuration file and reload it to have the latest settings go live.

$ sudo service nginx configtest
 * Testing nginx configuration                                    [ OK ]
$ sudo service nginx reload
 * Reloading nginx configuration nginx                            [ OK ]

Test Your Cache

To test your cache, open up your terminal and do a curl. In my case, I set it up to cache any paths under /get/image/*, so I am going to test it as such.

First Try

$ curl -X GET -I http://domain.com/get/image/test.jpg
HTTP/1.1 200 OK
Server: nginx/1.8.0
Content-Type: image/jpeg
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache
Date: Wed, 20 Jan 2016 13:04:47 GMT
X-Cache: MISS

Second Try

$ curl -X GET -I http://domain.com/get/image/test.jpg
HTTP/1.1 200 OK
Server: nginx/1.8.0
Content-Type: image/jpeg
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache
Date: Wed, 20 Jan 2016 13:06:31 GMT
X-Cache: HIT

Try After Expiry

$ curl -X GET -I http://domain.com/get/image/test.jpg
HTTP/1.1 200 OK
Server: nginx/1.8.0
Content-Type: image/jpeg
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache
Date: Wed, 20 Jan 2016 13:09:17 GMT
X-Cache: EXPIRED

And When It’s Not Cached

$ curl -X GET -I http://domain.com/somewhere-else
HTTP/1.1 200 OK
Server: nginx/1.8.0
Content-Type: image/jpeg
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache
Date: Wed, 20 Jan 2016 13:11:32 GMT
X-Cache: BYPASS

And there you go… Your hot new cache is live!

***