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.