My Blog Told Me It Was Vulnerable. It Was Right.

My Blog Told Me It Was Vulnerable. It Was Right.

I self host my blog. The whole thing, Ghost CMS, MySQL database, all of it, runs on a Docker host in my house. A Cloudflare tunnel punches it out to the internet so you can read posts at emir.fyi without my home IP being exposed. It sits alongside a dozen other self hosted services on a machine I manage through Portainer: a note taking app, a document management system, a container registry, monitoring agents. The blog is the only one that faces the public internet.

Why self host a blog in 2026? Honestly, because I can. I already have the infrastructure for my homelab, and running Ghost on hardware I control means no monthly bills, no platform risk, no "we're pivoting to AI and shutting down the blogging product" emails. Plus, I write about self hosting. It would be a little embarrassing to do that from a managed WordPress instance.

The setup had been running fine for some time now. Ghost 6.14.0 on port 2368, MySQL 8.4 backing it, both as Docker containers on a shared network. Traffic flows in through Cloudflare, hits a Caddy reverse proxy on my HA load balancer, and lands on Ghost. Simple, stable, boring in the best way.

Then I opened my Ghost admin panel and saw the kind of message that makes you put down your coffee:

"Update Ghost now: your Ghost site is vulnerable to an attack that lets unauthenticated attackers read arbitrary data from the database."

Not "hey, there's a minor update available." Not "consider upgrading when convenient." Unauthenticated attackers can read your entire database. Cool. Love that for me.

This is the part of self hosting nobody puts in the brochure. When you run your own stuff, you're the one who gets paged. There's no managed hosting provider absorbing the hit and pushing a patch while you sleep. The CVE lands, the banner appears, and it's on you.

The Vulnerability

CVE-2026-26980 is a SQL injection in Ghost's Content API. The Content API is the public facing, no auth required API that serves your blog posts to the world. Somebody figured out you could inject SQL through it and read whatever you want from the database. User credentials, session tokens, email addresses, draft posts, everything.

Affected versions: Ghost 3.24.0 through 6.19.0. I was running 6.14.0. My blog is publicly accessible via Cloudflare tunnel, so this wasn't a theoretical risk. Anyone on the internet could have been reading my database like a book.

CVSS score: 9.4. That's "stop what you're doing and fix this now" territory.

The Plan

Simple enough:

  • [x] Update Ghost from 6.14.0 to 6.21.2 (latest)
  • [x] Rotate all database credentials (if they could read the DB, they could read the passwords)
  • [x] Actually put Ghost in a docker-compose file like a civilized person
  • [x] Document the standard so this never happens again

That third one is what turned a 10 minute patch into an evening project.

The Dirty Secret

When I went to update Ghost, I discovered something embarrassing. Every other service on my Portainer host (SiYuan, Paperless, Redis, Errbit etc.) had proper docker-compose.yml files. Source of truth in the homelab repo, deployed copies on the host, secrets in .env files. Clean. Reproducible. Professional, even.

Ghost? The blog that's publicly accessible on the internet? The one service that actually has a CVE against it? Manually created through the Portainer UI. No compose file. No IaC. Just vibes and a container that's been running since whenever I clicked "deploy" long time ago.

I had to docker inspect the container just to figure out what environment variables it was using. That's how you know your infrastructure management has gaps.

The Update

First priority: get the vulnerable version off the internet. The actual patching process was straightforward since the data lives in Docker volumes that persist independently of the container.

Here's the approach:

  1. Pull the new image
  2. Rotate the database credentials while MySQL is still running
  3. Stop and remove the old containers
  4. Deploy new ones with the updated image and new credentials

The key thing to understand is that Ghost stores everything in two places. A ghost-content Docker volume holds themes, images, and settings. A MySQL database holds everything else. Both are external to the container. Swapping the container image is like changing the engine in a car while keeping all the cargo. Nothing gets lost.

Rotating Credentials

Since the SQLi vulnerability could read arbitrary database data, I had to assume the MySQL passwords were compromised. That means rotating both the ghost database user and the root MySQL password.

You change passwords in a running MySQL container like this:

