DEV Community

Cover image for I went looking for a one-curl HTML host. I built one instead.
Maksim Kochetkov
Maksim Kochetkov

Posted on

I went looking for a one-curl HTML host. I built one instead.

I went looking for a one-curl HTML host. Everything was broken.

I tried to publish an HTML file from my phone last month. One file. Already written, already saved, just sitting in iCloud Drive waiting for a URL. Every "free HTML host" I found wanted signup, served broken MIME types, or had a homepage bragging about 130,000 publications that turned out to be a hardcoded number in the static HTML.

130,000?! On a service nobody I know has ever used.

I was scrolling in a coffee shop, asking my Claude Code agent to find me a host I could hit with one curl. The agent kept returning options, and I was already half-tired by then, so the bar for "good enough host" was on the floor. Each option had a wall. Signup form. OAuth. An OpenAPI spec that didn't match the actual endpoints. One site looked promising for about 90 seconds, until I viewed source and found that famous "130k pages" counter pasted in as a literal string. I closed the tab and stared at my phone for a while.

Side projects usually die. I've shipped enough of them to know the shape of that death. You build the thing, you post about it once, traffic goes to zero by week two, and the VPS bill keeps coming. So I almost didn't start this one. I almost killed it again around week three, when the page count was still in single digits and I was the only one publishing. I started it anyway because the alternative was building yet another GitHub Pages workflow with a custom domain and a build step for a single static file.

So I built it. Spring Boot 4, Kotlin, Postgres, one weekend of bad sleep.

We crossed 1,000 today. Here's the live counter.

The counter is at brewpage.app/#stats and it actually moves.

Live counter at 1,025 pages

Today: 1,025 pages created lifetime, 21k views total, 538 unique IPs over the last 7 days. About 18% of the real traffic comes from AI agents, mostly OpenAI fetchers pulling pages on behalf of users. The number you see on the homepage is read server-side from Postgres on every request, not hardcoded in a template. That was the whole point. If I am going to ship a counter, it has to count.

The first month was the long part. Zero users for ages. I hit /api/stats about 40 times that first week, watched the page count tick from 3 to 4 because I kept testing my own uploads. Then someone published a page. Then someone else. I told myself I'd stop checking. I didn't stop checking. And I'm not going to pretend I wasn't refreshing the dashboard at 11pm on a Tuesday.

I thought maybe 100 pages a year. Maybe. We got over a thousand in less than two months. I'm still figuring out what to do with that.

The one curl

This is the whole API for publishing HTML:

curl -X POST https://brewpage.app/api/html \
  -H 'Content-Type: application/json' \
  -d '{"html":"<h1>hello</h1>","namespace":"public","ttlDays":15}'
Enter fullscreen mode Exit fullscreen mode

Response:

{"url":"https://brewpage.app/public/abc123","ownerToken":"...","expiresAt":"2026-05-27"}
Enter fullscreen mode Exit fullscreen mode

That URL serves your HTML with the right content type. No build step, no preview environment, no Vercel project.

Three ways to send content depending on where you live:

  • REST API. Full OpenAPI spec via Scalar UI.
  • MCP server. brewpage-mcp on npm, so Claude Desktop or any MCP client can publish for you.
  • Claude Code skill. /brewdoc:publish from the brewcode plugin suite, skill docs here. This is what I use 90% of the time.

Turns out it hosts video too

The "one-curl HTML host" framing was the wedge. The endpoint quietly accepts other content too. JSON, plain text, markdown, raw uploads. Short videos are the part I didn't plan, and I only shipped them because I tried to put my own clip on my own host and it didn't work.

Had a short video I wanted to drop somewhere with one link. Same as the HTML case. Uploaded it. Hit the URL. Browser tried to fetch the whole file before playing, then choked. Streaming was just broken. The kind of thing you only find when you eat your own dogfood.

Spent an evening rewriting the response path to send proper byte-range responses, so a <video> tag scrubs without buffering the whole file. Works in the browser now, no plugin, no CDN, no second product.

Proof, my own clip on it: brewpage.app/public/hCC54Mm1ED. Same /public/ namespace as the HTML pages. The line between "HTML hosting" and "general content hosting" turned out blurrier than I thought when I named the project. Anyway.

