Lessons learned from self-hosting Ghost (and this site)
This site is self-hosted using two Docker containers, which run on a server located in the room next to my home office. Right now. As I'm typing this, the primary container providing Ghost (the blog platform I use) consumes 214 MB of memory, and the MySQL database consumes 538 MB of RAM. That's less than 1 GB of RAM in total.
The backstory
The first blog article, which I still have online, was published in 2014. It's this one, about a standing desk in my old house. Feels like yesterday. Back then, it was mostly WordPress for me - I tried numerous themes, generated manual static copies, and also experimented with all the static site generators that were popular at the time. Finally, a few years ago, I made the leap to Ghost.
Writing on Ghost is a joy. It's frictionless, fluid, and supports basic markdown syntax, like code
and bold. You can purchase the service or host it yourself. I opted for the full-service option, so someone else can manage the whole thing for me. It's excellent, and I cannot recommend them enough. There's only one but: if you want even the tiniest change in your theme files, you have to pay quite a bit more per month - in total about $300 per year, which is a bit much for a static website (with loads of visitors, though).
During my summer vacation of 2025, I started thinking - what would it take to self-host some of these services? I have the hardware, I have the skills, and I finally had the time, too.
And here we are - self-hosted Ghost, running in a few Docker containers on Ubuntu Linux.
The setup
I opted to run Ghost in Docker on Ubuntu. I'm no longer an expert on Linux, but I've spent many sleepless nights compiling Slackware kernels and troubleshooting driver issues back in the 1990s, so I figured I could manage.
On Ubuntu, I run Docker—just the basic installation, per instructions here.
Then, I needed to craft a Docker Compose file so that I could easily spin up Ghost after reboot, for example. Here's the file I'm currently using:
version: '3'
services:
ghost-server:
image: ghost:5
cap_add:
- CAP_SYS_NICE
security_opt:
- seccomp:unconfined
restart: always
ports:
- 2368:2368
depends_on:
- ghost-db
environment:
url: https://jussiroine.com
database__client: mysql
database__connection__host: ghost-db
database__connection__user: root
database__connection__password: <password>
database__connection__database: ghost
mail__transport: SMTP
mail__options__service: Mailgun
mail__options__host: smtp.mailgun.org
mail__options__port: 465
mail__options__secure: true
mail__options__auth__user: [email protected]
mail__options__auth__pass: <password>
mail__from: Ghost <[email protected]>
volumes:
- /home/ghostsvc/ghost/content:/var/lib/ghost/content
ghost-db:
image: mysql:8
security_opt:
- seccomp:unconfined
restart: always
command: --mysql-native-password=ON
environment:
MYSQL_ROOT_PASSWORD: <password>
volumes:
- /home/ghostsvc/ghost/mysql:/var/lib/mysql
What's happening there is that it spins up two containers: ghost-server
and ghost-db
, which is a MySQL instance. These are exposed on port 2368; thus, accessing http://dockerhost:2368/ should open Ghost's default blog post for me.
Once the file is saved, run docker compose up -d
to spin it up.
And while this works, I wanted to expose the service as https://jussiroine.com - not http://dockerhost:2368
For this, I briefly considered different options, but for security, ease of use, and flexibility, I ultimately opted for Cloudflare Tunnel.
Setting up Cloudflare Tunnels for Ghost
Cloudflare tunnels allow you to expose (internal) endpoints through a proxy. I've written previously about similar tunnels with Microsoft's Dev Tunnels, and Entra ID's Application Proxy is a similar one, albeit a bit more archaic.
Cloudflare Tunnels can be exposed with a simple daemon. Thus, I'm feeding the daemon http://dockerhost:2368/,
Ghost thinks it's exposing https://jussiroine.com
, and then I'm telling the other side of Cloudflare Tunnels, 'Hey, you're actually advertising yourself as https://jussiroine.com
.'
This is the overview setup for Cloudflare Tunnels:

Under ghost-prod
tunnel (as you can have many), I'm exposing the endpoint, and you can have multiple endpoints in a tunnel:

And lastly, the DNS configuration to make the magic happen:

Checking the DNS entries for jussiroine.com
:

You can see that the public, routable IP is pointing to Cloudflare.
Migrating content
Now, all that is left to do is to migrate my content. All written content is easily exportable from Ghost as a single .json file. Importing that takes about a minute, and that's it! Except, you won't get any images. Those you have to fetch from the disk, and thankfully, the hosted Ghost service allows you to do that through a service request.
I'm managing themes, images, and other files remotely via Visual Studio Code. Using SSH to connect to my Ubuntu host, I can manipulate stuff remotely without needing to edit each file with nano
or vi
.

And this gives me added bonus of using GitHub Copilot!

Files are saved remotely, and I can even open a terminal window to restart the containers as needed.

In closing
All things considered, this has been a problem-free deployment, so far. Self-hosting something always requires you to worry about a few extra things, but I was happy to get to spend more time rebuilding my home network to support this need.
Getting Ghost up and running was easy, and Cloudflare Tunnels does most of the actual work here. I'd be keen to go back to hosted Ghost if the pricing reflected the needs I've. Currently, I'm running the containers on an old (Intel i7-12900K, custom-built server), and it barely needs 4 GB in total from the host - Ubuntu VM, Docker containers, and Cloudflare. As all content is static, outbound traffic is negligible, and I won't even need a static IP address for this.