Setting up a Blog Server using Docker

Setting up a Blog Server using Docker
Plainshawk Blogging Desk

Installing and Securing Ghost

Mac Mini · Docker · Nginx Proxy Manager · Mailrelay


Ghost is the publishing platform behind the Plainshawk blog. This guide documents my current self-hosted installation: Ghost running in Docker on a Mac Mini, backed by MySQL, with Nginx Proxy Manager providing external HTTPS access and a separate mail relay handling outgoing email.

The aim isn't simply to get Ghost running. The configuration below is designed to avoid several problems I encountered while building and rebuilding the installation, particularly around database persistence, Docker startup order, email delivery and Ghost's newer ActivityPub functionality.

The resulting architecture is:

with Ghost's persistent content stored on the external SSD:

/Volumes/Media-Home/ghost-content


Key lessons from my installation

These are the problems this guide specifically addresses.

The important lessons
  • The MySQL database volume must be explicitly mounted. Otherwise database data can be lost when the container is removed.
  • Environment variables must not have comments appended to the same line. Ghost can silently fall back to SQLite if the MySQL configuration isn't parsed correctly.
  • Ghost 6.x ActivityPub can make outbound HTTPS requests to itself during startup. In this setup, ActivityPub is disabled.
  • Ghost needs tls__rejectUnauthorized=false to communicate with the internal mail relay.
  • The MySQL healthcheck prevents Ghost from trying to connect before the database is ready.
  • privacy__useFavicon=false avoids an SSL self-reference error encountered during startup.

These details are specific to the configuration described in this guide, but they're also useful lessons if you're troubleshooting a similar Ghost/Docker installation.


My environment

Component Configuration
Host Mac Mini
Container platform Docker Desktop
Blog platform Ghost
Database MySQL 8.0
Reverse proxy Nginx Proxy Manager
DNS Cloudflare, DNS-only
Mail Internal mail relay
Ghost hostname blog.plainshawk.co.uk
Content storage /Volumes/Media-Home/ghost-content
Docker network proxy-net

Before you start

You will need:

  • Docker Desktop running
  • An existing Docker network called proxy-net
  • The mailrelay container running
  • Ports 80 and 443 forwarded on your router
  • A Cloudflare A record for blog.plainshawk.co.uk pointing to your public IP
  • The Cloudflare record set to DNS only (grey cloud)
💡
Check proxy-net before starting

The Ghost container needs to share a Docker network with Nginx Proxy Manager.

You can check that the network exists with:

docker network inspect proxy-net

If it doesn't exist, create it before continuing.


Contents

  • Stage 1 — Create the directory structure
  • Stage 2 — Create the Compose file
  • Stage 3 — Create the content directory
  • Stage 4 — Add the DNS record
  • Stage 5 — Start Ghost
  • Stage 6 — Verify MySQL is being used
  • Stage 7 — Configure Nginx Proxy Manager
  • Stage 8 — Complete the initial Ghost setup
  • Stage 9 — Configure Ghost
  • Stage 10 — Verify email delivery
  • Stage 11 — Add a pre-operation backup
  • Stage 12 — Add Ghost to the nightly backup
  • Stage 13 — Add Ghost to the startup script
  • Stage 14 — Add Ghost to Uptime Kuma
  • Quick reference
  • Troubleshooting

Installation

Stage 1 — Create the directory structure

I keep each Docker application in its own directory. This keeps the Compose file and Ghost-specific configuration together and makes the installation easier to maintain.

mkdir -p ~/docker/ghost
cd ~/docker/ghost

Stage 2 — Create the Compose file

Create the Docker Compose configuration:

nano docker-compose.yml

Paste the following complete Compose file.

There are several deliberately important details here, so I recommend using this configuration as a whole rather than trying to recreate it from memory.

Important: don't put comments on environment-variable lines

Every environment variable below is on its own line.

For example, don't change:

- database__client=mysql

into:

- database__client=mysql # MySQL

The comment can become part of the value, preventing Ghost from interpreting the configuration correctly. In my case this resulted in Ghost silently falling back to SQLite.

