How to Set Up Ghost in Your Homelab
I can do it, so you can do it too!
If you've ever wanted to run your own blog but didn't want to depend entirely on a third-party blogging platform, this is for you.
In this post, I'll walk through how I set up Ghost in my homelab using Docker Compose, MySQL, and Cloudflare.
The goal is simple:
Your blog. Your server. Your data.
And don't worry—you don't need an enterprise-grade server to do this. A small homelab machine is more than enough to get started.
What We're Building
Here's the architecture:
Internet
│
▼
┌──────────────┐
│ Cloudflare │
└──────┬───────┘
│
Cloudflare Tunnel
│
▼
┌───────────────────┐
│ Docker Network │
│ CLOUDFLARE-PUBLIC │
└─────────┬─────────┘
│
▼
┌───────────┐
│ Ghost │
│ :2368 │
└─────┬─────┘
│
▼
┌───────────┐
│ MySQL │
└───────────┘
Ghost itself is not exposed directly to the internet.
Cloudflare handles the public-facing connection, while Ghost communicates internally with MySQL through a private Docker network.
Prerequisites
Before starting, you'll need:
- A Linux server or homelab machine
- Docker
- Docker Compose
- A domain
- A Cloudflare account
- Cloudflare Tunnel
- Basic knowledge of the Linux command line
I'm using a small homelab server, but the same concept works on a VPS or another Linux machine.
1. Create the Project
I like keeping each Docker application self-contained.
Create a directory for Ghost:
mkdir -p ghost/{content,mysql}
cd ghost
This gives us:
ghost/
├── docker-compose.yml
├── .env
├── content/
└── mysql/
There are no named Docker volumes here.
All persistent data lives inside the project directory.
That makes backups and migrations much easier.
2. Create the Environment File
Create .env:
GHOST_URL=https://blog.example.com
MYSQL_DATABASE=ghost
MYSQL_DATABASE_USER=ghost
MYSQL_DATABASE_PASSWORD=CHANGE_THIS
MYSQL_ROOT_PASSWORD=CHANGE_THIS_TOO
Use strong random passwords.
For example:
openssl rand -base64 32
Don't commit .env to Git.
Add it to .gitignore:
.env
And protect the file:
chmod 600 .env
3. Docker Compose
Now create docker-compose.yml:
services:
ghost:
image: ghost:6-alpine
container_name: ghost
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
url: ${GHOST_URL}
database__client: mysql
database__connection__host: mysql
database__connection__user: ${MYSQL_DATABASE_USER}
database__connection__password: ${MYSQL_DATABASE_PASSWORD}
database__connection__database: ${MYSQL_DATABASE}
depends_on:
mysql:
condition: service_healthy
expose:
- "2368"
volumes:
- ./content:/var/lib/ghost/content
networks:
- ghost-backend
- CLOUDFLARE-PUBLIC
- MONITORING
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
test:
- CMD-SHELL
- wget --spider -q http://127.0.0.1:2368/ || exit 1
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
mysql:
image: mysql:8.4
container_name: ghost-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_DATABASE_USER}
MYSQL_PASSWORD: ${MYSQL_DATABASE_PASSWORD}
volumes:
- ./mysql:/var/lib/mysql
networks:
- ghost-backend
security_opt:
- no-new-privileges:true
healthcheck:
test:
- CMD-SHELL
- mysqladmin ping -h 127.0.0.1 -u root -p"$${MYSQL_ROOT_PASSWORD}" --silent
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
ghost-backend:
name: ghost-backend
internal: true
CLOUDFLARE-PUBLIC:
external: true
MONITORING:
external: true
There are a few important things happening here.
Ghost
Ghost listens internally on port 2368.
Notice that we're using:
expose:
- "2368"
instead of:
ports:
- "2368:2368"
That's intentional.
We don't want Ghost directly exposed on the host.
MySQL
MySQL is only connected to:
ghost-backend
This network is marked as:
internal: true
So MySQL isn't sitting on the public-facing Docker network.
Cloudflare
Ghost joins:
CLOUDFLARE-PUBLIC
This allows the Cloudflare Tunnel container to reach:
http://ghost:2368
using Docker's internal DNS.
Monitoring
Ghost also joins:
MONITORING
which means a monitoring service such as Uptime Kuma can check the Ghost container directly.
4. Start Ghost
Make sure the external Docker networks already exist:
docker network ls
Then start everything:
docker compose up -d
Check the status:
docker compose ps
You should see both containers running:
ghost
ghost-mysql
If something goes wrong, check the logs:
docker compose logs --tail=100 ghost
5. Configure Cloudflare Tunnel
Now comes the fun part.
Your Cloudflare Tunnel should point the public hostname:
blog.example.com
to:
http://ghost:2368
The important part is that the Cloudflare container and Ghost container share the same Docker network:
CLOUDFLARE-PUBLIC
So Cloudflare doesn't need to know the homelab's IP address.
It can simply resolve:
ghost
through Docker's internal DNS.
6. Open Ghost for the First Time
Once the tunnel is working, open:
https://blog.example.com/ghost/
Ghost should show the initial setup screen.
Create your administrator account.
There is no default:
admin / admin
password.
You create the first admin account yourself.
After setup, /ghost/ becomes your administration panel.
7. Why I Don't Expose Port 2368
You might be tempted to do this:
ports:
- "2368:2368"
and access:
http://192.168.x.x:2368
It works.
But for a public blog, I prefer:
Internet
↓
Cloudflare
↓
Tunnel
↓
Docker
↓
Ghost
instead of:
Internet
↓
Host:2368
↓
Ghost
The less you expose directly, the better.
8. Your Data Is Still on Your Machine
One of the reasons I prefer this setup is the directory structure.
Ghost content:
./content/
MySQL data:
./mysql/
So the project contains everything needed for the application.
Backups become straightforward.
For example:
rsync -a ghost/ /path/to/backup/ghost/
Of course, for a real production setup, I'd still recommend proper backup rotation and testing that the backups can actually be restored.
A backup that has never been restored is just a very confident copy of your data.
9. Updating Ghost
When you want to update the Ghost image:
docker compose pull
docker compose up -d
If you change the Compose configuration:
docker compose up -d --force-recreate
And if you changed the Docker build configuration:
docker compose up -d --build --force-recreate
Final Thoughts
This setup isn't complicated.
That's probably the most important thing I learned from building my homelab.
You don't need a massive server rack.
You don't need to be a Linux wizard.
You don't need to understand every single Docker feature before starting.
Start small.
Break things.
Read the logs.
Fix them.
Then break them again.
That's how you learn.
And that's exactly why I'm building this blog.
I can do it, so you can do it too.
Welcome to my homelab.