ALTER USER 'ghost'@'%' IDENTIFIED BY 'new-ghost-password-here';
ALTER USER 'root'@'localhost' IDENTIFIED BY 'new-root-password-here';
FLUSH PRIVILEGES;

One gotcha: the MYSQL_ROOT_PASSWORD and MYSQL_PASSWORD environment variables in a MySQL container are only used on first initialization. Changing them in your compose file doesn't change the actual passwords in the database. You have to change them in MySQL first, then update the compose config to match. If you do it the other way around, your new container will try to connect with the new password and MySQL will say no.

The Docker Compose File

Here's what the Ghost stack looks like properly codified:

services:
  ghost:
    image: ghost:6.21.2
    container_name: emir.fyi-blog
    restart: always
    ports:
      - "2368:2368"
    volumes:
      - ghost-content:/var/lib/ghost/content
    env_file: .env
    environment:
      url: https://yourdomain.com
      database__client: mysql
      database__connection__host: mysql-db
      database__connection__user: ghost
      database__connection__database: ghost
    depends_on:
      mysql:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "node -e \"const h=require('http');h.get('http://localhost:2368/ghost/api/admin/site/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""]
      interval: 30s
      timeout: 10s
      retries: 3

  mysql:
    image: mysql:8.4
    container_name: mysql-db
    restart: always
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    env_file: .env
    environment:
      MYSQL_DATABASE: ghost
      MYSQL_USER: ghost
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -u root -p\"$$MYSQL_ROOT_PASSWORD\""]
      interval: 30s
      timeout: 10s
      retries: 3

volumes:
  ghost-content:
    external: true
  mysql-data:
    external: true

The .env file (which never gets committed to the repo) holds the secrets:

database__connection__password=your-ghost-db-password
MYSQL_PASSWORD=your-ghost-db-password
MYSQL_ROOT_PASSWORD=your-mysql-root-password

Notice the volumes are marked external: true. That means Docker won't try to create new ones. It'll use the existing volumes with all your data intact. This is what makes the upgrade safe. Your posts, your themes, your images, they're all in those volumes. The container is just the runtime.

The depends_on with condition: service_healthy is important too. Ghost will crash on startup if MySQL isn't ready yet. Without the health dependency, Docker starts both containers simultaneously and Ghost tries to connect to a database that's still initializing. Ask me how I know.

Establishing a Standard

While fixing this, I realized I should document how container management works on Portainer so I don't end up with another rogue manually created container. Here's the standard I landed on:

Source of truth: docker/<service>/docker-compose.yml in the homelab Git repo. This is what gets version-controlled, reviewed, and tracked.

Deployed to: /opt/<service>/compose/ on the Portainer host. This is where the running config lives.

Secrets: .env file in the compose directory on the host. A .env.example with placeholder values gets committed to the repo so someone (future me) knows what variables are needed.

Deploy/update: scp the compose file to the host, then cd /opt/<service>/compose && docker compose up -d.

Healthchecks required: Every container gets a healthcheck. No exceptions. Beszel monitors container health, and a container without a healthcheck is invisible to monitoring.

It's simple, it's boring, and it means I'll never have to docker inspect a container to figure out how it was configured again. (I sure hope I don't regret saying never, again. Not gonna happen take 33 I guess)

What I Learned

The technical fix here took maybe 15 minutes. Pull image, rotate credentials, deploy. The part that took longer, and mattered more, was realizing that my most publicly exposed service was the least well managed one.

There's a pattern in homelabs (and honestly, in production environments too) where the first thing you set up gets the least love. You deploy Ghost early on, it works, you move on to shinier projects. Six months later every other service has proper IaC and the blog is held together by a manually created container and muscle memory.

CVE-2026-26980 was the kick I needed to fix that. If the vulnerability hadn't forced my hand, Ghost would probably still be running on vibes. Sometimes it takes a 9.4 CVSS score to make you do the housekeeping you've been putting off.

The blog is now running Ghost 6.21.2 with fresh credentials, a proper compose file, and a documented standard for how every container on Portainer should be managed. Not bad for a Saturday morning. Done with all this before my kids woke up.