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

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 and frontend 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 of http for Ghost, or ghost may not recognize the url as it will be different with the url you will set in the config.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 tunnel
  • ingress: 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.

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!