Self-Hosting Ghost on Unraid: The Config Issues Nobody Warns You About

Self-Hosting Ghost on Unraid: The Config Issues Nobody Warns You About

Somewhere between a rant and a reason.

I recently set up Ghost as a self-hosted blog on my Unraid server behind Cloudflare and Nginx Proxy Manager. The install itself was easy. Ghost container from Community Apps, MariaDB container, done. What came after was a solid few hours of debugging that I couldn't find good answers for anywhere. So, here's everything I ran into and how I fixed it.

My Setup

  • Unraid server running Ghost and MariaDB as Docker containers
  • Nginx Proxy Manager handling reverse proxy
  • Cloudflare for DNS and DDNS (free tier)
  • Ubiquiti Dream Machine with DDNS pushing my dynamic home IP to Cloudflare
  • Hover for domain registration and email hosting
  • Domain: pointed at my home IP through Cloudflare to NPM to Ghost

If you're doing something similar, like self-hosting Ghost behind a reverse proxy on a home network, you're probably going to hit these same walls.

Issue 1: Environment Variables Don't Do What You Think

This one wasted the most time. In Unraid, you configure Docker containers through a template UI where you set environment variables. Ghost's documentation says environment variables override config.production.json. That's true, if you're starting fresh.

Here's the catch: the Ghost Docker image only writes environment variables into config.production.json on first boot. Once that file exists (and it persists because your content volume is mounted), the container template values in Unraid are completely ignored. You can change the URL, mail settings, whatever you want in the Unraid UI. Ghost won't care. It's reading the old config file.

The Fix

Edit config.production.json directly inside the container:

docker exec -it Ghost.johnseals.me cat /var/lib/ghost/config.production.json

That shows you what Ghost is actually using. To write a corrected config:

cat << 'EOF' | docker exec -i YOUR_GHOST_CONTAINER tee /var/lib/ghost/config.production.json > /dev/null
{
  "url": "https://yourdomain.com",
  "server": {
    "port": 2368,
    "host": "::"
  },
  "database": {
    "client": "mysql",
    "connection": {
      "host": "YOUR_MARIADB_IP",
      "port": 3306,
      "user": "YOUR_DB_USER",
      "password": "YOUR_DB_PASSWORD",
      "database": "ghost"
    }
  },
  "mail": {
    "transport": "SMTP",
    "from": "you@yourdomain.com",
    "options": {
      "host": "your.smtp.server",
      "port": 465,
      "secure": true,
      "auth": {
        "user": "you@yourdomain.com",
        "pass": "YOUR_EMAIL_PASSWORD"
      }
    }
  },
  "security": {
    "staffDeviceVerification": false
  },
  "logging": {
    "transports": ["file", "stdout"]
  },
  "process": "systemd",
  "paths": {
    "contentPath": "/var/lib/ghost/content"
  }
}
EOF

Then restart:

docker restart YOUR_GHOST_CONTAINER

Don't trust the Unraid template for Ghost configuration. Edit the JSON directly.

Issue 2: The URL Has to Be Exactly Right

My Ghost config had http://localhost:2368 as the URL, which is the default. I changed it in the Unraid container template to https://johnseals.me, but because of Issue 1, Ghost never picked it up.

This causes two problems:

  1. Ghost generates session cookies scoped to localhost, so your browser doesn't send them back when you visit your actual domain.
  2. Ghost marks cookies as insecure (HTTP), and your browser refuses to store them because you're accessing the site over HTTPS through your reverse proxy.

The result: you can log in (the POST returns 200), but the admin panel immediately forgets who you are. Every API call returns 403 Authorization failed with the message "Unable to determine the authenticated user or integration."

The Fix

The URL in config.production.json must exactly match how you access the site, protocol included:

"url": "https://yourdomain.com"

Not http://, not localhost, not your internal IP. The actual public HTTPS URL. Verify it took by checking the boot log:

docker logs YOUR_GHOST_CONTAINER --tail 5

You should see: Your site is now available on https://yourdomain.com/

Issue 3: Ghost Blocks Login When Email Fails

Ghost 5+ introduced a "staff device verification" feature. Every time you log in from a new device, Ghost sends an email with an auth code before letting you in. If email isn't configured, or if the SMTP connection fails, the login hangs or errors out entirely.

In my case, I saw two different behaviors depending on the failure mode:

  • SMTP timeout (wrong server/port): Login POST took 90 seconds, browser gave up, session cookie expired before the response came back. The login technically succeeded but the session was dead on arrival.
  • SMTP connection refused (no mail config): Login responded quickly but returned 500 with "Failed to send email."