services:
  ghost-db:
    image: mysql:8.0
    container_name: ghost-db
    restart: unless-stopped
    environment:
      - MYSQL_ROOT_PASSWORD=choose_a_root_password
      - MYSQL_DATABASE=ghost
      - MYSQL_USER=ghost
      - MYSQL_PASSWORD=choose_a_db_password
    volumes:
      - ./mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-u", "ghost", "-pchoose_a_db_password"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 30s

  ghost:
    image: ghost:latest
    container_name: ghost
    restart: unless-stopped
    depends_on:
      ghost-db:
        condition: service_healthy
    environment:
      - NODE_ENV=production
      - url=https://blog.plainshawk.co.uk
      - server__host=0.0.0.0
      - server__port=2368
      - database__client=mysql
      - database__connection__host=ghost-db
      - database__connection__database=ghost
      - database__connection__user=ghost
      - database__connection__password=choose_a_db_password
      - privacy__useUpdateCheck=false
      - privacy__useGravatar=false
      - privacy__useFavicon=false
      - imageOptimization__resize=false
      - activitypub__enabled=false
      - mail__transport=SMTP
      - mail__from=Plainshawk <noreply@plainshawk.co.uk>
      - mail__options__host=mailrelay
      - mail__options__port=25
      - mail__options__secure=false
      - mail__options__tls__rejectUnauthorized=false
    volumes:
      - /Volumes/Media-Home/ghost-content:/var/lib/ghost/content
    networks:
      - default
      - proxy-net

networks:
  default:
  proxy-net:
    external: true

Why these settings matter

There are four particularly important areas in this configuration.

MySQL persistence

volumes:
  - ./mysql:/var/lib/mysql

This keeps the MySQL database outside the disposable container.

Startup ordering

depends_on:
  ghost-db:
    condition: service_healthy

Ghost won't attempt to start until the MySQL healthcheck reports that the database is ready.

Persistent Ghost content

volumes:
  - /Volumes/Media-Home/ghost-content:/var/lib/ghost/content

Themes, images and other Ghost content are stored on the external SSD rather than inside the container.

Shared reverse-proxy network

networks:
  - default
  - proxy-net

This makes Ghost accessible to Nginx Proxy Manager without exposing port 2368 directly to the Internet.

💡
Change the passwords

Replace:

  • choose_a_root_password
  • choose_a_db_password

with strong, unique passwords.

The value of MYSQL_PASSWORD and the corresponding Ghost database password must match.

The password used by the MySQL healthcheck must also match MYSQL_PASSWORD exactly.


Stage 3 — Create the content directory

Create the directory that will hold Ghost's persistent content:

mkdir -p /Volumes/Media-Home/ghost-content

This directory will be mounted inside the Ghost container as:

/var/lib/ghost/content
💡
Docker Desktop file sharing

Because this directory is on an external macOS volume, Docker Desktop must have permission to access /Volumes/Media-Home.

If Docker cannot mount the directory, check:

Docker Desktop → Settings → Resources → File Sharing

and make sure /Volumes/Media-Home is included.


Stage 4 — Add the DNS record

Create an A record in Cloudflare.

Type Name Content Proxy
A blog Your public IP Grey cloud — DNS only

This should result in:

blog.plainshawk.co.uk → your public IP

Why DNS-only?

In this configuration Cloudflare provides DNS but doesn't proxy the traffic. Nginx Proxy Manager on the Mac Mini handles the HTTPS connection and obtains the Let's Encrypt certificate.


Stage 5 — Start Ghost

Start the containers:

docker compose up -d

Then follow the Ghost logs:

docker compose logs -f ghost

Watch the startup sequence carefully.

You should see:

  1. ghost-db becoming healthy
  2. Ghost connecting to MySQL
  3. Ghost completing its startup
  4. A message similar to Ghost boot X.x.x completed

The database may take 30–60 seconds to become ready.

💡
Check the database

The important thing at this stage is to confirm that Ghost is using MySQL, not SQLite.

If you see references to SQLite in the startup output, stop here and check the Compose file.

Pay particular attention to the database__client=mysql line and make sure it has no inline comment or other unexpected characters.

Press Ctrl+C once Ghost is running.

Now check both containers:

docker compose ps

Both ghost and ghost-db should be running.


Stage 6 — Verify MySQL is being used

Before configuring Nginx Proxy Manager, I want to verify that Ghost's database is actually MySQL.

Run:

docker exec ghost-db mysql \
  -u ghost \
  -pchoose_a_db_password \
  ghost \
  -e "SHOW TABLES;"

You should get a list of Ghost database tables.

💡
Expected result

The command should return Ghost's table list.

If it returns nothing, or if Ghost has created a fresh installation unexpectedly, stop and check the database configuration before proceeding.


Configure remote access

Stage 7 — Configure Nginx Proxy Manager

Once Ghost is working internally, configure Nginx Proxy Manager to make it available at:

https://blog.plainshawk.co.uk

Log into Nginx Proxy Manager and go to:

Proxy Hosts → Add Proxy Host

Details

Field Value
Domain blog.plainshawk.co.uk
Scheme http
Forward Hostname ghost
Forward Port 2368
Cache Assets Off
Block Common Exploits On
Websockets Support On