That is the #showdev part. The rest is how it stays alive.

Anonymous by default. localStorage, not cookies.

Every publish returns a 32-character Base62 owner token. That token is your only proof of ownership. The browser stores it in localStorage. No email, no signup form, no session cookie, no GDPR banner.

Two visibility modes. Public pages go into a small indexed gallery on the homepage. Private pages get an EFF-wordlist style URL, something like metal-dragon-47, which is guessable enough to share over chat and obscure enough not to get crawled by accident.

On mobile, the homepage lets you flip through your own published pages without logging in. The localStorage token does the work. Clear your storage, lose your pages. That tradeoff is the deal, and I'm not putting a cookie banner on a one-curl host to pretend otherwise.

Keeping it un-spammy without becoming a gatekeeper

Three numbers that matter: 300 reads/min/IP, 60 uploads/hr/IP, default TTL 15 days with a 30-day max. A small PhishingChecker pass blocks javascript: and data: URIs in <a href> attributes before storage. I shipped that filter on day one and for almost two months it was the only thing standing between the homepage gallery and the open internet.

This morning the logs got noisy. One SEO bot, one IP, around 30 pages of generic backlink garbage in roughly two hours. First real spam wave. I watched the counter tick faster than humans publish, and honestly that one stung a bit, but also the project felt real in a way it hadn't been before. Somebody out there decided I was worth abusing.

Spent the next few hours shipping a content-pattern filter. Felt weirdly good to ship the filter the same day the spam hit, like the project finally had skin. Looks at template similarity across recent uploads from one IP and at link density inside the HTML body. Not perfect, bots will adapt, PhishingChecker still misses clever encodings sometimes. The rest of the detection logic stays vague on purpose. Scammers read dev.to too.

Sometimes a page deserves to outlive the TTL. The first long-tail user posted a chat-style apology page from Mumbai. Real conversation, real apology, person clearly meant it. I sat with my finger over the delete-after-expiry job for a minute and then... extended the expiry to the year 2099 by hand: https://brewpage.app/public/NnJR737yf8. Not a feature. Just a thing I did because the page felt like it should still exist in 2027.

Pasting Grafana panels into Claude (a habit, not a product)

The full observability stack has been running for 18 days now. Five containers: Prometheus, Loki, Promtail, cAdvisor, Grafana. Seven dashboards: business, traffic and uploads, HTTP, JVM, Caddy, Docker, Logs.

The daily ritual is dumber than it sounds. I open Grafana, find a panel that looks off, screenshot it, paste it into Claude Code, ask "what is this".

The response is usually a triage. "Those 503s at 14:22 line up with your deploy, not a real outage." "Those broken-HTML 400s are bot trash, ignore." "Memory dipped because the JVM did a G1 collection, you can stop staring at it."

It isn't an agent. There is no cron job watching Grafana on my behalf. No smart anomaly detector, no Slack webhook firing into a war room. I'm pasting screenshots into Claude like a caveman, twice a day, between two PRs, and that's enough to keep the service honest at this size.

Yeah. The 503 spike on every deploy genuinely bothers me though. It is tiny, it lasts maybe 4 seconds, and nobody has ever complained... but I see it in the dashboard every time and I know it is mine.

When the service grows, this stops working and something automated has to take over. For now, caveman flow.

Stack and what comes after 1,000

  • Backend: Spring Boot 4 + Kotlin 2.3
  • Data: Postgres 18 + MinIO for file storage
  • Frontend: vanilla JS, no React, no bundler, because load speed beats DX on a homepage you visit once
  • Edge: Caddy with automatic TLS

Roadmap is short. Kubernetes is next, because the current rolling updates on Docker Compose produce a handful of 503s every deploy and they show up in Grafana as a clean spike I can no longer ignore. Once that lands, the daily Grafana triage gets smaller, the deploy bothers go away, and I'll need a new thing to obsess over. There always is one.

Counter's at 1,025 as I publish this. We crossed a thousand today, and got the first real spam wave on the same day. Side projects usually die quiet. This one is getting noisy.

Top comments (0)