I Spent 3 Hours Debugging a Ghost Login Bug That Had a 30-Second Fix
Last week I decided to move my Ghost blog from the root domain to a /blog subpath. Simple change, right? Update the URL, adjust the reverse proxy routing, restart the container. Done.
The blog loaded fine. The admin panel loaded fine. But when I tried to log in?
Nothing.
Just "Signing in..." with an infinite spinner. No error. No timeout. No feedback whatsoever. Just vibes.
The Setup
I'm running Ghost in Docker with Traefik as my reverse proxy and MySQL for the database. Pretty standard self-hosting stack. The move from mydomain.com to mydomain.com/blog should've been a five-minute job.
Narrator: It was not a five-minute job.
What I Tried First (Spoiler: None of This Worked)
- Cleared cookies. Multiple times.
- Tried incognito mode.
- Tried a different browser.
- Restarted the container approximately 47 times.
- Stared at the Traefik dashboard like it owed me money.
- Googled variations of "ghost login stuck signing in" until my search history looked like a cry for help.
Actually Reading the Logs
Eventually I did what I should've done from the start: checked the container logs.
"Unable to determine the authenticated user or integration.
Check that cookies are being passed through if using session authentication."
NoPermissionError: Authorization failed
GET /blog/ghost/api/admin/users/me/?include=roles — 403Okay so the login POST was working. Ghost accepted my password. But then every API call after that returned 403. The session cookie was being rejected.
Cool cool cool. Totally normal. Everything is fine.
The First Problem: Ghost Was Living a Double Life
I had set the URL in my docker-compose environment variables. Should be straightforward.
But when I checked the actual config file inside the container, it still said http://localhost:2368.
Turns out Ghost reads the config file first. If there's already a value there, environment variables don't override it. They only fill in blanks. So Ghost thought it was running on localhost while I was trying to access it through my actual domain.
No wonder it was confused about cookies.
Fixed that by updating the config file directly inside the container. But login still didn't work.
The Real Problem: Ghost's Paranoid Security Feature
Ghost 5.118 introduced something called staffDeviceVerification. It's actually a good security feature. When you log in from a "new device," Ghost sends you an email with a verification code. Extra layer of protection.
The problem? Ghost determines "new device" based on IP address and session fingerprinting. Behind a reverse proxy, Ghost doesn't see your real IP. It sees the Docker internal network IP. So every single login looks like a suspicious new device.
And since I didn't have email configured... Ghost tried to send me a verification code, failed silently, and just hung forever.
No error message. No "check your email" prompt. Just an infinite spinner into the void.
Three hours of debugging for what turned out to be a security feature doing its job too well.
The Fix
Disable staffDeviceVerification in the Ghost config:
{
"security": {
"staffDeviceVerification": false
}
}Or via environment variable in docker-compose:
environment:
security__staffDeviceVerification: "false"Restart the container.
Login worked immediately.
I stared at my screen for a solid minute processing the fact that this was it. This was the whole thing.
Why This Is So Annoying
- Zero feedback. The login page gives you nothing. No error, no hint, no indication that it's waiting for email verification. Just endless spinner.
- The feature assumes you have email working. Which... fair. You probably should have email working on a production blog. But during initial setup or migration? Not always the case.
- Reverse proxies break the device detection. Ghost sees proxy IPs, not real client IPs. Unless you've got your X-Forwarded headers configured perfectly, every login looks suspicious.
- The config file vs environment variable thing is confusing. The docs say env vars override config. In practice, it's more nuanced than that. If config.production.json already has a value, Ghost uses it.
What I Learned
Check the actual config file inside the container. Not your docker-compose, not your .env file. The actual config.production.json that Ghost is reading.
Read the logs before going down rabbit holes. I spent way too long messing with Traefik rules when the logs clearly showed an authentication issue.
Security features can fail silently. Ghost's device verification is good security. But when it can't send email, it should probably tell you that instead of just... not.
Configure email early. Would've avoided this whole thing if I'd set up SMTP first. Lesson learned.
Checklist If You're Moving Ghost to a Subpath
- Update your reverse proxy routing rules
- Set the correct URL in your Ghost config (check INSIDE the container)
- Either configure SMTP email OR disable staffDeviceVerification temporarily
- Make sure X-Forwarded-Proto is set to https
- Make sure X-Forwarded-For passes the real client IP
- Don't use stripPrefix middleware. Ghost needs to see the full path
- Clear browser cookies before testing
- Actually read the logs when things break
Final Thoughts
Self-hosting is great until it isn't. This was one of those moments where I questioned every decision that led me to running my own infrastructure instead of just paying for Ghost Pro.
But then I fixed it, and now I have a blog running exactly how I want, where I want, and I understand the whole stack. That's worth something.
Also I have a blog post out of it. Always be content farming your own suffering.