> ## Content Index
> Fetch the complete content index at: https://kk-lab.uk/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Set Up Ghost in Your Homelab
- URL: https://kk-lab.uk/how-to-set-up-ghost-in-your-homelab/
- Published: 2026-09-09T15:26:31.000Z
- Updated: 2026-09-10T02:16:06.000Z
- Author: Ryan Arief
- Tags: 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:

```text
                    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:

```bash
mkdir -p ghost/{content,mysql}
cd ghost

```

This gives us:

```text
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`:

```dotenv
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:

```bash
openssl rand -base64 32

```

Don't commit `.env` to Git.

Add it to `.gitignore`:

```text
.env

```

And protect the file:

```bash
chmod 600 .env

```

---

# 3\. Docker Compose

Now create `docker-compose.yml`:

```yaml
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:

```yaml
expose:
  - "2368"

```

instead of:

```yaml
ports:
  - "2368:2368"

```

That's intentional.

We don't want Ghost directly exposed on the host.

### MySQL

MySQL is only connected to:

```text
ghost-backend

```

This network is marked as:

```yaml
internal: true

```

So MySQL isn't sitting on the public-facing Docker network.

### Cloudflare

Ghost joins:

```text
CLOUDFLARE-PUBLIC

```

This allows the Cloudflare Tunnel container to reach:

```text
http://ghost:2368

```

using Docker's internal DNS.

### Monitoring

Ghost also joins:

```text
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:

```bash
docker network ls

```

Then start everything:

```bash
docker compose up -d

```

Check the status:

```bash
docker compose ps

```

You should see both containers running:

```text
ghost
ghost-mysql

```

If something goes wrong, check the logs:

```bash
docker compose logs --tail=100 ghost

```

---

# 5\. Configure Cloudflare Tunnel

Now comes the fun part.

Your Cloudflare Tunnel should point the public hostname:

```text
blog.example.com

```

to:

```text
http://ghost:2368

```

The important part is that the Cloudflare container and Ghost container share the same Docker network:

```text
CLOUDFLARE-PUBLIC

```

So Cloudflare doesn't need to know the homelab's IP address.

It can simply resolve:

```text
ghost

```

through Docker's internal DNS.

---

# 6\. Open Ghost for the First Time

Once the tunnel is working, open:

```text
https://blog.example.com/ghost/

```

Ghost should show the initial setup screen.

Create your administrator account.

There is no default:

```text
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:

```yaml
ports:
  - "2368:2368"

```

and access:

```text
http://192.168.x.x:2368

```

It works.

But for a public blog, I prefer:

```text
Internet
   ↓
Cloudflare
   ↓
Tunnel
   ↓
Docker
   ↓
Ghost

```

instead of:

```text
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:

```text
./content/

```

MySQL data:

```text
./mysql/

```

So the project contains everything needed for the application.

Backups become straightforward.

For example:

```bash
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:

```bash
docker compose pull
docker compose up -d

```

If you change the Compose configuration:

```bash
docker compose up -d --force-recreate

```

And if you changed the Docker build configuration:

```bash
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.