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 to configure Nginx then here are some things you can change.

A basic Nginx set-up for a site running on a Ubuntu system involves two configuration files:

The default nginx.conf is set up to process all the site specific files listed in the /etc/nginx/sites-enabled directory[1].

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


# this block handles http requests and redirects them to the second block that handles https
server {
    listen 80;
    listen [::]:80 ipv6only=on;

    return 301 https://$host$request_uri;
}

server {
    listen [::]:443 ssl http2 ipv6only=off; # managed by Certbot
    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;

    root /var/www/ghost;

    #nginx serve the theme files directly
    
    location ^~ /assets/ {
        root /var/www/ghost/content/themes/liebling;
        # 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 sometimes referred to as blocks.

the /etc/etc/nginx.conf file holds the 'http' context, which contains all of the directives necessary to define how the program will handle HTTP or HTTPS connections. This file includes site-specific directives with this instruction:

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

The site specific configuration file defines the server contexts. The reason for allowing multiple server contexts for a single site is that each instance defines a specific virtual server to handle client requests. Each client request can only be handled by a single server context and Nginx decides which context is appropriate based on the details of the request. The selection is made based on the following directives:

The server_name directive can be used to tell Nginx to only serve requests to specific subdomains (this is useful if you want to have several virtual servers running on a single server, for instance: dev.example.org, test.example.org, example.org).

Redirecting HTTP to HTTPS

Modern web sites 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;
    listen [::]:80 ipv6only=on;

    return 301 https://$host$request_uri;
}

This block is selected based on the listen directives. The first listen directive looks for IPv4 request received on port 80/HTTP.

The [::] address contained on the second line is a wildcard address for IPv6 addresses. By default it only accepts IPv6 connections.

In should be possible to match both IPv6 and IPv4 addresses with a single directive:

listen [::]:80 ipv6only=off

Unfortunately the certificate renewal script I'm using (certbot) fails when using a combined IPv6/IPv4 listen statement like this, so they have to be listed separately in the http block.

The return directive tells nginx to stop processing the request and to send status code 301 (Moved Permanently) to the client, including 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).

The redirected request is then matched by the next block which is listening for requests received on port 443/HTTPS.

HTTPS configuration

The ssl parameter must be set on the listen directive for the server and the location of the server certificate and private key should be specified. If you are using certbot to manage installation and renewal of your certificate it will add these details for you automatically:

server {
    listen [::]:443 ssl http2 ipv6only=off; # managed by Certbot

    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

www subdomain mapping

The days where people routinely typed in a "www." subdomain to indicate a world-wide-web url are long gone, but in case it still happens now and then, you may like to map these requests to the root domain name. Although this can be done in the Nginx configuration, the usual way to do this is to create a CNAME entry in your DNS that maps any www requests to the root domain name. This server is running on AWS Lightsail and Amazon provides a simple interface for creating new DNS records:

redirecting www requests using AWS Lightsail DNS

Security Headers

You can set common security headers in the response 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, but the key features are that it will:

  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 the 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 without the installation directory:

<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>

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 (the portion of the request that comes after the domain name/port combination in the URL) to form the path to the requested file. 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 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 servers them directly. 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 be know the location.

#nginx serve the theme files directly
    location ^~ /assets/ {
        root /var/www/ghost/content/themes/liebling;
        # 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[2]. 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.

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⏎ 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.
2⏎ The $scheme variable will always be set to https in this configuration - see the section on redirecting HTTP to HTTPS above