My 'draft' announcement was live for the whole world to see

Written with Kimi (kimi-for-coding).

A story from when I was cleaning up the News section of a service I run solo.

I was building a habit of actually announcing things — outages, new features, the usual. The site is static (= every page is baked to HTML ahead of time), and each announcement is one Markdown file. At the top of the file you write:

draft: true

Which means “this is a draft, don’t show it yet.” Obviously that’s what it means. That’s what I believed, too.

It wasn’t in the list, so I felt safe

Open the announcement list in a browser and — sure enough — draft: true posts do not show up. Not public. Working as intended.

I had also left one draft: true post in the repo as a template. “Template for a new-feature announcement,” that kind of thing. Half-finished placeholder content. Doesn’t show up in the list, so what’s the harm in leaving it there, right?

Just to be sure, I typed the URL in directly. If it’s not in the list, surely it 404s, right?

It returned 200.

Half-finished placeholder content, sitting on production, readable by anyone on the planet who knew the URL. Horror movie? In my production site?

The culprit was the “make the map” function

Here’s the reveal. A static site builds a list of every page URL (= the map) before publishing, and bakes HTML only for what’s on that map.

The problem: the code that built this map never looked at draft.

  • The code that made the list view properly excluded draft: true (that’s why it never showed up)
  • The code that made the URL map happily collected everything, drafts included (that’s why direct links got baked and published)

Two functions doing “collect the posts,” one of them filtering drafts and the other not. My left hand hid the stuff and my right hand quietly put it all back on the table. A beautifully dumb setup.

So draft: true, in this setup, meant nothing more than “don’t put it in the index.” What I thought was a private flag was just a “remove from the table of contents” flag.

Static sites have no “sneaky preview”

Now for the quiet but crucial premise.

A static site becomes all-public the moment you bake it. There is no in-between state like “it’s on production but only I can see it” — not by default. Baked HTML reaches anyone who knows the URL, equally.

A dynamic blog CMS can do “preview visible only to logged-in me.” Bring that intuition to a static site and you’re in for an accident. The deepest part of the trap is that the moment you write draft, you feel protected.

How I fixed it

Two things.

  • Kicked drafts and templates out of the publishable folder. Templates live in the docs side (outside the publish target), and only get copied into the publish folder when I’m actually shipping them. Back to the boring, obvious rule: don’t leave unfinished things in the publish folder.
  • Real previews go to a separate environment. I deploy to a dedicated staging site, check there, then promote to production. I gave up on “keep the draft on production and peek at it myself” and switched to “have a separate place to check.”

And I fixed the map-building code too. Drafts don’t go on the map — written into the function itself. Leaving it as a “just be careful” habit means the next time something gets dropped in the same place, it leaks the same way again.

The mantra:

Call it a “draft” all you want — if it lives in the publish folder, it’s published.

What I learned

  • A static site’s draft flag is only private if the implementation says so. Don’t trust the flag’s name. The name is the wish; the behavior is the reality.
  • “Not in the list” and “not accessible” are completely different things. It vanished from the table of contents, but the door was unlocked. Always verify by typing the URL directly.
  • Static sites fundamentally have no “sneaky preview on production.” If you want a preview, build a separate environment. Feeling protected is the most dangerous state of all.

If you have an unfinished file sitting in your publish folder, go type its URL right now. If it returns 200, it’s no longer a draft. Stay safe out there, everyone.