Create image server with nginx + lua (Openresty) + graphicsmagick (Part II)

I assume that you have already gone through part one (it is not too late to do it now if not done yet) of this tutorial and have all of the underlying infrastructure ready for this part. Herein we will cover installation of Lua modules, configuration of openresty (essentially nginx configuration) and its infrastructure, finally creation of the application that will run on the server to do the actual processing of the images.

Since we will need to store images locally on the server, as part of the cache, and also keep copies of all the derivative images, we will need sufficient storage available. To just give you an estimate, if your site(s) have total of 1GiB of images, you will probably require to have three times that much more space for originals and its derivatives.

Without further ado, lets jump into it.

Add Lua modules

Lua as a language is fairly simple and intuitive, and has plenty of libraries (at least the ones that I needed) to get you started. Thanks to its very light runtime, we can execute multiple instances of the process with not too much overhead, which makes it perfect for use on the web server. On top of that, Openresty project uses Luajit (which is much faster implementation of Lua) and makes it just perfect for light and fast application server, which is exactly what we are targeting.

In the part one we went over the installation of luarocks (Lua module manager) that will help us with getting all of the dependencies and necessary packages in place. Lets start by installing all of the necessary modules for luajit, a.k.a. rocks. Below is a handy script that will take care of the installation, please note that one of the modules, net-url, had a broken rockspecs file in the original git repository, I had to fork it and fix.

Using your favorite editor (vim in my case), open a new file for editing, set it into “paste” mode, copy the text below, paste it, and finally save. To make file executable, you will need to run chmod +x /tmp/ and then fire it up like so: /tmp/

sudo /opt/openresty/luajit/bin/luarocks install graphicsmagick --server=
sudo /opt/openresty/luajit/bin/luarocks install image --server=
sudo /opt/openresty/luajit/bin/luarocks install Lua-cURL --server=
sudo /opt/openresty/luajit/bin/luarocks install lua-resty-readurl
sudo /opt/openresty/luajit/bin/luarocks install luafilesystem
sudo /opt/openresty/luajit/bin/luarocks install lua-path
sudo /opt/openresty/luajit/bin/luarocks install xml
sudo /opt/openresty/luajit/bin/luarocks install

Once script finished running, check your screen for any errors and try fixing them, or leave a comment below to get additional help. Now you should have all of the modules that we will require installed, with some extra that you can consider using in the future.

Add openresty/nginx startup script

To ensure that your new server starts after server reboots, we will need to add a script that will tell your linux system how to do it. I am accounting for two variations, one is for legacy init.d script (this is what you will find on Amazon Linux on AWS) and one for systemd, commonly found on CentOS 7, Fedora (whatever the latest build is), etc … In case of init.d, script can be executed directly to control openresty, in case of systemd, you will need to use systemctl command with different parameters, i.e., systemctl start openresty.service.

If you are not using/running systemd

Modern distributions are moving toward systemd and arguably is much better than dated init.d/rc.d collection of scripts. In case that your installation is still using old method, below is the script to get your up and running. You will need to write it with elevated privileges, so either become root (sudo su -) or just execute editor with root permissions, sudo vim /etc/init.d/openresty

# openresty - this script starts and stops the openresty daemon
# chkconfig:   - 85 15
# description:  Openresty is an HTTP(S) server, HTTP(S) reverse \
#               proxy and IMAP/POP3 proxy server
# processname: nginx
# config:      /opt/openresty/etc/nginx.conf
# pidfile:     /opt/openresty/run/

# Source function library.
. /etc/rc.d/init.d/functions

# Source networking configuration.
. /etc/sysconfig/network

# Check that networking is up.
[ "$NETWORKING" = "no" ] && exit 0

prog=$(basename $openresty)



[ -f $sysconfig ] && . $sysconfig

