Setting Up Nginx for Ghost

The recommended web server for Ghost installations is Nginx. The software has a number of features in addition to its HTTP server capabilities - including caching, load balancing and more - but the bits we are most interested in when running a simple blog site are the web server and reverse proxy.

As the name suggests the web server component of Nginx delivers web pages, images and other files to client devices browsing the site. A "proxy" is an intermediary bit of software that forwards requests from multiple clients to different servers across the Internet. A so-called reverse proxy typically sits behind a firewall and directs client requests to a backend server. This is the set-up generally used for sites running content management systems like Wordpress, or as is the case of this site, Ghost.

Once you have installed nginx on your server you need to configure it.

The vanilla config installed by Ghost works fine, but if you would like to understand how it works and learn how to tweak the configuration, read on.

Key files and directories

The main nginx configuration files are organised like this[1]:

/etc/nginx/
├── nginx.conf              # Main Nginx configuration file
├── conf.d/                 # additional configuration files
│   └── *.conf              
├── sites-available/        # virtual server files (not yet enabled)
│   └── example.com.conf    # Config for example domain 
├── sites-enabled/          # virtual server files (enabled)
│   └── example.com.conf    # Symlink to sites-available/example.com.conf

Setting up the Nginx for Ghost involves two of these configuration files:

Nginx can be used to manage traffic to multiple domains/subdomains hosted on a single sever. For instance, my server hosts two sites: the site you are viewing now and a demonstration site for the Ghost theme that I created. The demo site is available on the demo subdomain of my transmissionthemes.com domain, so I have the following site-specific configurations files:

/etc/nginx/sites-available/smallworkshop.co.uk.conf
/etc/nginx/sites-available/demo.transmissionthemes.com.conf

Files in the sites-avaialble directory are not enabled by default. Sites are enabled by creating symbolic links in the sites-enabled directory based on the configuration files in sites-available[2]. For example:

ln -s /etc/nginx/sites-available/smallworkshop.co.uk.conf /etc/nginx/sites-enabled/

Site configuration

Most of the configuration work is done in the site specific file. Here is the configuration used on this site. Each section is explained below

# the first block handles http requests and redirects them to the second server block
# this works in most cases (e.g where the user just types the domain into the webrowser search bar)
# but if a request is made explictly to eg. https/www then there will be a cert error
# To avoid this create a separate certificate for the domain and then create a new server block to redirect it.
server {
    listen 80;
    listen [::]:80 ;
    server_name www.smallworkshop.co.uk smallworkshop.co.uk;
    return 301 https://smallworkshop.co.uk$request_uri;
}

# serve website content
server {
    listen 443 ssl;                   # For IPv4
    listen [::]:443 ssl;              # For IPv6
    http2 on;

    server_name smallworkshop.co.uk;

    ssl_certificate /etc/letsencrypt/live/smallworkshop.co.uk/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/smallworkshop.co.uk/privkey.pem; # managed by Certbot

	include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

    
    # add security headers
    add_header Strict-Transport-Security "max-age=31536000";
    add_header Referrer-Policy "no-referrer";
    add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()";
    add_header X-Content-Type-Options "nosniff";
    add_header X-Frame-Options "sameorigin";
    add_header X-Xss-Protection "1; mode=block";
    proxy_hide_header X-Powered-By;
    
    # add security headers
    add_header Strict-Transport-Security "max-age=31536000";
    add_header Referrer-Policy "no-referrer";
    add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()";
    add_header X-Content-Type-Options $header_content_type_options;
    add_header X-Frame-Options "sameorigin";
    add_header X-Xss-Protection "1; mode=block";
    proxy_hide_header X-Powered-By;

    root /var/www/ghost;

    #nginx serve the theme files directly
    
    location ^~ /assets/ {
        root /var/www/ghost/content/themes/transmission;
        # add cache-control header when serving assets from nginx (added by Ghost when using the proxy)
        add_header Cache-Control 'public,max-age=31536000';
        try_files $uri @ghost;
    }
    
    # serve images via nginx rather than the proxy.
    # Ghost generates responsive image sizes in /content/images/size on request if they do not already exist
    # ..  to preserve this functionality we  pass the request to the Ghost proxy when the image can't be found on the server
    
    location ~* ^(/content/images/)(.+)\.(png|jpe?g)$ {	
        expires max;
        set $webp_image_subdir "/content/images/webp/";
        set $basename $2;
        
        # look for basename.webp (the $webp_suffix var set in nginx.conf if the browser supports webp)
        # if not found then try the original file, else pass to ghost in case a responsive image needs to be generated by the ghost server
        # add_header X-debug-message "nginx served file: $webp_image_subdir$basename$webp_suffix or $uri" always;
        try_files $webp_image_subdir$basename$webp_suffix $uri @ghost;
    }

    location / {
        try_files _ @ghost;
    }
	
    location @ghost {
        proxy_ignore_headers Cache-Control;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        proxy_pass http://127.0.0.1:2368;
    }
   
    client_max_body_size 50m;

}

