Introducing My Blog Server Stack: Ghost + Nginx + MySQL with Cloudflare Tunnel

In this post, I’ll walk you through the exact stack I used to host this blog server—all self-contained using Docker Compose and securely exposed using Cloudflare Tunnel.
Project Structure
I created a single directory to manage everything related to my blog server.
/ghost-blog-server/
├── ghost-docker-compose.yml
├── config.production.json
├── .env
└── nginx/
├── conf.d/
│ └── ghost.conf
└── log/
└── access.log
└── error.log
└── ghost-access.log
└── ghsot-error.log
The Docker Compose Stack
Here's the structure of my ghost-docker-compose.yml
service:
nginx:
container_name: nginx01
image: nginx:latest
restart: unless-stopped
ports:
- "80:80"
env_file:
- .env
volumes:
- ./nginx/conf.d/:/etc/nginx/conf.d/:ro
- ./nginx/log/:/var/log/nginx/
networks:
- frontend
ghost:
container_name: ghost01
image: ghost:latest
restart: unless-stopped
env_file:
- .env
environment:
database__client: mysql
database__connection__host: db
database__connection__database: ghost
volumes:
- ghost_data:/var/lib/ghost/content/
- ./config.production.json:/var/lib/ghost/config.production.json
depends_on:
- db
networks:
frontend:
aliases:
- ghost.local
backend:
db:
container_name: mysql01
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${ROOT_PASSWORD}
MYSQL_DATABASE: ghost
MYSQL_USER: ghost-user
MYSQL_PASSWORD: ${USER_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
networks:
- backend
networks:
frontend:
driver: bridge
ipam:
config:
- subnet: 172.16.198.0/24
backend:
driver: bridge
internal: true
ipam:
config:
- subnet: 172.16.199.0/24
Basically, after you run the command sudo docker compose up -d
in the directory, your blog server is online!
However, you may wonder what they mean.
Let me break it down one by one below.
Ghost Service
First is the main blog application.
ghost:
container_name: ghost01
image: ghost:latest
restart: unless-stopped
env_file:
- .env
environment:
database__client: mysql
database__connection__host: db
database__connection__database: ghost
volumes:
- ghost_data:/var/lib/ghost/content/
- ./config.production.json:/var/lib/ghost/config.production.json
depends_on:
- db
networks:
frontend:
aliases:
- ghost.local
backend:
For the volumes, you do not need to specify config.production.json
as it is recommended to declare the variables in environment
or .env
file. However, for newsletter settings, it is easier for me to tweak the settings in the json file.
MySQL Service
db:
container_name: mysql01
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${ROOT_PASSWORD}
MYSQL_DATABASE: ghost
MYSQL_USER: ghost-user
MYSQL_PASSWORD: ${USER_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
networks:
- backend
Environment File (.env file)
Keep sensitive credentials out of your Compose file:
ROOT_PASSWORD=supersecret
USER_PASSWORD=ghostpass123
Network Design
networks:
frontend:
driver: bridge
ipam:
config:
- subnet: 172.16.198.0/24
backend:
driver: bridge
internal: true
ipam:
config:
- subnet: 172.16.199.0/24
The Backend network has a parameter of internal: true
, which means the traffic from the apps that has backend
set will not leave the container environments.
- MySQL is on the internal-only
backend
network. - Ghost is on both networks:
backend
to talk to MySQL andfrontend
to be reachable by Nginx, and send mails. - Nginx is on the
frontend
network, to communicate with Ghost.
Nginx Reverse Proxy
nginx:
container_name: nginx01
image: nginx:latest
restart: unless-stopped
ports:
- "80:80"
env_file:
- .env
volumes:
- ./nginx/conf.d/:/etc/nginx/conf.d/:ro
- ./nginx/log/:/var/log/nginx/
networks:
- frontend
You can set the ports to any other ports if feel sketchy about using 80 for external connection in the machine, like "???:80". You just have to remember to route the public traffic to this port when setting up Cloudflare tunnel.
Nginx Config (ghost.conf)
server {
listen 80;
server_name localhost;
server_tokens off;
proxy_headers_hash_max_size 1024;
proxy_headers_hash_bucket_size 128;
error_log /var/log/nginx/ghost-error.log;
access_log /var/log/nginx/ghost-access.log;
client_max_body_size 20M;
location / {
proxy_pass http://ghost.local:2368;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x>
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 20M;
}
}
Explanation for some of the config:
- client_max_body_size: increase the size to allow larger image upload for post.
- proxy_set_header X-Forwarded-Proto https: pass the header as
https
instead ofhttp
for Ghost, or ghost may not recognize the url as it will be different with the url you will set in theconfig.production.json
file.
Cloudflare Tunnel Routing
Cloudflared forwards public traffic to localhost:80
, or the port you specified earlier, where Nginx listens and proxies to Ghost.
Sample cloudflared/config.yml
tunnel: my-blog-tunnel
credentials-file: /etc/cloudflared/my-blog-tunnel.json
ingress:
- hostname: blog.example.dev
service: http://localhost:80
- service: http_status:404
tunnel
: Declare your tunnel service name.credentials-file
: Where you store you secret key for cloudflare tunnelingress
: The domain name that you want to redirect.- For the example above, whoever accessed
blog.example.dev
will be redirect to port 80 in localhost, which is my nginx, but for any other access, like root domain, or other subdomain, the server will just return a 404 status for the client.
- For the example above, whoever accessed
Overall Architecture
Although I run my blog server stack in docker containers using docker compose
, I run my Cloudflare tunnel service natively on my machine, for reliability reason. I need have direct access to Cloudflare tunnel for quick config modifications and monitoring (maybe in the future), as it is the main gate of my machine.
With that in mind, Below is my architecture digram:

Conclusion
With just Docker Compose and Cloudflare Tunnel, I got a production-ready blog with Ghost, Nginx, and MySQL - fully contained, secured, and performant. Next, I'll talk about enabling email delivery with Mailgun to support Ghost newsletters.
But until then, see you in the next post! Have a nice day!