start() {
    [ -x $openresty ] || exit 5
    [ -f $NGINX_CONF_FILE ] || exit 6
    echo -n $"Starting $prog: "
    daemon $openresty -c $NGINX_CONF_FILE
    [ $retval -eq 0 ] && touch $lockfile
    return $retval

stop() {
    echo -n $"Stopping $prog: "
    killproc -p $pidfile $prog
    [ $retval -eq 0 ] && rm -f $lockfile
    return $retval

restart() {
    configtest_q || return 6

reload() {
    configtest_q || return 6
    echo -n $"Reloading $prog: "
    killproc -p $pidfile $prog -HUP

configtest() {
    $openresty -t -c $NGINX_CONF_FILE

configtest_q() {
    $openresty -t -q -c $NGINX_CONF_FILE

rh_status() {
    status $prog

rh_status_q() {
    rh_status >/dev/null 2>&1

# Upgrade the binary with no downtime.
upgrade() {
    local oldbin_pidfile="${pidfile}.oldbin"

    configtest_q || return 6
    echo -n $"Upgrading $prog: "
    killproc -p $pidfile $prog -USR2
    sleep 1
    if [[ -f ${oldbin_pidfile} && -f ${pidfile} ]];  then
        killproc -p $oldbin_pidfile $prog -QUIT
        success $"$prog online upgrade"
        return 0
        failure $"$prog online upgrade"
        return 1

# Tell nginx to reopen logs
reopen_logs() {
    configtest_q || return 6
    echo -n $"Reopening $prog logs: "
    killproc -p $pidfile $prog -USR1
    return $retval

case "$1" in
        rh_status_q && exit 0
        rh_status_q || exit 0
        rh_status_q || exit 7
        rh_status_q || exit 7
        rh_status_q || exit 7
        echo $"Usage: $0 {start|stop|reload|configtest|status|force-reload|upgrade|restart|reopen_logs}"
        exit 2

Make script executable, chmod +x /etc/init.d/openresty and go ahead enable autostart: chkconfig --add openresty && chkconfig openresty on && chkconfig --list openresty Do not start it yet, we still need to configure things.

If your installation uses systemd

With systemd it is a bit simpler, just create below file and then run systemctl enable openresty.service, as with init.d, do not start the service yet, things need to be configured first.

Description=openresty-nginx - high performance web application server
ExecStartPre=/opt/openresty/sbin/nginx -t -c /opt/openresty/etc/nginx.conf
ExecStart=/opt/openresty/sbin/nginx -c /opt/openresty/etc/nginx.conf
ExecReload=/bin/kill -s HUP $MAINPID
ExecStop=/bin/kill -s QUIT $MAINPID

Configuring openresty/nginx log rotation

With time and a lot of requests you will find you logs growing big, it is better rotate them daily. Note that this configuration keeps 1 year rotate 366 worth of past logs, you may want to change it for your specific needs.

/opt/openresty/log/*.log {
        rotate 366
        create 640 openresty adm
                [ -f /opt/openresty/run/ ] && kill -USR1 `cat /opt/openresty/run/`

And that should be it, you can check if your logs are rotated next day.

Configure NGINX

We will start by creating supporting (included in main nginx.conf) files and reach the final /opt/openresty/etc/nginx.conf by the end of this step. As before, please make sure that the user that you are logged-in as, has write access to the directories and files we will be modifying/creating. Use sudo where necessary.

common_config.conf has a long list of the server-wide configuration directives and is conveniently kept outside of main configuration file, to keep it readable. If you would like to know what each of the directive means, please refer to official NGINX documentation. I would highly recommend to at least go over some of it to gain more knowledge and confidence in what you are doing.

client_header_timeout  3m;
client_body_timeout    3m;
send_timeout           3m;

connection_pool_size         256;
request_pool_size            4k;

client_header_buffer_size    1k;
large_client_header_buffers  4 4k;

output_buffers   1 32k;
postpone_output  1460;

sendfile         on;
sendfile_max_chunk  128k;
tcp_nopush       on;
tcp_nodelay      on;

keepalive_timeout  75 20;

lingering_time     30;
lingering_timeout  10;
reset_timedout_connection  on;

gzip_min_length    256;
gzip_proxied       any;
gzip_vary          on;
gzip_buffers     4 8k;
gzip_proxied     expired no-cache no-store private auth;

gzip_types application/atom+xml application/javascript application/json
           application/ld+json application/manifest+json application/rdf+xml
           application/rss+xml application/schema+json application/vnd.geo+json
           application/ application/x-font-ttf application/x-javascript
           application/x-web-app-manifest+json application/xhtml+xml application/xml
           font/eot font/opentype image/bmp image/svg+xml image/
           image/x-icon text/cache-manifest text/css text/javascript text/plain
           text/vcard text/vnd.rim.location.xloc text/vtt text/x-component
           text/x-cross-domain-policy text/xml;

proxy_set_header   Host             $host;
proxy_set_header   X-Real-IP        $remote_addr;
proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;

open_file_cache            max=1000 inactive=20s;
open_file_cache_valid      30s;
open_file_cache_min_uses   2;
open_file_cache_errors     on;

client_max_body_size       4096m;
client_body_buffer_size    128k;

proxy_connect_timeout      90;
proxy_send_timeout         90;
proxy_read_timeout         90;

proxy_buffer_size          4k;
proxy_buffers              4 32k;
proxy_busy_buffers_size    64k;
proxy_temp_file_write_size 64k;
proxy_ignore_client_abort  on;

resolver valid=30s;

The following file is optional and only necessary if you have CloudFlare fronting your image server. Personally I would highly recommend it, since you cannot beat the price of the Free Plan that they are offering. Do not forget to configure caching on their site, so your images are served out of closes edge to the user.

#Can be refreshed from
   set_real_ip_from   2400:cb00::/32;
   set_real_ip_from   2606:4700::/32;
   set_real_ip_from   2803:f800::/32;
   set_real_ip_from   2405:b500::/32;
   set_real_ip_from   2405:8100::/32;
   real_ip_header     CF-Connecting-IP;

Lets also update slightly outdated mime.types file, it is used to determine what to tell to the browser based on file extension, as well as, decide on wether to compress or process file locally.

types {
  # Borrowed (and slightly fixed) from good people at
  # Data interchange
    application/atom+xml                  atom;
    application/json                      json map topojson;
    application/ld+json                   jsonld;
    application/rss+xml                   rss;
    application/vnd.geo+json              geojson;
    application/xml                       rdf xml;
  # JavaScript
    # Normalize to standard type.
    application/javascript                js;
  # Manifest files
    application/x-web-app-manifest+json   webapp;
    text/cache-manifest                   appcache;
  # Media files
    audio/midi                            mid midi kar;
    audio/mp4                             aac f4a f4b m4a;
    audio/mpeg                            mp3;
    audio/ogg                             oga ogg opus;
    audio/x-realaudio                     ra;
    audio/x-wav                           wav;
    image/bmp                             bmp;
    image/gif                             gif;
    image/jpeg                            jpeg jpg;
    image/png                             png;
    image/svg+xml                         svg svgz;
    image/tiff                            tif tiff;
    image/vnd.wap.wbmp                    wbmp;
    image/webp                            webp;
    image/x-jng                           jng;
    video/3gpp                            3gpp 3gp;
    video/mp4                             f4v f4p m4v mp4;
    video/mpeg                            mpeg mpg;
    video/ogg                             ogv;
    video/quicktime                       mov;
    video/webm                            webm;
    video/x-flv                           flv;
    video/x-mng                           mng;
    video/x-ms-asf                        asx asf;
    video/x-ms-wmv                        wmv;
    video/x-msvideo                       avi;
    # Serving `.ico` image files with a different media type
    # prevents Internet Explorer from displaying then as images:
    image/x-icon                          cur ico;
  # Microsoft Office
    application/msword                                                         doc;
    application/                                                   xls;
    application/                                              ppt;
    application/vnd.openxmlformats-officedocument.wordprocessingml.document    docx;
    application/vnd.openxmlformats-officedocument.spreadsheetml.sheet          xlsx;
    application/vnd.openxmlformats-officedocument.presentationml.presentation  pptx;
  # Web fonts
    application/font-woff                 woff;
    application/font-woff2                woff2;
    application/         eot;
    # Browsers usually ignore the font media types and simply sniff
    # the bytes to figure out the font type.
    # However, Blink and WebKit based browsers will show a warning
    # in the console if the following font types are served with any
    # other media types.
    application/x-font-ttf                ttc ttf;
    font/opentype                         otf;
  # Other
    application/java-archive              jar war ear;
    application/mac-binhex40              hqx;
    application/octet-stream              bin deb dll dmg exe img iso msi msm msp safariextz;
    application/pdf                       pdf;
    application/postscript                ps eps ai;
    application/rtf                       rtf;
    application/  kml;
    application/      kmz;
    application/vnd.wap.wmlc              wmlc;
    application/x-7z-compressed           7z;
    application/x-bb-appworld             bbaw;
    application/x-bittorrent              torrent;
    application/x-chrome-extension        crx;
    application/x-cocoa                   cco;
    application/x-java-archive-diff       jardiff;
    application/x-java-jnlp-file          jnlp;
    application/x-makeself                run;
    application/x-opera-extension         oex;
    application/x-perl                    pl pm;
    application/x-pilot                   prc pdb;
    application/x-rar-compressed          rar;
    application/x-redhat-package-manager  rpm;
    application/x-sea                     sea;
    application/x-shockwave-flash         swf;
    application/x-stuffit                 sit;
    application/x-tcl                     tcl tk;
    application/x-x509-ca-cert            der pem crt;
    application/x-xpinstall               xpi;
    application/xhtml+xml                 xhtml;
    application/xslt+xml                  xsl;
    application/zip                       zip;
    text/css                              css;
    text/html                             html htm shtml;
    text/mathml                           mml;
    text/plain                            txt;
    text/vcard                            vcard vcf;
    text/vnd.rim.location.xloc            xloc;
    text/      jad;
    text/vnd.wap.wml                      wml;
    text/vtt                              vtt;
    text/x-component                      htc;

And lastly, here is the main configuration file for our server, it has fairly flexible configuration with a lot of comments seeded throughout it, please do go over it line by line and note if you need to make any adjustments to suit your particular needs.

This file also has a small mix of lua in it and utilization of NGINX Lua module.

user  openresty;
worker_processes  auto;
worker_rlimit_nofile 300000;
pcre_jit on;

error_log  /opt/openresty/log/error.log info; #A little verbose, change to "warn" after all is working

pid        /opt/openresty/run/;

events {
    worker_connections 8192;
    use epoll;
    multi_accept on;

http {
    include       mime.types;
    default_type  application/octet-stream;
    lua_package_path '/opt/openresty/etc/lua/?.lua;;/opt/openresty/nginx/lua/?.lua;;';

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for" "$http_x_picms_host" '
                      '"$http_x_forwarded_proto" "$http_cf_connecting_ip"';

    access_log  /opt/openresty/log/access.log  main;

    #Map to get URL signing keys for each proxied domain/host
    # good way to generate one: dd if=/dev/urandom bs=1 count=102400 2>/dev/null| sha256sum
    map $origin_host $url_signing_key {
        default "<change me to something more secure, see comment above>";    0; #Allow for unsigned URL or place secret string and sign URLs

    #This map controls what hosts are OK to proxy for, keep it tight ...
    map $origin_host $allowed_origin {
        default 0;   1;

    #Map to allow/deny different file extensions
    map $origin_ext $allowed_origin_ext {
        default  0;

        ~*^jpg$   1;
        ~*^png$   1;
        ~*^gif$   1;
        ~*^jpeg$  1;
        ~*^webp$  1;

    #Control expiration based on mime type
    map $sent_http_content_type $cache_expires {
        default         2h;

        ~image/         "16d";

    #Server wide optimizations
    include common_config.conf;
    #/Server wide optimizations

    #Proxy config
    #Place file-cache on tmpfs for speed, we do not care if it is lost
    proxy_cache_path /dev/shm/nginx-cache-picms levels=1:2 keys_zone=PICMS:100m inactive=24h;
    proxy_cache_key "$scheme$request_method$host$request_uri";
    proxy_cache_use_stale updating error timeout invalid_header http_500; #Use stale cache if upstream is in troubles
    proxy_cache_lock on; #Prevent pile-up effect and control inflow of traffic to upstream
    proxy_cache_lock_age 5s;
    proxy_cache_lock_timeout 3s;
    proxy_ignore_headers X-Accel-Expires Cache-Control Expires Set-Cookie; #Cache regardless of downstream controls
    upstream picms_backend {
        keepalive 32;
    #/Proxy config

    #Lua init
    #Shared dictionaries
    lua_shared_dict imgsrv_locks 10m; #Shared memory segment for locking mechanism
    lua_shared_dict imgsrv_cache 50m; #Set according to your load #Shared memory to cache look-ups

    #Initialize all of the modules that we will use regularly
    init_by_lua '
        url        = require("net.url")
        picms      = require("picms")
        cjson      = require("cjson")
        cjson_safe = require("")

    server {
        #Reverse proxy that will front/cache/serve all of the requests
        # See for explanation
        listen default_server backlog=1024 rcvbuf=32768 sndbuf=131072;
       #listen       [::]:80    default_server backlog=1024 rcvbuf=32768 sndbuf=131072; #Uncomment if IPv6 is an option
        server_name  _; #This will ensure that all requests to any hostname will come here, unless other virtual servers configured
       #include cf.conf; #Uncomment if you front this server with CloudFlare
        access_log  /opt/openresty/log/  main;

        location = / {
            #If someone requests plain servername with no path, route to downstream
            proxy_intercept_errors  on;
            proxy_http_version 1.1;
            proxy_cache off;
            proxy_pass http://localhost:8080;

        location ~* \.(jpg|jpeg|png|gif|webp)$ {
            #Main location that accepts all of the incomming requests for images
            #We also have nginx map to control what extensions are processed

            proxy_intercept_errors  on;
            proxy_http_version 1.1;
            proxy_cache_lock on;
            proxy_cache_lock_age 3s;
            proxy_cache_lock_timeout 3s;
            proxy_cache_methods GET HEAD;
            proxy_cache_revalidate on;

            proxy_cache PICMS;
            proxy_cache_valid 200       4h; #Cache successful requests for 4 hours
            proxy_cache_valid 404       20s;
            add_header X-Proxy-Cache    $upstream_cache_status;
            add_header X-Request-Time   $request_time;
            add_header Cache-Control    "public, max-age=1382400, s-maxage=43200";
            etag                        on;
            expires                     16d;

            proxy_pass http://picms_backend;

    server {
        #(Default) Frontend server to serve transformed images
        listen          localhost:8080;
        listen          [::1]:8080;
        server_name     localhost;

        access_log  /opt/openresty/log/picms.access.log  main; #Probably just extra log that will be mostly duplicate of the upstream

        #Global variables
        #Shared dict info
        set $sd_default_expire    86400; #Set default shared dict cache expire to 24h
        set_md5 $sd_cache_key     $request_uri; #Prepare cache key for lookup/storage

        #Path info
        set $processed_path       ''; #Predefine processed image local path for setting by Lua later

        set $min_width   32;
        set $min_height  32;
        set $max_width   2500;
        set $max_height  2500;
        set $min_quality 50;

        #Default args
        set $width          0;    #Default to no resizing
        set $height         0;    #Deafult to no resizing
        set $crop           0;    #No cropping by default
        set $zoom           0;    #No zooming by default
        set $strip          1;    #Strip all the info by default
        set $quality        75;   #Set quality to 75 by default, can reduce to 65~70 for better compression but with less details
        set $create_webp    0;    #Do not create WEBP files by default

        #Default catch-all location
        location / {
            #For any requests that do not match the pattern redirect to tutorial
            return 302 "";

        location = /empty.gif {
            #Empty GIF to serve on errors if needed
            expires -1;

        location ~* "^/(?<origin_host>[^/]+)(?<origin_path>/.*\.(?<origin_ext>[0-9a-z]+))$" {
            #Perform verification
            #I could have done most of it with If statements in nginx
            #but since and need for more safistication
            #access_by_lua will be better
            access_by_lua_file 'lua/picms_access.lua';

            #Route the request and set/cache CTX if not cached
            rewrite_by_lua_file 'lua/picms_main_rewrite.lua';

            #Set headers for files
            etag on;
            expires    $cache_expires;
            add_header Cache-Control    "public";

            alias /opt/openresty/cache/; #Make sure that this directory exists and openresty user can write into it
            try_files $processed_path @process;
            error_page 403 =200 /empty.gif; #If someone tries to do something forbidden, give them empty GIF

        location @process {
            if ( $request_method != "GET" ) {
                #If image is not processed, do not allow unless it is being GETed
                return 405; #Method not allowed

            root /opt/openresty/cache/;
            content_by_lua '
                local exec           = ngx.exec
                local exit           = ngx.exit
                local OK             = ngx.OK
                local HTTP_NOT_FOUND = ngx.HTTP_NOT_FOUND
                local picms          = picms
                local request_uri    = ngx.var.request_uri
                local cjson          =

                -- Shared dictionary
                local imgsrv_cache = ngx.shared.imgsrv_cache

                -- Check if ctx is in cache, if not, rerun the request
                local sd_cache_key = ngx.var.sd_cache_key
                local jctx, flags  = imgsrv_cache:get(sd_cache_key)
                local ctx          = cjson.decode(jctx)
                if not ctx then

                -- Lets check if origin exists or fetchable
                local ok = picms.get_origin(ctx)
                if ok then
                    -- Perform requested transformation, cache, and rerun the request
                    local ok = picms.transform_image(ctx)
                    if not ok then
                        -- Should not happen but who knows
                -- If no errors we should not reach here
            error_page 404 =200 /empty.gif;

Lua application files

As a last step of the process, we will create three files with pure lua code in them. Those files are: picms_access.lua – used for validation of the request and can be customized to heighten security further; picms_main_rewrite.lua – used for “decoding” of request URL into parameters to process files, as well as, find locally cached request, if already present; picms.lua – is our module that has all of the necessary functions to process images and populate cache.

The following file is what will ensure that none of the bogus requests get through, the web is a wild place, we need to lookout for ourselves.

-- General Variables
local allowed_origin     = ngx.var.allowed_origin
local allowed_origin_ext = ngx.var.allowed_origin_ext
local url_signing_key    = ngx.var.url_signing_key
local origin_host        = ngx.var.origin_host
local origin_host_ext    = ngx.var.origin_host_ext
local args               = ngx.var.args
local http_x_picms_host  = ngx.var.http_x_picms_host
local request_uri        = ngx.var.request_uri
local http_referer       = ngx.var.http_referer

-- Crypto signing arguments
local sigv1          = ngx.var.arg_sigv1 -- Signature in URL
local expires        = ngx.var.arg_expires -- Expires timestamp in URL
-- General Functions
local exit   = ngx.exit
local log    = ngx.log
local re     =
local say    = ngx.say
local os     = os
local string = string
-- Hashing functions
local encode_base64 = ngx.encode_base64
local hmac_sha1     = ngx.hmac_sha1
local md5           = ngx.md5
-- Logging levels
local WARN = ngx.WARN
-- HTTP Codes
local OK             = ngx.OK

if http_x_picms_host ~= nil then
    log(WARN, "Looping, saw X-Picms-Host header: ", http_x_picms_host)
if allowed_origin == "0" and not sigv1 then -- Allow to proxy for disallowed domain as long as URL is signed, signature will be checked for validity
    log(WARN, "Attempt to proxy for unknown origin: ", origin_host)
if allowed_origin_ext == "0" then
    log(WARN, "Attempt to proxy for unknown file extension: ", origin_ext)
if url_signing_key == "0" then
    -- If key is not set we are good and can allow access to anyone
if args then
    -- Only perform signature verification if args are present and processing is required
    local params_to_sign = "" -- Initiate signable string

    -- Validate hostname for sanity
    if origin_host:len() > 255
    or not re.match(origin_host, "^(?:(?:[a-z0-9]|[a-z0-9][a-z0-9\\\\-]*[a-z0-9])[\\\\.]{1,1})+(?:[a-z0-9]|[a-z0-9][a-z0-9\\\\-]*[a-z0-9]){2,}$", "joui")
        -- Reject request to
        log(WARN, "Attempt to proxy for invalid hostname: ", origin_host)
    -- Make sure that signature is present in URL
    if sigv1 == nil then
        -- Reject request without signature
        log(WARN, "Attempt to proxy without signature for: ", origin_host)
    -- Remove signature from parameters before generating verification hash
    -- This is a full request URL with all of the arguments present
    params_to_sign = re.gsub(request_uri, "(?:sigv1)=[^&]+&?", "", "joui")
    -- Update sd_cache_key with clean URL hash, increase cache hit rate
    ngx.var.sd_cache_key = md5(params_to_sign)

    -- Lets hash the string and check if it is authorized
    local signature = encode_base64(hmac_sha1(url_signing_key, params_to_sign))
    local utcnow    = os.time("!*t"))
    -- If you desire to have fixed expiration on assets, we will check its validity
    if expires and expires < utcnow then
        -- Reject expired URLs
        ngx.status = HTTP_FORBIDDEN
        say("URL Expired")
        log(WARN, "Attempt to proxy for with expired URL, expired on: ", expires)
    -- Lastly, check validity of the supplied signature and reject if it doesn't match
    if sigv1 ~= signature then
        -- Reject invalid signature URLs
        ngx.status = HTTP_FORBIDDEN
        say("Invalid Signature")
        log(WARN, "Attempt to proxy for with invalid signature for: ", origin_host)

Next file has all of the secret souse that will create local cache path for the target file, figure-out how to fetch file from origin, and populate shared memory cache with the information about the request to eliminate further processing upon repeated requests.

-- Cache shared dictionary
local imgsrv_cache = ngx.shared.imgsrv_cache
local cjson        =

-- Check if processed path is in cache so we can stop right here
local sd_cache_key = ngx.var.sd_cache_key
local jctx, flags  = imgsrv_cache:get(sd_cache_key)
local ctx          = cjson.decode(jctx)
if ctx then
    ngx.var.processed_path = ctx.processed_path
    -- Not in cache yet, create new ctx
    ctx = {}

-- Cache miss, do full processing

-- Localazie modules we will use during this call
local lurl   = url -- net.url imported in init_by_lua
local picms = picms -- My module imported in init_by_lua
local P      = picms.P -- Assign path module from picms for conveninence
local string = string
local re     =
local log    = ngx.log
local INFO   = ngx.INFO
local WARN   = ngx.WARN
local ERR    = ngx.ERR
-- Localize variables
local http_x_forwarded_proto = ngx.var.http_x_forwarded_proto
local scheme                 = ngx.var.scheme
local args                   = ngx.var.args
local lquery                 = nil
local document_root          = ngx.var.document_root
if args then
    lquery = ngx.decode_args(args)

-- Sanitize variables
-- Figureout scheme we should be using to fetch from origin
ctx.origin_proto = string.lower(http_x_forwarded_proto or scheme or "http")

-- Origin host, path and extension to the asset
ctx.origin_host = string.lower(ngx.var.origin_host) -- case-insensitive, normalize
ctx.origin_path = ngx.var.origin_path               -- case-sensitive, leaving it as it is
ctx.origin_ext  = string.lower(ngx.var.origin_ext)  -- not used for fetching, lower for condition checking
-- If webp is requested, origin can be different, grab real extension and modify origin path
if ctx.origin_ext == "webp" then
    local origin_true_ext
    local s, e, err = re.find(ctx.origin_path, "\\.([a-z0-9]{3,})\\.webp$", "joiu")
    if s and e then
        origin_true_ext = string.sub(ctx.origin_path, s+1, e-5)
    if origin_true_ext then
        ctx.origin_ext  = origin_true_ext
        ctx.origin_path = string.sub(ctx.origin_path, 1, -6)
        ctx.origin_req_ext = "webp"
        log(INFO, "Origin is not webp, requested to be converted to webp: [EXT]: ", origin_true_ext, " [PATH]: ", ctx.origin_path)

-- Constract URL to fetch original file
local origin_url    = lurl
origin_url.scheme   = ctx.origin_proto     = ctx.origin_host
origin_url.path     = ctx.origin_path

-- Build query to origin
ctx.origin_url          = origin_url:normalize():build() -- Normalize URL, resolve "../../.." and generaly clean it up
-- We want normalized url to build local path, hence, regex it here; remove any double slashes as well
ctx.origin_cache_path   = re.gsub(document_root .. "origin" .. re.sub(ctx.origin_url, "^(?:http(?:s)?)://", "/", "joui"), "/+", "/", "jou")

log(INFO, "Origin URL: ", ctx.origin_url)

-- Determening what transformation of image is requested
-- Start with filling in defaults
min_width   = tonumber(ngx.var.min_width) or 32
max_width   = tonumber(ngx.var.max_width) or 2500
min_height  = tonumber(ngx.var.min_height) or 32
max_height  = tonumber(ngx.var.max_height) or 2500
min_quality = tonumber(ngx.var.min_quality) or 50
ctx.width   = tonumber(ngx.var.width) or 0
ctx.height  = tonumber(ngx.var.height) or 0
ctx.crop    = tonumber(ngx.var.crop) or 0
ctx.zoom    = tonumber(ngx.var.zoom) or 0
ctx.strip   = tonumber(ngx.var.strip) or 1
ctx.quality = tonumber(ngx.var.quality) or 75
-- Url signature
if lquery then
    -- Width & Height
    local w = tonumber(lquery.w)
    local h = tonumber(lquery.h)
    local z = tonumber(lquery.zoom) or tonumber(lquery.z)
    local q = tonumber(lquery.q) or tonumber(lquery.quality)
    local c = (tonumber(lquery.crop) or tonumber(lquery.c))and "1" or 0
    local s = tonumber(lquery.strip) or ctx.strip -- Do not care, strip always ... ;-)
    if type(z) ~= "number" or z == 1 or z < 0 or z > 2 then -- No point of zooming beyond 2 or under 0, and if asked to zoom, no need to have dimensions
        if type(w) == "number" and w >= min_width and w < = max_width then
            ctx.width = math.floor(w)
        if type(h) == "number" and h >= min_height and h < = max_height then
            ctx.height = math.floor(h)
        ctx.zoom = 0
        ctx.width  = 0
        ctx.height = 0
        ctx.zoom   = z ~= 1 and z or 0
    ctx.crop    = c
    if s ~= 1 then
        ctx.strip   = 0
    if type(q) == "number" and q >= min_quality and q < = 100 then
        -- Default will be taken if never reached here
        ctx.quality = math.floor(q)

-- Building asset path url, maybe better done in a separate function
ctx.processed_path = re.gsub("/processed/"
                            .. ctx.origin_host .. "/"
                            .. ctx.width .. "x" .. ctx.height
                            .. "/crop-" .. ctx.crop
                            .. "/zoom-" .. ctx.zoom
                            .. "/strip-" .. ctx.strip
                            .. "/q-" .. ctx.quality
                            .. "/" .. re.sub(ctx.origin_url, "^http(?:s)?://[^/]+/", "", "joui"), "/+", "/", "jou")
-- Absolute path to save processed image
ctx.processed_cache_path = re.gsub(document_root .. ctx.processed_path, "/+", "/", "jou")

-- If originaly requested file type was different from the one that was fetched, make sure that requested type served
if ctx.origin_req_ext then
    ctx.processed_path = ctx.processed_path .. "." .. ctx.origin_req_ext

-- Add entry to cache
local jctx = cjson.encode(ctx)
if jctx then
    local ok, err, forcible = imgsrv_cache:set(sd_cache_key, jctx, tonumber(ngx.var.sd_default_expire))
    if not ok then
        log(WARN, "Failed to store entry in cache: ", err)
    if forcible then
        log(WARN, "Maybe cache too small? Had to force valid items to store this one: ", sd_cache_key)
    log(WARN, "Failed to convert context table into json format for cache id: ", sd_cache_key)

-- Return to let nginx serve the file

Lastly, here is the main module for fetching images, maintaining cache structure, and processing the derivatives of origin. It has most of the code commented for ease of understanding and future maintenance.

local _M = {}
local P      = require("path")
local lfs    = require("lfs")
local string = string
local math   = math
local os     = os
local cURL   = require("")
local gm     = require("graphicsmagick")
local r_lock = require("resty.lock")
local re     =
local log    = ngx.log
local INFO   = ngx.INFO
local WARN   = ngx.WARN
local ERR    = ngx.ERR
_M.P = P -- Export for usage inside other lua scripts
_M.mkdir = function (absdir)
    -- Creates directory tree recursevely
    local t = absdir
    local dir, base = P.split(t)
    local a = P.isdir(t)
    if a then
        -- Path exists, return
        return true
    local d = P.isdir(dir)
    local b = P.isdir(base)
    if d and not b then
        -- Directory exists, basename does not, create and return
        local ok = lfs.mkdir(t)
        return ok
        -- Directory does not exist, recursively call self
        local ok = lfs.mkdir(t)
        return ok
_M.fetch_origin = function (ctx)
    -- Localize nginx variables
    local http_host                = ngx.var.http_host
    local http_x_forwarded_for     = ngx.var.http_x_forwarded_for or ""
    local http_x_forwarded_proto   = ngx.var.http_x_forwarded_proto or "Unknown"
    local http_cf_connecting_ip    = ngx.var.http_cf_connecting_ip or ""
    local http_referer             = ngx.var.http_referer or ""
    -- To avoid pileup and false presence, use tmp file to download first
    local tmp_file                 = ctx.origin_cache_path .. "~"
    -- Create directories if not present yet
    local ok = _M.mkdir(P.dirname(ctx.origin_cache_path))
    if not ok then
        return false
    -- Origin file not present, fetch it
    local f =, "w+b")
    if not f then
        log(ERR, "Failed to create a new file: ", tmp_file)
        return false
    log(INFO, "Fetching file [", ctx.origin_cache_path, "] from: ", ctx.origin_url)
    local c = cURL.easy{
        url = ctx.origin_url,
        writefunction = f,
        [cURL.OPT_FAILONERROR]       = true, -- Fail on HTTP 4xx errors.
        [cURL.OPT_FOLLOWLOCATION]    = true, -- Follow 301 and 302 redirects
        [cURL.OPT_AUTOREFERER]       = true, -- Set Referer for 301 and 302 redirects
        [cURL.OPT_MAXREDIRS]         = 5,    -- No more than 5 redirects to follow
        [cURL.OPT_TIMEOUT]           = 16,   -- Timeout downloading file after 14 seconds if not done
        [cURL.OPT_CONNECTTIMEOUT]    = 3,    -- Timeout downloading file after 14 seconds if not done
        [cURL.OPT_HTTP_VERSION]      = cURL.CURL_HTTP_VERSION_2_0, -- Use highest HTTP protocol version
        [cURL.OPT_SSL_VERIFYPEER]    = false,    -- Do not verify SSL certificate of the peer
        [cURL.OPT_SSL_CIPHER_LIST]   = "ecdhe_ecdsa_aes_128_sha,ecdhe_ecdsa_aes_256_sha,ecdhe_ecdsa_3des_sha,rsa_aes_128_sha,rsa_aes_256_sha",
        [cURL.OPT_MAXFILESIZE]       = 20971520, -- Limit download to no larger than 20Mb
        [cURL.OPT_MAXFILESIZE_LARGE] = 20971520, -- Limit download to no larger than 20Mb
        "X-Picms-Host: "              .. http_host,
        "X-Forwarded-For: "           .. http_x_forwarded_for,
        "X-Forwarded-Proto: "         .. http_x_forwarded_proto,
        "X-Picms-Connecting-CF-IP: "  .. http_cf_connecting_ip,
        "Referer: "                   .. http_referer,
        "User-Agent: Mozilla/5.0 ( ImageProxy cURL Fetcher)",
    local _, e = c:perform()
    -- close connection and output file
    -- (Improvement) Possible connection pooling
    if P.isfile(tmp_file) and not e then
        log(WARN, "Successfully fetched file [", ctx.origin_cache_path, "] from: ", ctx.origin_url)
        os.rename(tmp_file, ctx.origin_cache_path)
        return true
        log(WARN, "Failed to fetch file [", ctx.origin_cache_path, "] from: ", ctx.origin_url)
        log(WARN, "cURL error (if any): ", tostring(e))
        os.remove(tmp_file) -- Remove failed temp file
        return false
_M.locked_execution = function(lockid, func, ctx)
    -- This function will attempt to lock the lockid (should be path to a dir/file)
    -- execute func (passing ctx as an argument), and unlock the path
    -- To be successful, create temp file (on the same filesystem) and work with that,
    -- once done, move (rename file) to a target "lockid" filename.
    -- This will ensure atomicity of the lock and file access.
    -- Check if original file already in cache and return if present:
    if P.exists(lockid) then
        log(INFO, "To be locked file/dir is present, aborting: ", lockid)
        return true
    -- cache miss!
    -- Acquire lock:
    -- Make sure that nginx.conf has something like "lua_shared_dict imgsrv_locks 10m;"
    -- Set lock timeout to 20 seconds, cURL 16 seconds for download and 3 for connection timeout
    local lock         = r_lock:new("imgsrv_locks", {timeout=20})
    local elapsed, err = lock:lock(lockid)
    if not elapsed then
        log(ERR, "Failed to acquire the lock: ", err)
        return false
    -- lock successfully acquired!
    log(INFO, "Acquired lock after [", elapsed, "] seconds: ", lockid)
    -- while waiting for a lock other process might created the file already
    -- check for its existance again and act accordingly
    if P.exists(lockid) then
        local ok, err = lock:unlock()
        if not ok then
            log(ERR, "Failed to unlock: ", err)
            -- Should we care? Maybe just silently ignore this error?
            return false
        log(INFO, "To be locked file/dir was created while waiting for lock: ", lockid)
        return true
    -- Acquired exclusive lock, work on the file/dir creation
    -- Expecting to get true/false back from func
    local fok = func(ctx) -- Execute function to populate lockid file/dir
    if not fok then
        local ok, err = lock:unlock()
        if not ok then
            log(ERR, "Failed to unlock: ", err)
            -- Should we care? Maybe just silently ignore this error?
            return false
        log(WARN, "Failed to populate file/dir: ", lockid)
        return fok
    local ok, err = lock:unlock()
    if not ok then
        log(ERR, "Failed to unlock: ", err)
        -- Should we care? Maybe just silently ignore this error?
        return false
    return fok
_M.get_origin = function(ctx)
    -- Pass fetch_origin to be executed safely, make target local path a lockid
    return _M.locked_execution(ctx.origin_cache_path, _M.fetch_origin, ctx)
_M.imgoptim = function(img_path, ctx)
    -- We pass image path here in case we are dealing with temp file name of which is not in context table
    local origin_ext = ctx.origin_ext -- Use extension to identify what command to use for optimization
    local img_fmt    = origin_ext:lower() -- Maybe already in a lower case, do it again to make sure
    local quality    = ctx.quality or 75 -- If for some reasons quality prameter is not in context, default to 75%
    -- List of optimizers to use with their options and path concatinated to the command
    -- local jpeg_exec  = "/usr/bin/jpegoptim -q -P -p --all-progressive --max=" .. quality .. " " .. img_path
    local jpeg_exec  = "/opt/openresty/bin/jpegtran -copy none -optimize -progressive -outfile " .. img_path .. "~ " .. img_path
    local png_exec   = "/usr/bin/optipng -o7 -fix -preserve -q -zm9 -strip all " .. img_path
    local gif_exec   = "/opt/openresty/bin/gifsicle -b -O3 " .. img_path
    if origin_ext and (img_fmt == "jpg" or img_fmt == "jpeg") then
        log(INFO, "Optimizing JPEG: ", jpeg_exec)
        local ok = 0 == os.execute(jpeg_exec)
        os.rename(img_path .. "~", img_path)
        return true
    elseif origin_ext and img_fmt == "png" then
        log(INFO, "Optimizing PNG: ", png_exec)
        return 0 == os.execute(png_exec)
    elseif origin_ext and img_fmt == "gif" then
        log(INFO, "Optimizing GIF: ", gif_exec)
        return 0 == os.execute(gif_exec)
    log(INFO, "Not Optimizing: ", img_fmt)
    return true -- Just pass through
_M.process_image = function (ctx)
    local src     = ctx.origin_cache_path
    local dst     = ctx.processed_cache_path
    local dst_tmp = dst .. "~"
    local width   = ctx.width ~= 0 and ctx.width or nil -- Need to nil'ify for easier dealings with gm
    local height  = ctx.height ~= 0 and ctx.height or nil -- Need to nil'ify for easier dealings with gm
    local crop    = ctx.crop
    local zoom    = ctx.zoom
    local strip   = ctx.strip -- We will actualy strip all metadata in final optimization
    local quality = ctx.quality
    log(INFO, "Transforming image: w:", width, " h:", height, " crop:", crop, " zoom:", zoom, " quality:", quality)
    -- Origin should be present
    -- Instanciate GraphicsMagickWand
    local image = gm.Image()
    -- If width or height specified and smaller than origin, load speed may increase
    if width or height then
        image:load(src, width, height)
    -- Original W&H
    local src_w, src_h = image:size()
    -- Zoom?
    if type(zoom) == "number" and zoom > 0 then
        -- If zooming, we will figure-out sizes and scale accordingly
        width  = math.floor(src_w * zoom)
        height = math.floor(src_h * zoom)
    -- Crop or resize
    -- Preserve aspect ration, no one likes stretched images
    local resize_filter = "Undefined" -- GM will select one for us
    if width and height then
        -- Borrowed from lua imagemagick bindings
        local ar_src = src_w / src_h
        local ar_dest = width / height
        if ar_dest > ar_src then
            -- Landscape orientation
            local new_height = width / ar_src
            image:size(width, new_height, resize_filter)
            if crop == "1" then
                -- Crop image after we are resized, keep centered
                image:crop(width, height, 0, (new_height - height) / 2)
            -- Portrait orientation
            local new_width = height * ar_src
            image:size(new_width, height, resize_filter)
            if crop == "1" then
                -- Crop image after we are resized, keep centered
                image:crop(width, height, (new_width - width) / 2, 0)
        -- Some fancy logging for ya...
        log(INFO, "Cropping: ", (crop == "1" and "[yes]" or "[no]"), "; Resizing to: ", width, "x", height)
    elseif width or height then
        image:size(width, height, resize_filter)
    image:depth(8) -- Make image 8bit in depth, we are optimizing for web
    -- Need to lock path before doing any saving.
    -- Finally save image to a temp file
    image:save(dst_tmp, quality)
    -- Run optimization on a newly created image, WEBP is already optimized so we do not touch it
    _M.imgoptim(dst_tmp, ctx)
    -- (Improvement) To save space we could hardlink new file to other permutations, i.e., 0x0 will also be the same as original image WxH
    -- Finaly rename files to the target name
    os.rename(dst_tmp, dst)
    -- Save in WEBP format if requested
    if tonumber(ngx.var.create_webp) ~= 0 then
        image:save(dst .. "~.webp", quality)
        os.rename(dst .. "~.webp", dst .. ".webp")
    return true
_M.transform_image = function (ctx)
    -- Pass process_image to be executed safely, make target local path a lockid
    return _M.locked_execution(ctx.processed_cache_path, _M.process_image, ctx)
return _M

That should be it, as far as creation of configuration is concerned, there are only few steps left to make sure that it starts and works as expected.

Final checks and start

First we will need to create image cache directory and give openresty user permission to write into it: sudo mkdir /opt/openresty/cache/ && sudo chown openresty:openresty /opt/openresty/cache/ By this point all should be ready to start, lets make sure that NGINX agrees with that by performing validation: sudo /opt/openresty/sbin/nginx -t. You should see that nginx reports all as being ok, otherwise refer to the error/warning messages that should be printed as part of the error.

In one of the previous steps we have created system file to help us start/stop/reload openresty, let use it to start it now:

  • (for systemd)

sudo systemctl start openresty.service

  • (for init.d)

sudo /etc/init.d/openresty start

At this time nginx should be up and running, lets confirm that by checking error logs, process, and port:

 tail -n 50 /opt/openresty/log/error.log
#You should not see any errors, maybe some warnings and general information, otherwise you will need to troubleshoot depending on what is in the log
 ps -ef | grep nginx
#You should see something like this:
root      30241     1  0 Mar30 ?        00:00:00 nginx: master process /opt/openresty/sbin/nginx -c /opt/openresty/etc/nginx.conf
openresty 31616  6678  0 Apr04 ?        00:00:17 nginx: worker process
openresty 31651  6678  0 Apr04 ?        00:00:00 nginx: cache manager process
 sudo netstat -anp | grep 8080
#You should see something along those lines:
tcp        0      0  *               LISTEN      4179/nginx: worker

That is all, now it should be ready for your first image processing. If you followed all of the steps and read through the code/configuration, you should have an idea of how to use it. Since I wanted it to be backwards compatible with Jetpack Photon API, most of the important directives are identical in use and behavior.

Thanks for getting this far and making it work, job well done! If you find any errors or would like to suggest improvements in the code or tutorial, please leave a comment below or contact me directly.

Copyright (c) 2015, Ian Matyssik
All rights reserved.


Ian Matyssik is geek to the bone with love for art and nature. In a free time likes walking with his dog and taking pictures.
  • What is `$origin_ext`? Where does it get defined?

    • PhotograPhy Blog

      Sorry about that, did not escape HTML properly and now it should be fixed. The line was supposed to be “””location ~* “^/(?[^/]+)(?/.*.(?[0-9a-z]+))$” {“”” for the main processing location.

      It is set via regex in nginx and it is to capture the original extension of the file as on the server.

      • Thanks! That makes sense now 🙂

        • PhotograPhy Blog

          Glad it worked, I hope I did not mess it up in other places. Do let me know if something else is not clear or not working, thanks.

  • Fanendra

    Hey, Thanks for the useful information. I got error while testing the nginx config
    nginx: [emerg] invalid URL prefix in /opt/openresty/etc/nginx.conf:134
    nginx: configuration file /opt/openresty/etc/nginx.conf test failed

    It worked fine when i changed from

    proxy_pass picms_backend;



    • PhotograPhy Blog

      Do you have upstream defined in the config and do you have upstream module compiled in nginx?

      • Fanendra

        Yes upstream has been defined and is compiled in nginx. In fact the configuration is exactly the same you have mentioned. I haven’t changed anything yet.

        • PhotograPhy Blog

          Interesting, is it correct assumption that everything else works once you have changed to plain IP with port?

          Also, try changing proxy_pass picms_backend; to proxy_pass http://picms_backend;

          I am on mobile noe and cannot check what I have in the real config, will do that once near my computer. Thanks.

          • PhotograPhy Blog

            Checked the docs, and yes, my suggestion to add http:// was correct. See nginx upstream module docs.

          • Fanendra

            Thanks it works now!! Apart from this I have written my use case. Please check if you got that. I am a developer basically and has basic knowledge of nginx working. Thus I would need support from your side in setting up this server 🙂

          • PhotograPhy Blog

            Thanks again for finding a bug and verifying that the fix works. As for any custom work or further help on individual projects, I am sorry but I cannot help. You can ask me any further questions that you might have about the tutorial or in relation to it, but I would not be able to consult on any customizations, sorry about that. Do feel free to ask any specific questions about the implementation, I might be able to help.

          • Fanendra

            No issues at all. Will let you know if I find any issue related to this.

  • Michael Muthmann

    Thank you very much for this nice tutorial.

    When i am trying to install the required modules i get a lot of errors.

    /opt/openresty/luajit/bin/luarocks install graphicsmagick –server=
    Using… switching to ‘build’ mode
    Missing dependencies for graphicsmagick:
    torch >= 7.0
    sys >= 1.0
    image >= 1.0
    Using… switching to ‘build’ mode
    Missing dependencies for torch:
    paths >= 1.0
    cwrap >= 1.0
    Using… switching to ‘build’ mode
    Klone nach ‘paths’…
    fatal: unable to connect to[0:]: errno=Verbindungsaufbau abgelehnt[1:]: errno=Verbindungsaufbau abgelehnt
    Error: Failed installing dependency: – Failed installing dependency: – Failed cloning git repository.

    /opt/openresty/luajit/bin/luarocks install image –server=
    Using… switching to ‘build’ mode
    Missing dependencies for image:
    torch >= 7.0
    sys >= 1.0
    xlua >= 1.0
    Using… switching to ‘build’ mode
    Missing dependencies for torch:
    paths >= 1.0
    cwrap >= 1.0
    Using… switching to ‘build’ mode
    Klone nach ‘paths’…
    fatal: unable to connect to[0:]: errno=Verbindungsaufbau abgelehnt[1:]: errno=Verbindungsaufbau abgelehnt
    Error: Failed installing dependency: – Failed installing dependency: – Failed cloning git repository.

    Lua-cURL Works fine and compiles
    /opt/openresty/luajit/bin/luarocks install Lua-cURL –server=

    lua-resty-readurl and /opt/openresty/luajit/bin/luarocks install
    are not working.

    Any help would be much appreciated.

  • Michael Muthmann

    ok i’have solved it :
    git config –global url. git://

    • PhotograPhy Blog

      Hey, sorry for delay. I am not sure I follow what problem you had? “/usr/local/openresty/luajit/bin/luarocks install graphicsmagick –server=” worked for me even now, it does pull a lot of dependancies with it, but that is part of the install.

      Am I correct in my assumption, that it was part of your git configuration that was not standard?

  • Michael Muthmann

    Thank you very much for your reply,
    indeed it was a git/ firewall issue.

    It would be nice if you could add some samples how to use the server.

    It would be much easier to get started.

%d bloggers like this: