My Ghost/Cloudflare Setup
Want to self-host a blog using Ghost, run it on hardware in your basement, and hide it behind Cloudflare? Here is how I did it.
Having recently moved my setup to Ghost, I figured it would be a good time to document my process.
Rather than deploy my new Ghost blog to a VPS, I wanted to run Ghost on my infrastructure at home. While this is certainly #NotABestPractice for a mission-critical site, it's fine for Qoda: it saves me a few bucks and uses the infrastructure I already have. While I could mess around with firewall rules and point my domain at my ISP-allocated IP address, that introduces a bunch of problems:
- My ISP IP is dynamic. While it doesn't change often, it does change. I'd need to add some processes to keep my DNS up-to-date. Meh.
- I'm running more than one website on my home servers, and I have one public IP. This would mean I'd have to deploy something like Caddy or Nginx Proxy Manager to direct traffic to the correct service. More things to manage...
- It exposes my home IP to the Internet. Not that it's a big secret, but it certainly does make it easy for some neer do well on the Internet to DoS me off the Internet.
My preferred tool for web services like this is Cloudflare's awesome (and free!) Cloudflare Tunnels. Cloudflare's Tunnels allow my service to connect to Cloudflare, and then Cloudflare will take inbound traffic and push it through that tunnel to my underlying service. It looks like this.
This is awesome because:
- It's magically easy and doesn't require me to poke holes in firewalls.
- It doesn't require me to expose my public IP.
- It is trivial to move my blog ANYWHERE and have it "just work."
Of course, there are a few downsides.
- Some folks dislike Cloudflare because they've refused to take down sites containing wrong/misleading/harmful content.
- Cloudflare owns the TLS certificate and is essentially a man-in-the-middle by design. They probably aren't evil, but... they sure could be if they wanted to!
- Sites behind Cloudflare are often annoying to access via. Tor, where users are subject to a never-ending barrage of CAPTCHA challenges. "If I have to identify one more *#&@$ing sidewalk, I swear..."
For me and this blog, however, the upsides outweigh the downsides.
My Containers
First, I'm running Ghost and associated infrastructure within Docker using Docker Compose. My pattern for things like this is to put my service in a single folder (let's say, for this blog, I'm using /docker/qoda
) containing:
- The
/docker/qoda/docker-compose.yml
file defines all the containers and the public/non-secret/static content. - A
/docker/qoda/.env
file contains the variable things (URL) and secrets (Tunnel Tokens). The nutshell is that I want to be able to spin up additional blogs by copying mydocker-compose.yml
file and copying/tweaking.env
. - Controversially... All of the data volumes from Docker bind-mounted to subdirectories in
/docker/qoda
(i.e../data/mysql
,./data/ghost
). I realize that Docker Volumes offer many advantages, such as better performance and better isolation. Still, it also makes it a bit more difficult to move a service between hosts and deal with the backup/restore of a single service.
The setup involves four containers:
- Ghost
- MySQL (the underlying database used by Ghost): My servers have lots of memory, so I prefer to keep things self-contained and use a dedicated DB server for each service rather than trying to share a database server and add complexity when I want to move a service to a different host or restore a single service.
- Caddy is a great web server that can do many awesome things. Here, I'm ignoring all of the cool stuff it can do, and I'm using it to host large static content that I don't want to stuff into Ghost.
- Cloudflared is the helper that connects to Cloudflare and allows inbound requests to my site via. Cloudflare to be pushed through a tunnel to the Ghost and Caddy containers.
The following docker-compose.yml
snippet configures Ghost and MySQL.
- Most of the Ghost configuration documentation provides blocks of JSON for the Ghost configuration file. Rather than split my configuration between Docker and a separate Ghost configuration, I'd much rather put my configuration into the
docker-compose.yml
file, and fortunately, Ghost allows this. So, a configuration sample that shows something like"server": {"port": 2368}
can be set with an environment variable likeserver__port: 2368
- I use Mailgun for mail delivery, and I've hardcoded that into my
docker-compose.yml
file. While you can use other SMTP services for transactional emails (login, etc.), Ghost requires Mailgun for bulk mailings (newsletters), so it seems reasonable to hardcode it here. Besides, Mailgun is what I use anyway. - Note that the Ghost content in
/var/lib/ghost/content
is bind-mounted to./data/ghost
on the local machine, and similarly for MySQL's data. This means that all of the state that I care about is sitting in./data
right alongside my.env
anddocker-compose.yml
file.
ghost:
image: ghost:5-alpine
restart: always
depends_on:
- db
environment:
# see https://ghost.org/docs/config/#configuration-options
database__client: mysql
database__connection__host: db
database__connection__user: root
database__connection__password: ${MYSQL_PASSWORD}
database__connection__database: ghost
mail__transport: SMTP
mail__options__service: Mailgun
mail__from: ${MAILGUN_USERNAME}
mail__options__auth__user: ${MAILGUN_USERNAME}
mail__options__auth__pass: ${MAILGUN_PASSWORD}
url: ${URL}
server__port: 80
volumes:
- ./data/ghost:/var/lib/ghost/content
db:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- ./data/db:/var/lib/mysql
I also deploy an instance of the Caddy web server to host large static files (from ./static
) that don't belong in Ghost.
caddy:
image: caddy:alpine
volumes:
- ./static:/usr/share/caddy/static
restart: always
And finally, I deploy an instance of the cloudflared container to set up my Cloudflare Tunnel. The only configuration this needs on my end is the TUNNEL_TOKEN
which will be defined in the .env
file. There are additional steps below for the configuration on the Cloudflare side.
tunnel:
image: cloudflare/cloudflared
command: tunnel --no-autoupdate run
restart: always
environment:
TUNNEL_TOKEN: ${TUNNEL_TOKEN}
depends_on:
- ghost
- caddy
Putting it all together, my full docker-compose.yml
file looks like this:
services:
tunnel:
image: cloudflare/cloudflared
command: tunnel --no-autoupdate run
restart: always
environment:
TUNNEL_TOKEN: ${TUNNEL_TOKEN}
depends_on:
- ghost
- caddy
caddy:
image: caddy:alpine
volumes:
- ./static:/usr/share/caddy/static
restart: always
ghost:
image: ghost:5-alpine
restart: always
depends_on:
- db
environment:
# see https://ghost.org/docs/config/#configuration-options
database__client: mysql
database__connection__host: db
database__connection__user: root
database__connection__password: ${MYSQL_PASSWORD}
database__connection__database: ghost
mail__transport: SMTP
mail__options__service: Mailgun
mail__from: ${MAILGUN_USERNAME}
mail__options__auth__user: ${MAILGUN_USERNAME}
mail__options__auth__pass: ${MAILGUN_PASSWORD}
url: ${URL}
server__port: 80
volumes:
- ./data/ghost:/var/lib/ghost/content
db:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- ./data/db:/var/lib/mysql
As mentioned, the .env
file contains secrets and variable parameters for my setup. Make sure to set a nice, secure MYSQL_PASSWORD
in this file.
openssl rand -hex 20
So, at this point, I have a folder on my machine (let's say /docker/qoda
) with two files docker-compose.yml
and .env
.
Before starting this service, we must configure our domain, and some things on the Cloudflare side.
Domain Setup
The first step is to set up my domain to work with Cloudflare tunnels. Cloudflare's free account is sufficient for everything we're doing in this article.
- The easiest option is to buy your domain on Cloudflare and use Cloudflare to host your DNS. Their domains are fairly cheap, but their list of supporter TLDs is somewhat lacking.
If you take this option, you can skip the rest of this section. - The next easiest option, and the path I've taken, is to buy your domain elsewhere and configure it to use Cloudflare for your nameservers.
- I think using Cloudflare Tunnels when your DNS is hosted elsewhere is also possible, but I've not tried this.
Since my domain qoda.ca
was purchased elsewhere (Porkbun), I've opted to update the authoritative nameservers to those provided by Cloudflare.
First, on Cloudflare, navigate to Websites and select "+ Add a domain". Enter your domain name. I've selected "Manually enter DNS records" because I didn't want to pull across any of the automatically created placeholders from my registrar.
After clicking "Continue," you'll be asked to select a plan. The free option is quite generous and probably works fine.
Next, you'll be asked to set up initial DNS records. If you have pre-existing services, such as mail, configured for this domain, you'll want to make sure those are there before continuing.
In my case, I'll "Continue to activation." Next, Cloudflare will ask you to update the nameservers at your registrar (where you bought the domain from). The important part is the list of nameservers you should be using.
You then need to log in to your registrar and update the nameservers for your domain.
It may take a while for the changes to become active. You'll receive an email from Cloudflare when things are configured correctly.
The Cloudflare Tunnel
Navigate to "Zero Trust" in the menu-bar. Next, select "Networks > Tunnels", and select "+ Create a tunnel".
- Tunnel Type: "Cloudflared"
- Tunnel Name: something like "Qoda Blog"
Click the "copy" button next to the "If you already have cloudflared installed on your machine:" box. It'll give you something like this:
sudo cloudflared service install eyJhIjoiMGQwNjMxMTgzN2M1ZWJlYzVkOWMxMWNkOWY0MWZmMGMiLCJ0IjoiY2RjZjNhM2ItMmZjNy00ODc1LTk5MWEtZjJmNzkwYThlNTY3IiwicyI6Ik1USTFNMlExTmpRdFl6QTNaUzAwTkRBeUxUazJaVEl0WWpnME9ESTBOMlU0WWpneiJ9
The TUNNEL_TOKEN
you need in your .env
file is the part after "install". So, with the above, I'd update my .env
file like so:
# Token used to authentication with CloudFlare Tunnel
TUNNEL_TOKEN=eyJhIjoiMGQwNjMxMTgzN2M1ZWJlYzVkOWMxMWNkOWY0MWZmMGMiLCJ0IjoiY2RjZjNhM2ItMmZjNy00ODc1LTk5MWEtZjJmNzkwYThlNTY3IiwicyI6Ik1USTFNMlExTmpRdFl6QTNaUzAwTkRBeUxUazJaVEl0WWpnME9ESTBOMlU0WWpneiJ9
# Password used for MySQL root account
MYSQL_PASSWORD=...YOU PROBABLY SHOULD HAVE A PASSWORD HERE BY NOW...
# Base URL for the Blog
URL=https://www.qoda.ca
# From credentials use for transactional emails through Mailgun
# Note: that bulk emails need a separate configuration with Mailgun API Key
[email protected]
MAILGUN_PASSWORD=....MAILGUN PASSWORD FOR ABOVE SMTP CREDENTIAL....
Click the "Next" button and set up the first public hostname entry.
Click "Save tunnel". Go back into your tunnel and make additional public hostname entries for code.ca
and www.qoda.ca/static
.
Rule # | Subdomain | Domain | Path | Type | URL |
---|---|---|---|---|---|
1 | www | qoda.ca | static | http | caddy |
2 | www | qoda.ca | http | ghost | |
3 | qoda.ca | http | ghost |
static
route needs to be first!The result should look like this:
At this point, you should be able to start the containers up:
cd /docker/qoda
docker compose up -d
It'll take a few minutes to start the first time, but eventually, you should be able to access your site (in my case, https://www.qoda.ca
) and the ghost admin/setup page (https://www.qoda.ca/ghost/
).
Success!
Mail Delivery
Almost everything is up and running at this point except for mail delivery. As mentioned, I'd recommend using Mailgun since it is required if you ever decide to use the newsletter functionality. Mailgun's "Flex" plan is free if your monthly volume is less than 1000 emails.
Pick a subdomain you'd like Mailgun to use (I used the subdomain mg.qoda.ca
to keep it separate from my normal email service) and add that to Mailgun, following their instructions. You'll need to add a bunch of DNS records under Cloudflare. Mailgun will let you know when you've configured things correctly.
For transactional emails (logins), you'll need to configure an SMTP user for your domain. Navigate to "Send > Sending > Domains" and select your domain.
Select "SMTP credentials" and click "Add new SMTP user". Enter a name like noreply
and select "Create". You'll see a pop-up with an option to copy the password for the new SMTP user.
This password (and the address you selected) needs to be updated in your .env
file.
Once that change is made, docker compose up -d
will restart the appropriate services.
At this point, normal email should work.
If you'd like to use Ghosts' awesome newsletter functionality, create an API key by clicking "Sending API Keys" and "Add sending key. "
This key must be added to your "Mailgun Settings" in Ghost through the Admin UI.
And at this point, the bulk emailing of newsletters should work!
Finishing Touches
Here are a few additional adjustments I've made to improve things.
HTTPS and Redirect from Apex
My blog is the main thing on qoda.ca
, and I want people to be able to access it using either www.qoda.ca
and qoda.ca
.
Moreover, I want to redirect traffic from the apex (qoda.ca
) to the more specific www.qoda.ca
domain, rather than just allowing both addresses to work and creating confusion.
Additionally, because it's no longer the 90s, we will force all connections through TLS as God intended. (Cloudflare handles all the certificate stuff for us ~ for better and for worse)
Cloudflare has a feature called "Redirect Rules," which supports this. The free plan only supports ten rules, but I only need two.
First, add a rule that redirects qoda.ca
to https://www.qoda.ca
.
- Rule Name: "Redirect to WWW"
- If incoming requests match... "Wildcard pattern"
- Request URL:
https://qoda.ca/*
- Target URL:
https://www.qoda.ca/${1}
- Preserve query string: Yup!
Second, add a rule that redirects http://*
to https://www.qoda.ca
.
- Rule Name: "Redirect from HTTP to HTTPS"
- If incoming requests match... "Wildcard pattern"
- Request URL:
https://*/*
- Target URL:
https://www.qoda.ca/${2}
- Preserve query string: Yup!
And that's it, now traffic to http://qoda.ca/?
, https://qoda.ca/?
and http://www.qoda.ca/?
will all correctly redirect to https://www.qoda.ca/?
.
Syntax Highlighting & Mermaid
Since my blog contains technical content, I wanted to add support for code syntax highlighting (using Prism) and diagrams (using Mermaid).
Rather than modifying my templates to do this (and making upgrades more difficult), you can use Ghosts' "Code Injection" feature to add the necessary JavaScript on each page.
I've injected the following into my Site Header to turn on Prism-based syntax highlighting. I'm using the "tomorrow" theme, but you can see a huge list of available syntax highlighting themes and update the last line to reflect that.
Now, the blog automatically highlights code in code fences that are tagged with a supported language such as this:
```python
import os
print(os.name)
```
import os
print(os.name)
Next, I'd like to be able to use Mermaid to embed diagrams in my posts. This can be accomplished by injecting the following into my Site Header.
This will allow me to add HTML sections in my blog posts like:
<pre class="mermaid">
flowchart LR
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[fa:fa-car Car]
</pre>
And see...
flowchart LR A[Christmas] -->|Get money| B(Go shopping) B --> C{Let me think} C -->|One| D[Laptop] C -->|Two| E[iPhone] C -->|Three| F[fa:fa-car Car]
Finally, the Site Header is also a convenient place to tweak formatting. I'm always hesitant to mess with the templates because that makes upgrades more difficult. Here, I'm rounding the corners of my code blocks and preventing the corner rounding of my site logo.
<style>
pre {
font-size: 0.70em !important;
border-radius: 0.5em;
}
.gh-about-icon {
border-radius: 0 !important;
}
</style>
Year in Post URLs
My previous blog erraticbits.ca
had included the year in the post URL. (i.e. https://www.erraticbits.ca/post/2022/ackee/
). I've moved over my important content from the old blog and wanted to do a simple redirection from the old blog to the new one, but for that to work, I need the relative URLs to match (old > new).
Ghost does allow you to set the Post URL and Publish date, which gets us most of the way there, but the default URLs still don't include the year of publication.
Fortunately, that's a fairly easily fixable problem.
From the Admin UI, navigate to "Advanced > Labs."
- Download your current routes
- Edit that file and update
permalink: /{slug}/
topermalink: /{year}/{slug}/
- Upload your updated routes
My updated default routes.yaml
file looks like this:
routes:
collections:
/:
permalink: /{year}/{slug}/
template: index
taxonomies:
tag: /tag/{slug}/
author: /author/{slug}/
Table of Contents
Many of my posts are very long and tutorially, and I wanted a Table of Contents to help readers navigate the post. Fortunately, Ghost has a walkthrough on using a library called Tocbot to your theme, and while the instructions are written for Casper, they seem to work fine for the theme I'm using (Source).
Secondary Tags
I also wanted to use more than one tag on my posts and, critically, actually show them. The Source theme I'm using only shows the primary (first) tag.
Fortunately, Matthew McGarity did the legwork here:
What if...
I want to move to a different server?
Maybe your home internet isn't able to keep up with your massively popular blog, and you want to move to a VPS? Easy!
- Stop your services on your current host:
docker compose down
- Provision a VPS (I love Digital Ocean)
- Install Docker on the new host:
curl
https://get.docker.com/
| sudo sh
- Move your whole folder to the new VPS
(containingdocker-compose.yml
,.env
,static
anddata
) - From the new host, start it all up
docker compose up -d
- Voila!
My site is being attacked?
Fortunately, being behind Cloudflare has some advantages. Cloudflare will automatically mitigate many attacks before they hit your server (whose IP is hidden from "The Bad Guys"). Cloudflare also has a neat "Under Attack Mode," which allows you to challenge all traffic to your site to thwart automated attacks.
I want to stop using Cloudflare?
That's no problem. You could drop the Cloudflare container, adjust the Caddy container to expose 80/443 directly (so Caddy becomes the entry point), and add a Caddyfile
to proxy traffic to Ghost.
Your docker-compose.yml
becomes something like:
services:
caddy:
image: caddy:alpine
volumes:
- ./data/caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./static:/usr/share/caddy/static
restart: always
depends_on:
- ghost
ports:
- 80:80
- 443:443
ghost:
image: ghost:5-alpine
restart: always
depends_on:
- db
environment:
# see https://ghost.org/docs/config/#configuration-options
database__client: mysql
database__connection__host: db
database__connection__user: root
database__connection__password: ${MYSQL_PASSWORD}
database__connection__database: ghost
mail__transport: SMTP
mail__options__service: Mailgun
mail__from: ${MAILGUN_USERNAME}
mail__options__auth__user: ${MAILGUN_USERNAME}
mail__options__auth__pass: ${MAILGUN_PASSWORD}
url: ${URL}
server__port: 80
volumes:
- ./data/ghost:/var/lib/ghost/content
db:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- ./data/db:/var/lib/mysql
And then you'll need a new Caddyfile
(/docker/qoda/Caddyfile
for this example) that looks like this:
qoda.ca {
redir https://www.qoda.ca{uri}
}
www.qoda.ca {
# Serve static files from /static in the document root
handle /static/* {
root * /usr/share/caddy/static
file_server
}
# Proxy all other traffic to the Ghost server
handle {
reverse_proxy ghost:80
}
}
You'd then need to run this on a publicly exposed machine (like a VPS) and point your DNS (A and AAAA) for qoda.ca
and www.qoda.ca
to that VPS.