Ever since I started this blog I have been using Nginx as a reverse proxy for Ghost.

Ghost is in a kind of weird place between real static and headless CMS like Hugo or Jekyll and fully-fledged CMS like WordPress. Ghost is a Node.js program based on Express that binds to a port and listen to HTTP requests, so it's not deployable like a static website with only static files.

However, the pages are actually static. In development mode, assets will be recompiled upon each request and views will be rerendered. In production mode however, views will be cached. Even with this internal cache, the node.js processes will still have to answer the queries, which doesn't scale well.

Since I was already using Nginx, setting up some proxy cache was really easy and had a huge impact on performance. Since most of my content does not change over time, caching really makes sense.

From my experience, pages cached by Nginx will have a TTFB down to twice as short, and most importantly they'll be able to sustain about 10 times more concurrent requests, at least on my setup, with much less CPU usage. This is useful for high traffic spikes like when I post a link to my blog on Mastodon and hundreds of instances try to fetch the pages in the span of a minute.

My setup has recently changed and I'm using Caddy as a single TLS-terminating reverse proxy, but Ghost still has its own Nginx in its LXC container, which only answers HTTP. The configuration is still easily adaptable.

Here is my configuration, with some comments:

proxy_cache_path /tmp/nginx_ghost levels=1:2 keys_zone=ghostcache:600m max_size=100m inactive=24h;

server {
	listen 80;
	listen [::]:80;
	server_name angristan.xyz www.angristan.xyz;

	if ($host = www.angristan.xyz) {
		return 301 https://angristan.xyz$request_uri;
	}

	access_log /var/log/nginx/ghost-access.log;
	error_log  /var/log/nginx/ghost-error.log;

	location / {
		proxy_set_header Host $host;
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_set_header X-Forwarded-Proto https;
		proxy_pass http://127.0.0.1:2368;

		# Remove cookies which are useless for anonymous visitor and prevent caching
		proxy_ignore_headers Set-Cookie Cache-Control;
		proxy_hide_header Set-Cookie;
		# Add header for cache status (miss or hit)
		add_header X-Cache-Status $upstream_cache_status;

		proxy_cache ghostcache;
		# Default TTL: 1 day
		proxy_cache_valid 1d;
		# Cache 404 pages for 1h
		proxy_cache_valid 404 1h;
		# use conditional GET requests to refresh the content from origin servers
		proxy_cache_revalidate on;
		proxy_buffering on;
		# Allows starting a background subrequest to update an expired cache item,
		# while a stale cached response is returned to the client.
		proxy_cache_background_update on;
		# Bypass cache for errors
		proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504;
	}

	# No cache + keep cookies for admin and previews
	location ~ ^/(ghost/|p/)/ {
		proxy_set_header Host $host;
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_set_header X-Forwarded-Proto https;
		proxy_pass http://127.0.0.1:2368;
	}

	# Bypass ghost for static assets
	location ^~ /assets/ {
		root /srv/ghost/content/themes/casper-xyz;
	}

	# Bypass ghost for original images but not resized ones
	location ^~ /content/images/(!size) {
		root /srv/ghost;
	}
}
My nginx vhost for Ghost

Overall, it's caching everything except admin and preview pages. Also, it bypasses Ghost for static assets (theme's CSS and JS, images) since it has access to the storage. Nginx is very good at serving static files, so it's better to have it handle them than Ghost, since they are not processed (except dynamically resized images).

Ghost response times before and after setting up cache on Nginx