SSL

On the SSL tab:

  • Request a new Let's Encrypt certificate
  • Force SSL: On
  • HTTP/2 Support: On

Advanced configuration

The following configuration handles larger image/media uploads, increases the proxy timeouts and blocks several common automated scanner requests.

Ghost is a Node.js application and doesn't use PHP, WordPress or phpMyAdmin, so requests targeting those services can simply be rejected.

# Upload size for images and media
client_max_body_size 50M;

# Timeouts
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;

# Block scanners — Ghost uses Node.js, no PHP
location ~ \.(php|php\d|phtml|asp|aspx|cgi)$ {
    return 444;
}
location ~ /(wp-admin|wp-login|wp-content|wp-includes|xmlrpc\.php) {
    return 444;
}
location ~ /\.(env|git|svn|aws|ssh|npmrc) {
    return 444;
}
location ~ /(phpmyadmin|pma|adminer|mysql)/ {
    return 444;
}
location ~ /(shell|cmd|exec|system|passthru|eval)/ {
    return 444;
}

Save the Proxy Host.


Configure Ghost

Stage 8 — Complete the initial Ghost setup

Open:

https://blog.plainshawk.co.uk/ghost

You should now see the Ghost setup wizard.

Complete the wizard:

  1. Create your admin account
    • Use a strong password
    • Store the password securely in Vaultwarden
  2. Publication name
    • Plainshawk
  3. Invite staff
    • Skip this for now
  4. Choose a theme
    • Casper is a clean starting point

After completing the wizard, Ghost should take you to the administration interface.


Stage 9 — Configure Ghost

Once you're logged in, open Settings.

General

Configure:

  • Publication name
  • Publication description
  • Logo
  • Favicon
  • Timezone: London

Design

Configure:

  • Theme
  • Accent colour
  • Navigation
  • Other theme-specific options

For Plainshawk, I'm using the Casper theme as the foundation and then adding some custom styling through Ghost's Code Injection facility.

Email newsletter

Go to:

Settings → Email

Scroll down to Sending domain and set:

plainshawk.co.uk

Then send a test email.

It should be delivered through the mail relay.

Members

Go to:

Settings → Members

Decide whether you want to enable member signups.

If you intend to use Ghost's newsletter functionality, this is where you can enable the audience/member functionality.

💡
You don't have to enable Members

If Plainshawk is primarily a traditional blog and you don't intend to build a newsletter audience, leaving member signups disabled keeps the installation simpler.


Stage 10 — Verify email delivery

Go to:

Settings → Email → Send test email

The test message should be delivered through the mail relay.

If the email doesn't arrive, check the mail relay logs:

cd ~/docker/mailrelay
docker compose logs --tail=20 mailrelay
💡
TLS and the internal mail relay

The Ghost configuration deliberately contains:

mail__options__tls__rejectUnauthorized=false

This is required in this particular setup because Ghost is communicating with the internal mail relay using its certificate configuration.

Without it, Ghost rejects the relay's self-signed certificate.


Backups and maintenance

Stage 11 — Add a pre-operation backup

A database backup should be taken before making changes that could affect the Ghost installation.

I created a small shell function called ghost-backup so that taking a backup is quick and difficult to forget.

Open your shell configuration:

nano ~/.zshrc

Add:

ghost-backup() {
  DATE=$(date +%Y%m%d_%H%M%S)
  echo "Backing up Ghost database..."
  docker exec ghost-db mysql \
    -u ghost \
    -pchoose_a_db_password \
    ghost > /Users/gavin/docker/backups/ghost_preop_$DATE.sql
  echo "Done — saved as ghost_preop_$DATE.sql"
}

Reload the configuration:

source ~/.zshrc

You can now create a pre-operation backup simply by running:

ghost-backup
💡
Make this part of your routine

Always run ghost-backup before performing an operation that involves taking Ghost or MySQL down, changing the database configuration, or otherwise making a potentially destructive change.


Stage 12 — Add Ghost to the nightly backup

Ghost's database should also be included in the regular nightly backup.

Open the backup script:

nano ~/docker/backups/backup.sh

Confirm that this section is present:

# Ghost
docker exec ghost-db mysql \
  -u ghost \
  -pchoose_a_db_password \
  ghost > $BACKUP_DIR/ghost_$DATE.sql \
  2>/dev/null
echo "Ghost backup done"

This gives you an independent SQL dump of the Ghost database every night.

💡
Database backup ≠ content backup

The MySQL backup protects Ghost's database, but your Ghost content directory also needs to be included in your normal filesystem backup.

The persistent content is stored at:

/Volumes/Media-Home/ghost-content

That directory contains important Ghost assets such as uploaded images and themes.


Stage 13 — Add Ghost to the startup script

If you use a startup script to bring your Docker services online after a reboot, add Ghost after the mail relay.

Open:

nano ~/docker/start-all.sh

Confirm that this section is present:

echo "Starting Ghost..."
cd /Users/gavin/docker/ghost && docker compose up -d

Starting Ghost after the mail relay ensures that the dependency is already available when Ghost starts.


Stage 14 — Add Ghost to Uptime Kuma

I use Uptime Kuma to monitor the services running on the Mac Mini.

Add a monitor at:

https://status.plainshawk.co.uk

Field Value
Name Blog
Monitor Type HTTP(s)
URL http://ghost:2368
Heartbeat Interval 60s
Retries 3

The monitor checks Ghost from within the Docker environment rather than relying on the external Internet connection.


Quick reference

URL Purpose
https://blog.plainshawk.co.uk Public blog
https://blog.plainshawk.co.uk/ghost Ghost administration

Troubleshooting

Ghost uses SQLite instead of MySQL

This is one of the most important problems to check.

The first thing to inspect is:

- database__client=mysql

Make sure there is no inline comment after the value.

For example, don't use:

- database__client=mysql # MySQL

Use:

- database__client=mysql

If Ghost has started using SQLite, stop the installation and correct the Compose file before adding content.


Startup error: Cannot GET /.ghost/activitypub

Ghost 6.x includes ActivityPub functionality that can make outbound HTTPS requests to the site itself.

In this installation I don't need ActivityPub, so it is explicitly disabled:

- activitypub__enabled=false

After changing the Compose file, recreate the Ghost container:

docker compose down
docker compose up -d
💡
Take a backup first

Because this involves taking the application down, run:

ghost-backup

before performing the operation.


Emails aren't sending

First check that this setting is present:

- mail__options__tls__rejectUnauthorized=false

Without it, Ghost may reject the self-signed certificate used by the internal mail relay.

Then inspect the mail relay logs:

cd ~/docker/mailrelay
docker compose logs --tail=20 mailrelay

Ghost loops or shows a fresh installation after down && up

Check the MySQL volume.

The ghost-db service must contain:

volumes:
  - ./mysql:/var/lib/mysql

Without this persistent volume, the MySQL data exists only inside the container and can disappear when the container is removed.

If you have already lost the database, restore the most recent Ghost SQL backup before proceeding.


ghost-db healthcheck is failing

Check the healthcheck:

healthcheck:
  test: ["CMD", "mysqladmin", "ping", "-u", "ghost", "-pchoose_a_db_password"]

The password in the healthcheck must exactly match:

MYSQL_PASSWORD=choose_a_db_password

Check carefully for:

  • Typographical errors
  • Different passwords
  • Missing characters
  • Accidental spaces

Ghost content isn't persisting

Check that:

volumes:
  - /Volumes/Media-Home/ghost-content:/var/lib/ghost/content

is present.

Then confirm that /Volumes/Media-Home is available to Docker Desktop:

Docker Desktop → Settings → Resources → File Sharing

The directory should be accessible from Docker.


Final checks

Before considering the installation complete, I recommend checking each of these:

💡
Ghost installation checklist
  • [ ] Ghost starts successfully
  • [ ] ghost-db reports healthy
  • [ ] Ghost is using MySQL rather than SQLite
  • [ ] MySQL data is stored in ./mysql
  • [ ] Ghost content is stored on /Volumes/Media-Home/ghost-content
  • [ ] blog.plainshawk.co.uk resolves correctly
  • [ ] Nginx Proxy Manager provides the HTTPS certificate
  • [ ] The Ghost administration interface is accessible
  • [ ] Email test succeeds
  • [ ] Pre-operation database backup works
  • [ ] Nightly backup includes Ghost
  • [ ] Ghost is included in the startup script
  • [ ] Uptime Kuma monitors the service

Finished

At this point Ghost is running as a persistent Docker service on the Mac Mini, with MySQL providing the database, the external SSD storing Ghost's content, Nginx Proxy Manager handling HTTPS access and the mail relay handling outgoing email.

The important part of this installation isn't simply getting Ghost to start. The configuration also makes the installation persistent, recoverable and maintainable.

The database has its own persistent volume and regular backups, Ghost waits for MySQL to become healthy before starting, the reverse proxy is isolated from the public Internet, and the service is included in the Mac Mini's startup and monitoring arrangements.

That gives Plainshawk a solid self-hosted foundation while retaining the convenience of Ghost's publishing interface.