Hello nerds! Welcome to TechWeirdo. In this post we are going to setup Fastly CDN for a selfhosted Ghost Blog using the Fastly free tier. Ghost Pro uses Fastly so I always wanted to do same for myself. But Fastly was surprisingly hard to setup for me (I am not an engineer). I have previously written guide for CloudFlare and Bunny CDN, and those were much much easier. So you are probably better off with those CDNs. But if you want to setup Fastly, then proceed. You can just copy-paste the VCL code from below and profit.
At Fastly, there is no easy interfaces. Fastly uses Varnish Cache and gives you the complete flexibility to configure very complex and advanced caching. But you need to write VCL code to do the setup. There are only a few guides and also all AI chat bots suck at writing VCL. Now I also had a working Varnish Cache setup for Ghost, so I started with that. But fastly VCL is slightly different, there were a lot of trial and error, but I am finally happy with something and in this post I will share that. It may have some mistakes, if it does, please comment down below and let me know. Let's begin.
Features of this VCL
- Full page caching for all visitors, who are not logged in. The entire site is served from CDN cache to them.
- Bypass cache user specific dynamic contents like htmls, account infos, membership details etc for users who are logged in.
- Always cache static assets like images, css, js and serve them from cache.
- Different cache timing for htmls which changes frequently when you publish something and longer cache time for static assets that rarely changes. It is all configurable using dictionaries (more on it later).
- Early hints 103 request to speed up content delivery. If you wanna know more about it, here is a great blog post.
- Basic block rules. WAF is NOT available on free tier. But varnish is powerfull and you can do a lot on the free tier. Like blocking sign ups using VPNs, tors, Bad user agents etc etc.
- Grannular cache purging. Well you need to setup some kind of webhook but you can configure it to only purge the htmls from the cache and keep the static assets.
... and many more. I will be explaining each part of the VCL first and at the end provide you with the complete VCL. lets setup Fastly first.
Create a Fastly account and setup your distribution
Go to https://www.fastly.com/signup and create a developer account. You will be asked to create your first distribution, fill that up. Then on the fastly dashboard select service-configuration
. Then follow the steps below:
- Go to
domains
and input your desired Ghost log domain. I prefer to usewww.
instade of a naked domain. But that is also okay. Also add a temporary testing domain likeyourblog.global.ssl.fastly.net
, this will help you to test the CDN before you switch your live blog. - Go to Host and put your server IP address and add a host. If you have SSL at your origin server enable TLS. You can ignore SSL verification (I do this as I use server generated SSL). Don't forget to set the
SNI host name
andoverride host
(both should usually be your blog domain). - On the same page configure shielding. A general rule of thumb is to put the
origin shield
at the closest PoP to your origin server. If you want to deploy multiple origin you can setup health checks, auto load balance and a shield for each origin. - Click on the settings menu and allow serving stale content (Your site will remain accessible if your origin is down, if it is cached). Force TLS and switch to production. Enable HTTP/3. Ghost Pro uses cache reserve, but it is not available on the free plan.
- Go to Content and enable
Brotli
. You can also customise your 404/503 error pages if you want. Just put your html. - You can setup logging if you want. I use new relic. You can skip it initially.
Now activate your CDN distribution (top-right corner, at the time of writing). Visit the domain name you have setup. After activation Clone
the deployment to edit further.

