My homepage had no hero card.
Not "the hero card looked wrong." Not "the hero card was slow to load." It was just gone. I'd added a featured post flag to a post, saved it, refreshed the homepage, and saw a blank where the big opening card should be. Spent twenty minutes assuming it was a CSS thing before I realized the template wasn't rendering at all.
That was the itch that started this whole thing.
The Ghost HBS bug nobody warns you about
The homepage used {{#get}} to fetch a featured post, and the {{else}} clause was supposed to fall back to the newest post when nothing was featured. The logic made sense on paper. In practice, Ghost's {{else}} on {{#get}} only fires on query errors, not on empty results. If the query succeeds but returns nothing, {{else}} is silent.
The fix is to wrap the check in {{#if posts}} inside the #get block:
{{#get "posts" filter="featured:true" limit="1"}}
{{#if posts}}
{{! featured hero }}
{{else}}
{{! newest post hero }}
{{/if}}
{{/get}}
Simple once you know it. Took me longer than I'd like to admit to figure out that the branch I was debugging would never fire.
While I was in there, I cleaned up the card CSS too. The featured post gets a white card with a shadow and rounded corners (.bc-featured--card). The fallback newest-post hero uses an open layout with a border-bottom and a gap between columns. Before the fix, those styles were bleeding into each other. Now the modifier class only applies when there's actually a featured post.
Making the Now page actually live
I have a /now page. For a while it was mostly static text I'd update manually every few weeks and immediately forget about. That bothered me. The whole point of a Now page is to show what you're actually doing, not what you were doing the last time you remembered to edit it.
So I wired up three live data sources.
GitHub auto-detects the most recently pushed repo and shows name, description, primary language, and time since last push. Below that, a list of the last five GitHub events (pushes, PRs, issues, releases) with appropriate icons. This required a bit of skeleton-state work so the page doesn't flash blank on load.
Wakatime pulls weekly coding hours, daily average, and top languages and projects. It's the one I check the most after deploying. Seeing "6.2 hrs/day" staring back at me is either motivating or alarming depending on the week.
Literal.club was the last piece. I used to maintain a reading list manually in the Ghost editor, which meant it was always out of date. Literal has a GraphQL API, so now the page fetches whatever I'm currently reading and shows cover, title, and author. When I finish a book and mark it read in the app, the site updates automatically. No editing required.
I also dropped a small teaser on the About page: current Wakatime hours, top language, top project, and a link to the full Now page. It's a one-paragraph summary that stays current without me touching it.
API keys in the browser are a bad idea
When I first wired up the Wakatime and Literal integrations, the API keys were sitting in Ghost's Code Injection box, exposed to anyone who viewed source. That worked for development. It was not a defensible production setup.
The fix was Nginx proxies on the server. Three routes, each forwarding to the real API with the credentials injected server-side:
/api/wakatime/forwards to Wakatime with Basic auth/api/unsplash/forwards to Unsplash with a Client-ID header/api/literal/forwards to Literal's GraphQL endpoint with a Bearer JWT
The frontend calls /api/wakatime/users/current/stats instead of the real Wakatime URL. The credentials never leave the server. I removed them from Ghost Admin entirely.
One gotcha: the Literal JWT failed with a 400 for about an hour. The token was split across two lines when I pasted it into the config file. The API silently accepted the malformed request and returned nothing useful. Fixed by making sure the token stays on a single line. Obvious in retrospect.
Automating the boring server maintenance
The other thing that was quietly broken: Ghost's email. Magic link login wasn't working because the server was trying to send mail directly over port 25, which most VPS providers block. Switched to Gmail SMTP with an App Password and it started working immediately.
From there I set up a few cron jobs I should have had from the start:
A weekly backup runs every Sunday at 2am. It dumps the database, compresses the content folder, archives the config. Deletes the previous backup on success to avoid filling the disk. Sends an email notification either way.
A weekly Ghost update runs at 3am the same night, after the backup finishes. Runs the backup first as a precaution, then ghost update, then sends an email with the result. I've been burned by updating before backing up before.
An auto-deploy script checks for new commits to the theme repo every minute. If it finds any, it pulls, rebuilds the CSS with Gulp, and restarts Ghost. Theme changes are live within sixty seconds of pushing.
Infrastructure as code (finally)
The last thing I did was stop treating the server like a snowflake. I'd been making changes directly over SSH, which meant the server configuration existed only on the server.
I created a private GitHub repo with three folders: Nginx configs, shell scripts, and crontab snapshots. A deploy script runs every minute via cron, checks for new commits, and selectively applies changes. Nginx changes go through nginx -t before reload. Script changes get the right permissions set. Crontab changes apply for both users.
The deploy key is read-only, added directly to the GitHub repo. The server can pull but not push.
What this means in practice: I can make a change to the Nginx config on my laptop, push it, and it's live within a minute without touching SSH. If I break something, I can revert the commit and it undoes itself.
Where things stand now
Two repos, two auto-deploy pipelines. Push a theme change and it's on the site in under sixty seconds. Push an infra change and it's applied in under sixty seconds. The server runs its own backups and updates. The Now page stays current without me editing anything.
The site's not done. It never is. But it finally feels like something I built instead of something I'm constantly fighting with.