Either way, you can't get into your own blog.

The Fix

Add this to your config.production.json:

"security": {
  "staffDeviceVerification": false
}

This is an officially documented setting. It disables the login email requirement so you can get in regardless of your mail setup. For a personal blog, you don't need it. If you want it back later after email is working, flip it to true.

Issue 4: SMTP Settings Are Provider-Specific

I use Hover for email hosting. I initially tried smtp.hover.com on port 587, which is what some third-party sites recommend. It didn't work. The connection timed out inside the Docker container.

Hover's actual SMTP settings (from their official support page) are:

Setting Value
Server mail.hover.com
Port 465
Encryption SSL/TLS
Username Your full email address
Password Your email password

The Ghost config for Hover looks like this:

"mail": {
  "transport": "SMTP",
  "from": "you@yourdomain.com",
  "options": {
    "host": "mail.hover.com",
    "port": 465,
    "secure": true,
    "auth": {
      "user": "you@yourdomain.com",
      "pass": "your_hover_email_password"
    }
  }
}

Before you waste time debugging Ghost, test whether your container can even reach the mail server:

docker exec -it YOUR_GHOST_CONTAINER sh -c 'node -e "const s = require(\"net\").connect(465, \"mail.hover.com\", () => { console.log(\"CONNECTED\"); s.end(); }); s.on(\"error\", e => console.log(\"ERROR:\", e.message)); setTimeout(() => { console.log(\"TIMEOUT\"); process.exit(); }, 5000);"'

If that says TIMEOUT, your container has a networking issue, or your ISP is blocking outbound SMTP. If it says CONNECTED, the problem is your credentials or config.

Issue 5: Reverse Proxy Needs the Right Headers

If you're running Ghost behind Nginx Proxy Manager (or any reverse proxy), you need to pass the right headers, so Ghost knows who the visitor is and how they connected. Without these, cookies get stripped or Ghost doesn't know the connection is HTTPS.

In NPM, edit your proxy host, go to the Advanced tab, and add:

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
proxy_pass_header Set-Cookie;

Also make sure:

  • Websockets Support is on
  • SSL is configured with a certificate
  • Force SSL is enabled

Issue 6: Forgot Your Password? Good Luck.

I set up my Ghost account, forgot my password, clicked "Forgot", and got Failed to send email. Reason: connect ECONNREFUSED 127.0.0.1:587. No SMTP means no password reset email.

The Ghost CLI ghost password command doesn't exist in the Docker image. You have to reset it through the database.

The Fix

Generate a bcrypt hash from inside the Ghost container using its own bcryptjs module:

docker exec -it YOUR_GHOST_CONTAINER node -e 'const b = require("/var/lib/ghost/versions/YOUR_VERSION/node_modules/.pnpm/bcryptjs@3.0.3/node_modules/bcryptjs"); b.hash("YourNewPassword", 10).then(h => console.log(h))'

Find the right path first:

docker exec -it YOUR_GHOST_CONTAINER find /var/lib/ghost -name "bcrypt*" -type d | head -5

Then update it in MariaDB. Get into the database interactively (not inline, or bash will mangle the $ signs in the hash):

docker exec -it YOUR_MARIADB_CONTAINER mariadb -u root -p

Then at the MariaDB prompt:

UPDATE ghost.users SET password='PASTE_YOUR_HASH_HERE' WHERE email='you@yourdomain.com';
DELETE FROM ghost.brute;

The second line clears the failed login counter, so you don't get locked out for 20 minutes.

Note: newer MariaDB Docker images use the mariadb command, not mysql.

The Working Stack

Once everything is sorted, here's what a working self-hosted Ghost setup looks like:

  • Cloudflare (free): DNS, DDNS, CDN, SSL proxy
  • UDM/router: DDNS client pushing IP updates to Cloudflare, port forwarding 80/443 to NPM
  • Nginx Proxy Manager: reverse proxy with SSL certs, routes domains to containers
  • Ghost Docker: the blog, configured via config.production.json directly
  • MariaDB Docker: Ghost's database
  • Hover: domain registrar and email (SMTP for Ghost transactional emails)

Total monthly cost: $0 (plus whatever Hover charges for domains and email, which you're already paying for).

It works. It just takes a bit of fighting to get there. Hopefully this saves you a few hours.