Setting up Fastly Dictionaries
At the bottom of your left side menu in the CDN tab you will find Dictionaries. You need to create the following dictionaries for the VCL to work properly. Important: You must create each dictionary (numbered and underlined) even if you don't plan to add any entries to it initially.
How to Create Dictionaries
- Go to your Fastly service configuration
- Navigate to CDN > Dictionaries in the left sidebar
- Click Create Dictionary
- Enter the dictionary name exactly as shown above
- Add the required key-value pairs for each dictionary
- Click Save and Activate your configuration
Required Dictionaries
1. cache_config
This dictionary controls caching behavior and timing.
Dictionary Name: cache_config
Required Entries:
- Key:
static_cache_time
Value:31536000
(1 year in seconds). (For the images, css, js etc) - Key:
dynamic_cache_time
Value:86400
(1 day in seconds) or higher/lower based on your needs. This is for the HTML. Note that we can setup auto cache purging when you update your post too. - Key:
stale_on_error
Value:true
2. security_config
Controls various security features and protection levels.
Dictionary Name: security_config
Required Entries:
- Key:
country_blocking_enabled
Value:false
, change it totrue
if you want to block any country. - Key:
ip_whitelist_enabled
Value:false
ortrue
- Key:
server_ip_whitelist_enabled
Value:true
- Key:
admin_protection_level
Value:low
/medium
/high
/extreme
, this is for both admin and signup, members login protection.
Admin Protection Levels:
low
- Blocks only Tor traffic from admin areasmedium
- Blocks VPN and Tor traffichigh
- Blocks hosting/cloud/proxy IPsextreme
- Blocks most non-residential IPs including iCloud private relay.
If you want you can modify the provided custom VCL below. Find the area below in the VCL and modify as per your need. It uses fastly geo-location data from here and here.
# ===== TIERED ADMIN & MEMBERS AREA PROTECTION =====
if (req.url ~ "^/(ghost|members)/") {
if (var.admin_protection_level == "low") {
if (client.geo.proxy_type ~ "^(anonymous|tor-exit|tor-relay)$" || client.geo.proxy_description ~ "^(tor-exit|tor-relay)$") {
error 403 "Admin area access restricted for Tor";
}
} else if (var.admin_protection_level == "medium") {
if (client.geo.proxy_type ~ "^(anonymous|tor-exit|tor-relay|vpn)$" || client.geo.proxy_description ~ "^(tor-exit|tor-relay|vpn)$") {
error 403 "Admin area access restricted for VPN, Tor etc";
}
} else if (var.admin_protection_level == "high") {
if (client.geo.proxy_type ~ "^(anonymous|tor-exit|tor-relay|hosting|corporate|public)$" || client.geo.proxy_description ~ "^(tor-exit|tor-relay|cloud)$") {
error 403 "Admin area access restricted to Cloud, Proxy, VPN, Tor IP addresses";
}
} else if (var.admin_protection_level == "extreme") {
if (client.geo.proxy_type ~ "^(anonymous|tor-exit|tor-relay|vpn|hosting|consumer-privacy|corporate|public|cloud-security)$" || client.geo.proxy_description ~ "^(tor-exit|tor-relay|vpn|cloud|apple|google|cloud-security)$") {
error 403 "Admin area access restricted, use a residential internet";
}
}
}
3. server_ip_whitelist
Whitelist for server IPs that should bypass all security checks.
Dictionary Name: server_ip_whitelist
Example Entries (optional):
- Key:
203.0.113.1
Value:true
- Key:
198.51.100.5
Value:true
4. ip_whitelist
General IP whitelist for trusted IPs.
Dictionary Name: ip_whitelist
Example Entries (optional):
- Key:
203.0.113.10
Value:true
- Key:
198.51.100.20
Value:true
5. blocked_countries
Country codes to block (only works if country blocking is enabled security_config above). Even if you dont enable it in you have to create a dictionary named blocked_countries
.
Dictionary Name: blocked_countries
Example Entries (optional):
- Key:
AA
Value:true
- Key:
BB
Value:true
- Key:
CC
Value:true
Important Notes
- Create all dictionaries: Even if you don't need a particular feature (like country blocking),
you must still create the empty dictionary or the VCL will fail
. - Case sensitivity: Dictionary names and keys are case-sensitive.
- Default values: The VCL includes fallback default values, but creating the dictionaries with proper values gives you full control.
- Easy updates: You can modify dictionary values without changing the VCL code and redeploy instantly.

