Vercel and Ghost - Sub-directory fun
We host our website on Vercel, and our blog on Ghost, but we wanted them to both be served from the same domain. Nginx came to the rescue!
For some time, we've had our blog hosted on Substack. It's been a fine experience, but as we've grown, so have our needs.
In particular, we really want our blog away from a sub-domain (blog.sunbeam.cx), and part of our primary site domain (www.sunbeam.cx/blog). This isn't a setup that Substack supports, so we decided to move to Ghost.
Our reasons for choosing Ghost could fill a blog post of it's own, but the two main reasons were our team's familiarity with it, and its best-in-class newsletter support.
This would be a fairly simple task if our main sites traffic came via some sort of gateway which we had full control over. However, we use Vercel to host our main nextJS site on www.sunbeam.cx and this isn't something we're looking to change right now. So we needed a way to get the blog traffic to our Ghost instance.
Ghost on a sub-domain
Because the DNS A record for www
points towards Vercel, there's no way for us have our blog listen on www.sunbeam.cx
and actually receive the traffic, so it will need to live on a sub-domain.
In the end, we chose to setup Ghost on content.sunbeam.cx/blog
. This is for two reasons:
- We wanted blog.sunbeam.cx to continue to point towards our Substack during the transition period.
- Ghost needs to know it's running on a sub-directory path, so that all asset links like css, and javascript get the correct path. Otherwise, when requesting
www.sunbeam.cx/blog
, Ghost would assume its static assets were onwww.sunbeam.cx/assets
, this should be possible to do, but in our case it would clash with some of our main site's directories. Making Ghost aware of its sub-directory path is significantly easier, and removes this problem entirely.
We used Terraform to provision the infrastructure, and wrote some Ansible to wrap the Ghost-CLI commands. This way we could automate the setup, while still following the recommended installation path by Ghost.
We also need to set Ghost's compress
to false
in the config.production.json
and restart Ghost. This is to allow us to perform some content rewrites with nginx, which we'll talk more about in a moment.
Next.js Rewrites
Next.js has a ["rewrites" feature](https://nextjs.org/docs/pages/api-reference/config/next-config-js/rewrites) which allows us to proxy calls for a given path, to external services. In our case, that let us setup www.sunbeam.cx/blog
and all sub-paths to be proxied through to content.sunbeam.cx/blog
.
Here's an example, this would go in your next.config.js
.
const nextConfig = {
async rewrites() {
return {
beforeFiles: [
{
source: '/blog/assets/:path*',
destination: 'https://www.sunbeam.cx/blog/assets/:path*'
},
{
source: '/blog/public/:path*',
destination: 'https://www.sunbeam.cx/blog/public/:path*'
},
{
source: '/blog/content/:path*',
destination: 'https://www.sunbeam.cx/blog/content/:path*'
},
{
source: '/blog/:path*',
destination: 'https://www.sunbeam.cx/blog/:path*/'
}
]
}
}
}
We need these 4 entries because Ghost forces all posts and pages to have a trailing /
in the URL. If you try to make a request without it, it will redirect to the version with the trailing /
. However, this is not the case for things like css, js, and images.
If you don't use the trailing /
, you can end up with infinite redirect loops. If you try to apply a trailing /
to the assets, public, or content paths you can break those resources URLs and cause them to respond with a 404.
Canonical links and the sitemap
The final hurdle are the canonical links and sitemap. These feed from the Ghost base URL configuration, which means all the canonical links for our blog posts come out with content.sunbeam.cx/blog
, even if they've been requested via www.sunbeam.cx/blog
. The same thing happens for the sitemap as well, which lists all links as content.sunbeam.cx/blog/...
. This would defeate the core goal of bringing our blog and it's pages into the www.sunbeam.cx
domain for SEO.
To fix this, we used a basic nginx sub_filter which looks a bit like this:
server {
# general server configuration...
location ^~ /blog {
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;
add_header X-Content-Type-Options $header_content_type_options;
proxy_buffering on;
sub_filter_once off;
sub_filter_last_modified on;
# sub_filter's are always applied to text/html by default
# we also want to capture text/xml for the sitemap.
sub_filter_types text/xml;
sub_filter 'https://www.sunbeam.cx' 'https://www.sunbeam.cx';
gzip on;
# gzip is on for html by deafult, so we want to just capture
# everything else
gzip_types text/css application/javascription application/json
gzip_min_length 256;
}
}
Let's walk through some of the additions we've made:
proxy_buffering on;
- Allows nginx to buffer the responses from the proxy server, without this itsub_filters
may not work, but it will depend on the content being served.sub_filter_once on;
- Allow the sub_filter to run multiple times per request. By default this is off, meaning you'll only get a single replacement.sub_filter_last_modified on;
- This will preserve the Last Modified header from the proxy. By default this is off, which means nginx will set the time for each request that is made which can impact caching. Because we're making this change globally across the whole site, we can continue to lean on Ghost for its Last Modified header.sub_filter_types text/xml;
- By default, sub_filter directives apply to text/html, but we also want it to run fortext/xml
so the sitemap receives the same treatment.sub_filter '
https://www.sunbeam.cx
' '
https://www.sunbeam.cx
';
- This is the actual string replacement we want to make.gzip
directives - We then have a variety of gzip settings, this is because Ghost's compression has been disabled to allow nginx's filters to run. We then want to compress the response before it is transmitted across the internet.
End result
With that, we have our blog running on www.sunbeam.cx/blog
with our canonical links, and sitemap also pointing to our main www sub-domain!