Nginx configurations consists of directives and their parameters. Some directives act as containers that group together related directives, enclosing them in curly braces ({}). These containers are also referred to as blocks.

the /etc/etc/nginx.conf file holds the 'http' context, which contains all of the directives necessary to define how to handle HTTP and HTTPS connections. This configuration also ensures all enabled site-specific directives are loaded with this statement:

include /etc/nginx/sites-enabled/*;

The site specific configuration file defines the server context for each domain being hosted on the server. Each server block defines a virtual server within Nginx, allowing it to handle multiple domains or subdomains within the same Nginx instance.

A client request - for instance, to get a web page from the server - can only be handled by a single server context and Nginx decides which context is appropriate based on details contained in the request sent by the client. The selection is based on comparing the HTTP request header details with the following directives:

This is explained below, using this site's configuration to illustrate the ideas.

Redirecting HTTP to HTTPS

Modern websites should use the secure HTTPS protocol rather than HTTP, and it is good practice to serve up all your content using HTTPS even when requests are made to an HTTP address. You will need an SSL certificate to enable HTTPS - if you haven't already got one for your site you can read about them here:

Securing your site with an SSL Certificate
If you want your website to use HTTPS you’ll need an SSL certificate. Read on to find out what this all means, starting with a bit of history. HTTP When Tim Berners Lee launched the first ever website in 1991 it was accessible using a protocol called HTTP (Hypertext Transfer

The first server block ensures that requests made to the site using HTTP are automatically served via HTTPS:

server {
    listen 80; # IPv4
    listen [::]:80 ; #IPv6
    server_name www.smallworkshop.co.uk smallworkshop.co.uk;
    return 301 https://smallworkshop.co.uk$request_uri;
}

As mentioned above, this block is initially selected based on the listen directives.

The first listen directive looks for IPv4 request received on port 80 (HTTP requests use port 80 and HTTPS requests use port 443).

Since the server has to handle clients that use IPv6[3] as well as IPv4 there are two separate listen directives.

The [::] address contained on the second line is a wildcard address that ensures NGINX listens on all available IPv6 addresses.

The return directive tells Nginx to stop processing the request and to a send status code 301 (Moved Permanently) to the client. The return statement includes a rewritten redirect address that uses https rather than http (the $host and $request_uri are variables set automatically by Nginx for each client request). On receiving the 301 message the client then try again using the https address instead of the original url.

When the server receives the redirected request it will be matched by the next block which is listening for requests received on the HTTPS port (443).

Note that some cloud based load balancers are set up to automatically redirect HTTP requests to HTTPS before your server sees them but - even if you use one of these services - it is still good practice to include an explicit redirection in your local configuration in any case.

HTTPS configuration

To allow the server block to handle HTTPS the ssl parameter must be set on the listen directive and you must also specify the location of the site SSL certificate and private key. If you are using certbot to manage installation and renewal of your certificate it will add these details for you automatically, but you may need to amend the http and https listen directives to include the syntax to match IPv6 addresses after you install your certificate:

server {
    listen 443 ssl;                   # For IPv4
    listen [::]:443 ssl;              # For IPv6
    http2 on;

    server_name smallworkshop.co.uk;

    ssl_certificate /etc/letsencrypt/live/smallworkshop.co.uk/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/smallworkshop.co.uk/privkey.pem; # managed by Certbot
    
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

The http2 directive enables support for the latest version of the HTTP protocol[4].

Note that certbot also adds links to two other configuration files

  1. options-ssl-nginx.conf
  2. ssl-dhparams.pem

... which configure the recommended SSL/TLS settings to secure HTTPS. When the Certbot software is upgraded on the server these files will be updated to keep configurations aligned with evolving security standards and therefore should not be changed manually.

www subdomain mapping

The days where people routinely typed in a "www." subdomain to indicate a world-wide-web url are long gone, however, accounting for use of the subdomain is still good practice. Here's how to set it up:

  1. DNS Configuration:
    Add a CNAME record in your DNS settings to point "www" requests to your root domain. This configuration tells the DNS to return the IP address for the root domain whenever a request is made to the "www" subdomain.
redirecting www requests using AWS Lightsail DNS - your DNS service provider will provide similar configuration option
  1. Nginx Configuration:
    While the DNS CNAME entry ensures the browser receives the correct IP address, the HTTP host header will still reference "www". To manage these requests on the server, add a redirect block in your Nginx configuration to route "www" requests to the root domain.

In the configuration above, the "www" requests are handled in the first redirection server block which is looking for both requests to both www.smallworkshop.co.uk and smallworkshop.co.uk.

Note the above configuration is not perfect. It will work if someone types www.smallworkshop.co.uk into their browser - because the browser defaults to sending a HTTP request - however if they explicitly request the https address https://www.smallworkshop.co.uk they will get a certificate error as there is no server block to handle that. If you want to avoid the certificate error you need to install a separate certificate for the www subdomain and create a new redirect server block to handle these requests.

Security Headers

You can set common security headers in the response headers returned to the client that will stop modern browsers from running into some preventable vulnerabilities:

# add security headers
add_header Strict-Transport-Security "max-age=31536000";
add_header Referrer-Policy "no-referrer";
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()";
add_header X-Content-Type-Options "nosniff"; 
add_header X-Frame-Options "sameorigin";
add_header X-Xss-Protection "1; mode=block";
proxy_hide_header X-Powered-By;

The first header is particularly important: although requests made to your server using HTTP are redirected to HTTPS (see above), any initial request using HTTP would be in plain text and vulnerable to attackers who can intercept the messages and change the data. The Strict-Transport-Security header is designed to protect against this. The other items are recommended for standard websites.

Serving images and other files

There is quite a lot going on in the remainder of the server block, this is what the directives do:

  1. serve images and theme assets (css files etc) directly from Nginx rather than via the Ghost proxy
  2. serve a webp version of requested images (where available) instead of any jpg/png image referenced in the html
  3. proxy all other requests to the Ghost server

Let's start by looking at how images are served up.

Serving images directly via NGINX

The Ghost server needs to handle all the requests for web pages on your site as it has to render the JSON content held on the database as html before returning it to the client.

When Ghost receives a request for images and other static files that are stored on the local server it will retrieve the file from the file system and, although Ghost can do this very efficiently, this adds in some overheads that can be avoided by serving the files directly through Nginx. In practice this is unlikely to represent a noticeable performance improvement unless you have a very high volume site but - if you are interested - this is how you do it.

All locally stored user data accessible by Ghost is stored in the location specified in the contentPath object in the server congiruation file config.production.json.
The default location is /content, for example on my server images are located in /var/www/ghost/content/images.

The images links in the html are stored like this:

<a href="https://smallworkshop.co.uk" class="m-logo">
    <img src="https://smallworkshop.co.uk/content/images/2021/10/circle-cropped-1.png" alt="The Small Workshop">
</a>

note that the path to the image is a relative location and does not include the /var/www/ghost installation directory.

So that Nginx can locate the local file we use the root directive to add the specified path, /var/www/ghost, to the request URI to form the absolute path to the requested file. So, for instance, /content/images/2018/04/17/img_123.jpg becomes /var/www/ghost/content/images/2018/04/17/img_123.jpg

To retrieve the images using Nginx we need to define a location block. Location blocks divide up request handling within a server block so that any URI matching the specified location receive special handling.

In this case we are matching request URIs that point to image files on the server. This is how the location declaration is specified:

location ~* ^(/content/images/)(.+)\.(png|jpe?g)$ {

}

The ~* parameter tells nginx to do a case insensitive regular expression match. The regex matches URIs that start with /content/images and have a suffix of png, jpg or jpeg.

The () syntax defines three "capture groups" and causes Nginx to set three variables ($1,$2,$3) based on the patterns matched inside each of the three pairs of parentheses.

For example a request for /content/images/2020/10/img_123.jpg will match this location and Nginx will create 3 variables:

Variables like this are useful when arranging subsequent handling of requested files, as shown below.

Serving webp images

I have created webp versions of all the png and jpg images stored on my Ghost server and stored them in a directory called /var/www/ghost/content/images/webp. This was done so that, where a webp alternative is available, it can be served up in preference to the (larger) jpg/png files I originally used.

The following directives define two variables, the first is hard coded to the location of the webp files and the next is set to the second variable returned by the capture groups in the regex above:

set $webp_image_subdir "/content/images/webp/";
set $basename $2;

The commented out directive below creates a header named X-debug-message (this can be helpful when trying to establish which file was actually served by Nginx) and the following line specifies a try_files directive:

# add_header X-debug-message "nginx served file: $webp_image_subdir$basename$webp_suffix or $uri" always;
try_files $webp_image_subdir$basename$webp_suffix $uri @ghost;

the try_files directive tells Nginx to check the existence of files in the specified order and to use the first found file for request processing. In this case, if a file can't be found at either of the first two locations, the request is passed to the @ghost location (the “@” prefix defines a named location that is used for request redirection inside the Nginx config file).

The first file location is constructed using three variables, $web_image_subdir, $basename, $web_suffix. For example a request URI of /content/images/2021/10/img_123.jpg would result in a file location of /content/images/webp/2021/10/img_123.webp as follows:

The first two variables are explained above. The $webp_suffix variable is set in /etc/nginx/nginx.conf with this directive:

map $http_accept $webp_suffix {
    "~*webp" ".webp";
}

The map directive matches the http_accept header provided by the client to see if it contains a reference to "webp" (indicating the browser supports webp images) and, if it does, sets $webp_suffix = .webp.

The effect of this directive is that the $webp_suffix variable is only set if the browser supports webp.

The root directive adds the specified path, /var/www/ghost, to the request URI (the portion of the request that comes after the domain name/port combination in the URL) to form the path to the requested file on the local file system. For instance /content/images/2021/10/img_123.jpg becomes /var/www/ghost/content/images/2021/10/img_123.jpg

Thus in the case of a request for /content/images/img_123.jpg made from a browser with webp support, Nginx will initially try /var/www/ghost/content/images/webp/2021/10/img_123.webp and, if that file does not exist, will then try the original file /var/www/ghost/content/images/2021/10/img_123.jpg.

If the browser does not support webp images then the initial filename will be rendered as /var/www/ghost/content/images/webp/2021/10/img_123 and - because this will not point to an image file - nginx will move on and try for a file at the URI specified in the original request instead.

In the event that neither of the files specified in the first two try_file parameters then Nginx will pass the request to the @ghost location. The reason for this step is explained in the next section.

Preserving Ghost's dynamic responsive images.

Ghost supports responsive images which means it can serve up appropriate sized images according to the resolution of the screen being used to browse the site. It does this by creating the img elements in the rendered html with a srcset attribute:

<img srcset="
    /content/images/size/w300/2021/10/img_123.jpg 300w,
    /content/images/size/w600/2021/10/img_123.jpg 600w,
    /content/images/size/w1000/2021/10/img_123.jpg 1000w,
    /content/images/size/w2000/2021/10/img_123.jpg 2000w
    "
    sizes="(max-width: 600px) 600px, (max-width: 1000px) 1000px, 2000px"
    src="/content/images/size/w1000/2021/10/img_123.jpg"
    alt=""
/>

Browsers that support srcset will request an appropriate size image based on the resolution of the client device. With a standard Ghost set-up all image request are passed to the Ghost server, which - if it can't locate a reduced size image in the /content/images/size directory - will automatically create the appropriately sized file so that it can be served up in future requests. This means Ghost is able to build up an archive of responsive image sizes as they are requested for the first time.

To preserve this functionality the final location on the try_files directive must be set to the @ghost location so that, in the event a request is made for a responsive image that has yet to be created , the Ghost server still has an opportunity to create it.

Serving theme assets directly

As with images, there is no need to pass .css and other theme files to the Ghost server and this block below server them directly via Nginx. Note we have to hard code the theme location - this is not ideal since you will need to update it whenever you change theme, however, it is not the end of the world if you forget since, when nginx can't find the file, the request is then passed to the ghost server which will know about the new theme location.

#nginx serve the theme files directly
    location ^~ /assets/ {
        root /var/www/ghost/content/themes/transmission;
        # add cache-control header when serving assets from nginx (added by Ghost when using the proxy)
        add_header Cache-Control 'public,max-age=31536000';
        try_files $uri @ghost;
    } 

Ghost proxy server

The @ghost location sets the headers expected by the server and then passes the request to the Ghost server, which normally runs on port 2368:

location @ghost {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        proxy_pass http://127.0.0.1:2368;
    }   

Nginx will handle SSL termination (the process of decrypting this encrypted traffic) and send data to Ghost using HTTP. Thex-forwarded-proto setting tells Ghost that the requests coming into the proxy are secure[5]. Without this, Ghost will think the requests are insecure, attempt to redirect to the https version of the URL and cause an infinite redirect loop.

The final location block is needed to make sure all the standard page requests are also sent to the ghost server:

location / {
        try_files _ @ghost;
    }  

tryfiles always expects 2 or more parameters and by convention _ is used to represent a location that should never be matched, therefore causing all requests that were not handled by the image and asset locations to be passed to Ghost.

nginx.conf

You will only need to change this file if you are using the advanced nginx features, such as content caching. Note that the standard Ghost cache will be adequate for most applications, and the Ghost developers generally recommend implementing a CDN if you have high traffic volumes or lots of static content (they use Fastly on their managed GhostPro service).

A note on certificate renewal

I use a Let's Encrypt certificate managed by certbot on this site and installed it separately. There is also an option to get the Ghost software installer to deploy a Let's Encrypt certificate and this will also set up a certificate renewal service. At the time I installed my server Ghost was using acme.sh to manage certificate renewals and apparently this script needs the following directive in the main server block to work:

location ~ /.well-known {
    allow all;
}

That's it! The next job is to sort out your system backups.


References

1⏎ this is the directory structure used in Ubuntu and most othe debian derived distribitutions, it may vary on other distributions.
2⏎ It is normal practice to only keep symbolic links to files in this directory as it makes it easier to switch between site configurations while retaining the original files.
3⏎ IPv6 is replacing IPv4 because IPv4's 32-bit address space (allowing for about 4.3 billion unique addresses) is insufficient to meet the growing number of internet-connected devices. IPv6, with its 128-bit address space, can support a vast number of unique addresses, but is not yet fully rolled-out.
4⏎ this is the latest syntax, replacing the deprecated syntax where http2 was included as a parameter in the listen directive.
5⏎ The $scheme variable will always be set to https in this configuration - see the section on redirecting HTTP to HTTPS above