The Custom VCL
Visit custom vcl
from the left menu. Select upload a new VCL file and in the edit area paste this VCL code. Please note that you need to modify some lines of the VCL for the Early hints. So see below for that.
Custom VCL Code:
# Version: 4.0
# Logic: Baseline global security, unconditional admin protection, expanded static file matching, and granular surrogate keys.
# ==============================================================================
# vcl_recv: Main request processing and security checks
# ==============================================================================
sub vcl_recv {
#FASTLY recv
h2.early_hints(
"link: <https://assets.techweirdo.net>; rel=preconnect",
"link: <https://cdn.jsdelivr.net>; rel=preconnect; crossorigin",
"link: <https://storage.ko-fi.com>; rel=preconnect",
"link: <https://fonts.googleapis.com>; rel=preconnect",
"link: <https://fonts.gstatic.com>; rel=preconnect; crossorigin",
"link: <https://assets.techweirdo.nethttps://assets.techweirdo.net/assets/dist/app.min.js?v=2c4fe79446>; rel=preload; as=script",
"link: <https://assets.techweirdo.nethttps://assets.techweirdo.net/assets/dist/app.min.css?v=2c4fe79446>; rel=preload; as=style",
"link: <https://assets.techweirdo.nethttps://assets.techweirdo.net/public/cards.min.css?v=04282d2b59>; rel=preload; as=style"
);
# Properly forward client IP for backend and security checks
if (fastly.ff.visits_this_service == 0 && req.restarts == 0) {
set req.http.Fastly-Client-IP = client.ip;
}
# Build proper X-Forwarded-For chain
if (req.http.X-Forwarded-For) {
set req.http.X-Forwarded-For = req.http.X-Forwarded-For ", " req.http.Fastly-Client-IP;
} else {
set req.http.X-Forwarded-For = req.http.Fastly-Client-IP;
}
# Set multiple client IP headers for compatibility
set req.http.X-Real-IP = req.http.Fastly-Client-IP;
set req.http.X-Client-IP = req.http.Fastly-Client-IP;
set req.http.CF-Connecting-IP = req.http.Fastly-Client-IP;
# ===== POP TRACE ENDPOINT =====
if (req.url == "/pop-trace") {
error 701 "pop-trace";
}
# ===== CONFIGURATION & DICTIONARY LOOKUPS =====
declare local var.cache_time_static STRING;
declare local var.cache_time_dynamic STRING;
declare local var.enable_stale_on_error STRING;
declare local var.country_blocking_enabled STRING;
declare local var.ip_whitelist_enabled STRING;
declare local var.server_ip_whitelist_string STRING;
declare local var.admin_protection_level STRING;
set var.cache_time_static = table.lookup(cache_config, "static_cache_time", "31536000");
set var.cache_time_dynamic = table.lookup(cache_config, "dynamic_cache_time", "31536000");
set var.enable_stale_on_error = table.lookup(cache_config, "stale_on_error", "true");
set var.country_blocking_enabled = table.lookup(security_config, "country_blocking_enabled", "false");
set var.ip_whitelist_enabled = table.lookup(security_config, "ip_whitelist_enabled", "false");
set var.server_ip_whitelist_string = table.lookup(security_config, "server_ip_whitelist_enabled", "false");
set var.admin_protection_level = table.lookup(security_config, "admin_protection_level", "low");
# ===== WHITELISTING (BYPASSES ALL SECURITY) =====
if (var.server_ip_whitelist_string == "true") {
if (table.lookup(server_ip_whitelist, req.http.Fastly-Client-IP, "false") == "true") {
set req.http.X-Server-IP = "true";
return(lookup);
}
}
if (var.ip_whitelist_enabled == "true") {
if (table.lookup(ip_whitelist, req.http.Fastly-Client-IP, "false") == "true") {
set req.http.X-Whitelist-Status = "whitelisted";
return(lookup);
}
}
# ===== BASELINE SECURITY (APPLIES TO ALL REQUESTS) =====
if (req.http.User-Agent == "" || req.http.User-Agent ~ "^\s*$") {
error 403 "Empty User-Agent blocked";
}
if (req.http.User-Agent ~ "(?i)(headless|phantom|selenium|webdriver|automation|spider)") {
error 403 "Automated browser blocked";
}
if (req.http.User-Agent ~ "(?i)(nikto|sqlmap|nmap|wpscan|masscan|zgrab|acunetix|netsparker|python-requests|go-http-client)") {
error 403 "Malicious tool detected";
}
if (var.country_blocking_enabled == "true") {
if (table.lookup(blocked_countries, client.geo.country_code, "false") == "true") {
error 403 "Access denied from your country";
}
}
# ===== TIERED ADMIN & MEMBERS AREA PROTECTION =====
if (req.url ~ "^/(ghost|members)/") {
if (var.admin_protection_level == "low") {
if (client.geo.proxy_type ~ "^(anonymous|tor-exit|tor-relay)$" || client.geo.proxy_description ~ "^(tor-exit|tor-relay)$") {
error 403 "Admin area access restricted for Tor";
}
} else if (var.admin_protection_level == "medium") {
if (client.geo.proxy_type ~ "^(anonymous|tor-exit|tor-relay|vpn)$" || client.geo.proxy_description ~ "^(tor-exit|tor-relay|vpn)$") {
error 403 "Admin area access restricted for VPN, Tor etc";
}
} else if (var.admin_protection_level == "high") {
if (client.geo.proxy_type ~ "^(anonymous|tor-exit|tor-relay|hosting|corporate|public)$" || client.geo.proxy_description ~ "^(tor-exit|tor-relay|cloud)$") {
error 403 "Admin area access restricted to Cloud, Proxy, VPN, Tor IP addresses";
}
} else if (var.admin_protection_level == "extreme") {
if (client.geo.proxy_type ~ "^(anonymous|tor-exit|tor-relay|vpn|hosting|consumer-privacy|corporate|public|cloud-security)$" || client.geo.proxy_description ~ "^(tor-exit|tor-relay|vpn|cloud|apple|google|cloud-security)$") {
error 403 "Admin area access restricted, use a residential internet";
}
}
}
# ===== CACHING LOGIC =====
if (req.method != "HEAD" && req.method != "GET" && req.method != "FASTLYPURGE") {
return(pass);
}
# UPDATED: Expanded static file matching for cacheability checks.
if (req.url ~ "\.(7z|apk|avi|avif|bin|bmp|bz2|class|css|csv|dmg|doc|docx|ejs|eot|eps|exe|flac|gif|gz|ico|iso|jar|jpe?g|js|mid|midi|mkv|mp3|mp4|ogg|otf|pdf|pict|pls|png|ppt|pptx|ps|rar|svg|svgz|swf|tar|tiff?|ttf|webm|webp|woff2?|xls|xlsx|zip|zst)(\?.*)?$") {
unset req.http.Cookie;
return(lookup);
}
if (req.http.Cookie ~ "ghost-members-ssr" || req.http.Cookie ~ "ghost-admin-api-session" || req.http.Cookie ~ "ghost-private") {
return(pass);
}
if (req.url ~ "^/(#|p)/") {
return(pass);
}
unset req.http.Cookie;
return(lookup);
}
# ==============================================================================
# vcl_hash: Defines the cache key
# ==============================================================================
sub vcl_hash {
#FASTLY hash
set req.hash += req.url;
set req.hash += req.http.host;
return(hash);
}
# ==============================================================================
# vcl_hit, vcl_miss, vcl_pass: Standard passthrough actions
# ==============================================================================
sub vcl_hit {
#FASTLY hit
return(deliver);
}
sub vcl_miss {
#FASTLY miss
return(fetch);
}
sub vcl_pass {
#FASTLY pass
return(pass);
}
# ==============================================================================
# vcl_fetch: After retrieving content from the backend
# ==============================================================================
sub vcl_fetch {
#FASTLY fetch
declare local var.cache_time_static STRING;
declare local var.cache_time_dynamic STRING;
declare local var.enable_stale_on_error STRING;
set var.cache_time_static = table.lookup(cache_config, "static_cache_time", "31536000");
set var.cache_time_dynamic = table.lookup(cache_config, "dynamic_cache_time", "31536000");
set var.enable_stale_on_error = table.lookup(cache_config, "stale_on_error", "true");
# UPDATED: Granular surrogate key logic.
# Tier 1: CSS and JS files get the 'static-assets' key.
if (req.url ~ "\.(css|js)(\?.*)?$") {
set beresp.http.Surrogate-Key = "static-assets";
set beresp.ttl = std.time(var.cache_time_static + "s", 31536000s);
# Tier 2: All other static files get the 'static-media' key.
} else if (req.url ~ "\.(7z|apk|avi|avif|bin|bmp|bz2|class|csv|dmg|doc|docx|ejs|eot|eps|exe|flac|gif|gz|ico|iso|jar|jpe?g|mid|midi|mkv|mp3|mp4|ogg|otf|pdf|pict|pls|png|ppt|pptx|ps|rar|svg|svgz|swf|tar|tiff?|ttf|webm|webp|woff2?|xls|xlsx|zip|zst)(\?.*)?$") {
set beresp.http.Surrogate-Key = "static-media";
set beresp.ttl = std.time(var.cache_time_static + "s", 31536000s);
# Tier 3: Everything else is dynamic.
} else {
set beresp.http.Surrogate-Key = "dynamic";
set beresp.ttl = std.time(var.cache_time_dynamic + "s", 31536000s);
}
if (var.enable_stale_on_error == "true") {
set beresp.stale_while_revalidate = 31536000s;
set beresp.stale_if_error = 31536000s;
}
if (beresp.http.Cache-Control) {
set beresp.http.X-Orig-Cache-Control = beresp.http.Cache-Control;
}
if (req.restarts > 0) {
set beresp.http.Fastly-Restarts = req.restarts;
}
if (beresp.http.Set-Cookie || beresp.http.Cache-Control ~ "(?:private|no-store)") {
return(pass);
}
if (!beresp.http.Expires && !beresp.http.Surrogate-Control ~ "max-age" && !beresp.http.Cache-Control ~ "(?:s-maxage|max-age)") {
set beresp.ttl = std.time(var.cache_time_dynamic + "s", 31536000s);
}
return(deliver);
}
# ==============================================================================
# vcl_error: Handles errors and generates synthetic responses
# ==============================================================================
sub vcl_error {
#FASTLY error
if (obj.status == 701) {
set obj.status = 200;
set obj.response = "OK";
set obj.http.Content-Type = "text/plain; charset=utf-8";
synthetic {"POP: "} server.datacenter {" ("} server.region {")
Server: "} server.hostname {"
IP: "} server.ip {"
Request ID: "} req.http.X-Request-ID {"
Client IP: "} client.ip {"
Country: "} client.geo.country_code {"
City: "} client.geo.city {"
ISP: "} client.as.name {"
Proxy Type: "} client.geo.proxy_type {"
Proxy Description: "} client.geo.proxy_description {"
Time: "} now {"
"};
return(deliver);
}
if (obj.status == 403 || obj.status == 444 || obj.status == 405) {
set obj.http.Content-Type = "text/html; charset=utf-8";
synthetic {"
<!DOCTYPE html>
<html>
<head>
<title>Access Denied</title>
<style>
body{font-family:Arial,sans-serif;text-align:center;margin:50px;background:#f5f5f5}
.error{background:#fff;padding:30px;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,0.1);display:inline-block}
h1{color:#e74c3c;margin:0 0 10px}
p{color:#666;margin:10px 0}
.btn{display:inline-block;padding:10px 20px;background:#3498db;color:#fff;text-decoration:none;border-radius:4px;margin:10px 5px}
.btn:hover{background:#2980b9}
</style>
</head>
<body>
<div class="error">
<h1>Access Denied</h1>
<p>You don't have permission to access this resource.</p>
<p>Error: "} obj.status {" - "} obj.response {"</p>
<a href="/" class="btn">Home</a>
<a href="javascript:history.back()" class="btn">Go Back</a>
</div>
</body>
</html>
"};
}
return(deliver);
}
# ==============================================================================
# vcl_deliver: Final processing before delivering to the client
# ==============================================================================
sub vcl_deliver {
#FASTLY deliver
if (resp.http.X-Orig-Cache-Control) {
set resp.http.Cache-Control = resp.http.X-Orig-Cache-Control;
unset resp.http.X-Orig-Cache-Control;
} else {
set resp.http.Cache-Control = "no-cache, must-revalidate";
}
set resp.http.X-Frame-Options = "SAMEORIGIN";
set resp.http.X-Content-Type-Options = "nosniff";
set resp.http.X-XSS-Protection = "1; mode=block";
set resp.http.Referrer-Policy = "strict-origin-when-cross-origin";
set resp.http.X-Debug-Country = client.geo.country_code;
set resp.http.X-Debug-Client-IP = client.ip;
set resp.http.X-Debug-Proxy-Type = client.geo.proxy_type;
set resp.http.X-Debug-Proxy-Description = client.geo.proxy_description;
unset resp.http.Surrogate-Key;
unset resp.http.Fastly-Restarts;
return(deliver);
}
# ==============================================================================
# vcl_log: Logging
# ==============================================================================
sub vcl_log {
#FASTLY log
}

Required Modifications in the Custom VCL:
Cache bypass Url Paths:
In this VCL and on my live setup I bypass cache for only members cookie and admin cookie. And only very specific urls to maximize cache hits. The relevant part can be found here
if (req.http.Cookie ~ "ghost-members-ssr" || req.http.Cookie ~ "ghost-admin-api-session" || req.http.Cookie ~ "ghost-private") {
return(pass);
}
if (req.url ~ "^/(#|p)/") {
return(pass);
}
As you can see I only bypass URLs containing /p/
i.e. the preview URLs and /#/
to bypass internal URLs, especially the sign-in form. The Admin area and member specif area for a perticular member should avoid being cached because those requests countain either ghost-members-ssr
for members ghost-admin-api-session
for admins and ghost-private
for private sites. But if you face any problems you can add /members/
, /ghost/
and the recent Activity Pub urls. For me this current setup is working fine.
I have no way to test stripe integration though, as stripe is restricted (invite only) in my country. Hopefully it works :).
Early Hints/ http103:
At the very begining of the VCL you will find this code block. Replace the links with the links relevant to your theme and files. It is specific to your setup.
sub vcl_recv {
#FASTLY recv
h2.early_hints(
"link: <https://assets.techweirdo.net>; rel=preconnect",
"link: <https://cdn.jsdelivr.net>; rel=preconnect; crossorigin",
"link: <https://storage.ko-fi.com>; rel=preconnect",
"link: <https://fonts.googleapis.com>; rel=preconnect",
"link: <https://fonts.gstatic.com>; rel=preconnect; crossorigin",
"link: <https://assets.techweirdo.nethttps://assets.techweirdo.net/assets/dist/app.min.js?v=2c4fe79446>; rel=preload; as=script",
"link: <https://assets.techweirdo.nethttps://assets.techweirdo.net/assets/dist/app.min.css?v=2c4fe79446>; rel=preload; as=style",
"link: <https://assets.techweirdo.nethttps://assets.techweirdo.net/public/cards.min.css?v=04282d2b59>; rel=preload; as=style"
);
Baseline Security:
By default the VCL blocks requesta without a User-Agent or user agents like headless|phantom|selenium|webdriver|automation|spider|nikto|sqlmap
|nmap|wpscan|masscan|zgrab|acunetix|netsparker
|python-requests|go-http-client
. You can add or remove any of them. Like adding curl or wget. You will find the relevant codes at around line 74, and it looks like the code block below.
# ===== BASELINE SECURITY (APPLIES TO ALL REQUESTS) =====
if (req.http.User-Agent == "" || req.http.User-Agent ~ "^\s*$") {
error 403 "Empty User-Agent blocked";
}
if (req.http.User-Agent ~ "(?i)(headless|phantom|selenium|webdriver|automation|spider)") {
error 403 "Automated browser blocked";
}
if (req.http.User-Agent ~ "(?i)(nikto|sqlmap|nmap|wpscan|masscan|zgrab|acunetix|netsparker|python-requests|go-http-client)") {
error 403 "Malicious tool detected";
}
Custom Headers:
At the bottom of the VCL you will find the section where I have added some custom headers. You can add some and remove some of the headers present. Many of them are there for debugging and you might not need them. You can find those here.
sub vcl_deliver {
#FASTLY deliver
if (resp.http.X-Orig-Cache-Control) {
set resp.http.Cache-Control = resp.http.X-Orig-Cache-Control;
unset resp.http.X-Orig-Cache-Control;
} else {
set resp.http.Cache-Control = "no-cache, must-revalidate";
}
set resp.http.X-Frame-Options = "SAMEORIGIN";
set resp.http.X-Content-Type-Options = "nosniff";
set resp.http.X-XSS-Protection = "1; mode=block";
set resp.http.Referrer-Policy = "strict-origin-when-cross-origin";
set resp.http.X-Debug-Country = client.geo.country_code;
set resp.http.X-Debug-Client-IP = client.ip;
set resp.http.X-Debug-Proxy-Type = client.geo.proxy_type;
set resp.http.X-Debug-Proxy-Description = client.geo.proxy_description;
unset resp.http.Surrogate-Key;
unset resp.http.Fastly-Restarts;
return(deliver);
}
Pop Trace End point:
I have added a /pop-trace
end point to the VCL
mostly for fun, kind-of like /cdn-cgi/trace endpoint of cloudflare. So if you visit your blog domain.com/pop-trace you will receive something like this. You can totally remove this part.
POP: BOM (Asia-South)
Server: cache-bom-vanm7210062
IP: 151.101.195.52
Request ID: (null)
Client IP: 49.37.41.204
Country: IN
City: calcutta
ISP: reliance jio infocomm limited
Proxy Type: ?
Proxy Description: ?
Time: Thu, 07 Aug 2025 03:37:44 GMT
Setting up Domain, SSL and DNS
After the setup of the custom VCL and the dictionaries, Activate
the deployment. Then go to the security menu in the left most vertical bar. Select TLS management and manage certificate. Procced with secure another domain
> Use certificate Fastly obtains for you
. Enter your domain name/names. If you are using www
subdomain and want to redirect naked domain to www, a better place to do that your DNS provider. I do that at my Cloudflare DNS.
While creating certificate you can choose from certainly and Let's Encrypt. Both are free to use. You will get some informations to put in your DNS. Like a ACME validation record. A CNAME record which looks like <single letter>.sni.global.fastly.net for example for me it was t.sni.global.fastly.net
.
If you are using www. Domain create a Cname record and put that at your DNS. If you are using naked domain and your DNS does not support CNAME at naked domain (cloudflare does), then add the 4 IP address shown there. This will start routing traffic to Fastly. So before doing this absolutely make sure your site works properly.
Cache Purging
Granular Cache Purging with Surrogate Keys
I have created the VCL in a fashion so that it creates three keys for granular cache purging:
static-assets
- CSS and JS filesstatic-media
- Images, fonts, and other media filesdynamic
- HTML pages and dynamic content
You can purge specific content types using these keys in your Ghost webhook or manually via Fastly's API. On most days you will need to purge only the dynamic
tag when you update your post.
Automating Cache Purging:
To purge cache automatically you need to send post requests to Fastly purge API. Unfortunatly ghost can't send required post request to the Fastly api when you update something on the site. But we can bypass that with some kind of serverless code on Cloudflare Workers or via some automation tool or even a python script running on your Ghost server itself. Here is a Cloudflare Workers script. You can adapt it to any other format, ask Claude AI. This code can purge multiple Fastly distributions togather on the same account.
Follow this post along with the code below:

// Fastly Purge Proxy Worker
// Configure your Fastly API token here
const FASTLY_API_TOKEN = "YOUR_FASTLY_TOKEN"; // Replace with your actual Fastly token
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const url = new URL(request.url);
const { pathname, searchParams } = url;
try {
// Check if soft purge is requested (default to false)
const softPurge = searchParams.get('soft') === '1';
// Handle different purge types based on URL pattern
if (pathname.startsWith('/purge/')) {
// URL purge: /purge/cached_url
return await handleUrlPurge(pathname, softPurge);
} else if (pathname.match(/^\/service\/[^\/]+\/purge_all$/)) {
// Purge all: /service/service_id/purge_all
return await handlePurgeAll(pathname);
} else if (pathname.match(/^\/service\/[^\/]+\/purge\/[^\/]+$/) && !searchParams.has('keys')) {
// Single surrogate key: /service/service_id/purge/surrogate_key
return await handleSingleSurrogateKeyPurge(pathname, softPurge);
} else if (pathname.match(/^\/service\/[^\/]+\/purge$/) && searchParams.has('keys')) {
// Multiple surrogate keys: /service/service_id/purge?keys=key1,key2,key3
const serviceId = pathname.split('/')[2];
const keys = searchParams.get('keys').split(',');
return await handleMultipleSurrogateKeysPurge(serviceId, keys, softPurge);
} else {
return new Response('Invalid purge endpoint', { status: 400 });
}
} catch (error) {
return new Response(`Error: ${error.message}`, { status: 500 });
}
}
async function handleUrlPurge(pathname, softPurge) {
// Extract the cached URL from the path
const cachedUrl = pathname.replace('/purge/', '');
const headers = {
'Fastly-Key': FASTLY_API_TOKEN,
'Accept': 'application/json'
};
if (softPurge) {
headers['fastly-soft-purge'] = '1';
}
const response = await fetch(`https://api.fastly.com/purge/${cachedUrl}`, {
method: 'POST',
headers: headers
});
return new Response(await response.text(), {
status: response.status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
async function handlePurgeAll(pathname) {
// pathname format: /service/service_id/purge_all
const serviceId = pathname.split('/')[2];
const headers = {
'Fastly-Key': FASTLY_API_TOKEN,
'Accept': 'application/json'
};
const response = await fetch(`https://api.fastly.com/service/${serviceId}/purge_all`, {
method: 'POST',
headers: headers
});
return new Response(await response.text(), {
status: response.status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
async function handleSingleSurrogateKeyPurge(pathname, softPurge) {
// pathname format: /service/service_id/purge/surrogate_key
const pathParts = pathname.split('/');
const serviceId = pathParts[2];
const surrogateKey = pathParts[4];
const headers = {
'Fastly-Key': FASTLY_API_TOKEN,
'surrogate-key': surrogateKey,
'Content-Type': 'application/json',
'Accept': 'application/json'
};
if (softPurge) {
headers['fastly-soft-purge'] = '1';
}
const body = JSON.stringify({
surrogate_keys: [surrogateKey]
});
const response = await fetch(`https://api.fastly.com/service/${serviceId}/purge`, {
method: 'POST',
headers: headers,
body: body
});
return new Response(await response.text(), {
status: response.status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
async function handleMultipleSurrogateKeysPurge(serviceId, keys, softPurge) {
// Clean up keys (trim whitespace)
const cleanKeys = keys.map(key => key.trim()).filter(key => key.length > 0);
const headers = {
'Fastly-Key': FASTLY_API_TOKEN,
'surrogate-key': cleanKeys.join(' '),
'Content-Type': 'application/json',
'Accept': 'application/json'
};
if (softPurge) {
headers['fastly-soft-purge'] = '1';
}
const body = JSON.stringify({
surrogate_keys: cleanKeys
});
const response = await fetch(`https://api.fastly.com/service/${serviceId}/purge`, {
method: 'POST',
headers: headers,
body: body
});
return new Response(await response.text(), {
status: response.status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
Configuration
- Set your Fastly API token at the top of the code by replacing
"YOUR_FASTLY_TOKEN"
with your actual token.
Supported Endpoints
1. Purge a URL
GET /purge/www.example.com/path/to/object
- Add
?soft=1
for soft purge - Example:
GET /purge/www.example.com/path/to/object?soft=1
2. Purge All from Service
GET /service/SERVICE_ID/purge_all
- Example:
GET /service/SU1Z0isxPaozGVKXdv0eY/purge_all
3. Purge Single Surrogate Key
GET /service/SERVICE_ID/purge/SURROGATE_KEY
- Add
?soft=1
for soft purge - Example:
GET /service/SU1Z0isxPaozGVKXdv0eY/purge/key_1?soft=1
4. Purge Multiple Surrogate Keys
GET /service/SERVICE_ID/purge?keys=key1,key2,key3
- Add
&soft=1
for soft purge - Example:
GET /service/SU1Z0isxPaozGVKXdv0eY/purge?keys=key_1,key_2,key_3&soft=1
Key Features of the cache purger.
- Dynamic Service ID: Service IDs are extracted from the URL path, so you can work with multiple services
- Soft Purge Control: Use
soft=1
parameter for soft purges, otherwise normal purge - Multiple Key Support: For multiple surrogate keys, use the
keys
parameter with comma-separated values.
Use the dynamic key purging for day to day use. If you do massive changes purge all or css by manually visiting the workers url.
Conclusion
So that's for today. Hopefully the guide is easy enough to follow and your caching works properly. If you have any questions comment down below to let me know. To get future posts directly at your inbox consider subscribing to the newsletter. Have a great day. Bye bye.