Skip to content
rsdlt.dev
Go back

Hono on Cloudflare Workers: Leaving a Docker VPS and Getting the Middleware Order Right

For its first year, Foambyte’s API ran the way many side projects do: Docker images, a Docker Swarm stack on a VPS, Traefik as the reverse proxy with automatic TLS, and a couple of replicas per service. It worked, and every hour I spent patching the host, rotating certificates or reading Traefik labels was an hour not spent on the product.

In February 2026 I moved the API to Hono on Cloudflare Workers. This post explains why, and walks through the middleware chain that every request goes through, because on a public API the order of middleware is a security decision.

Why Workers, and why Hono

Cloudflare Workers removed the parts of operations that didn’t differentiate the product:

Hono is the natural framework for that runtime. It’s small, fast, built on Web standards, and its middleware model is simple: middleware runs in the order you register it. Its @hono/zod-openapi integration gives me validated, documented routes (the contract post covers that side).

The one real refactor the move required: no global state. A Worker isolate can serve many requests and can disappear at any moment, so the database client and the auth instance stopped being module-level singletons and became request-scoped, built from c.env bindings. That change was the prerequisite for everything else.

The middleware chain

The principle behind the order is simple: reject cheaply, as early as possible. Checks that cost nothing run before checks that hit the database.

  1. Secure headers: HSTS, X-Content-Type-Options and friends on every response.
  2. Body size limit: Workers has no default request body limit, so the API sets one before anything parses a body.
  3. Global rate limiter: per client IP, using Cloudflare’s rate-limiting binding.
  4. Request ID and logging: the logger deliberately records no headers or query strings.
  5. Error handling: a central handler; clients never see internals.
  6. CORS: an explicit allow-list of origins with credentials.
  7. Authentication: better-auth handles /api/auth/*, with Cloudflare Turnstile CAPTCHA on sign-up, sign-in and password reset.
  8. Session lookup, then requireSession on everything under /api.
  9. Plan gating per route, with requireEntitlement.

Public routes such as the health check are mounted before the session requirement; everything else is protected by default. Forgetting to protect a new route isn’t possible, because protection isn’t opt-in.

Rate limiting that fails closed

In-memory rate limiters are a quiet bug on Workers: isolates don’t share memory, so each one counts separately and the limit is never really enforced. Foambyte uses Cloudflare’s rate-limiting binding instead, which counts at the edge across isolates.

The important design choice is what happens when the limiter isn’t available:

const limiter = c.env.FREE_USER_RATE_LIMITER;
if (!limiter) {
  if (!isDev) {
    c.header("x-rate-limiter-mode", "missing-binding-fail-closed");
    return c.json({ error: "Service temporarily unavailable" }, 503);
  }
  return next();
}

const result = await limiter.limit({ key: getClientIp(c) });

Outside development the API fails closed: a misconfigured deploy returns 503 instead of silently running without protection. A loud outage is easier to fix than an invisible hole.

Warning

Key the limiter on cf-connecting-ip, not X-Forwarded-For. The client controls X-Forwarded-For, so keying on it lets an attacker pick a fresh rate-limit bucket for every request.

CAPTCHA where bots actually go

Bots don’t browse batch pages; they hammer sign-up and password-reset endpoints. So the CAPTCHA lives exactly there, through better-auth’s captcha plugin with Cloudflare Turnstile:

captcha({
  provider: "cloudflare-turnstile",
  secretKey: turnstileSecret,
  endpoints: ["/sign-up/email", "/sign-in/email", "/request-password-reset"],
}),

Turnstile is invisible for most real users, which matters on a phone in a brewery.

Plan gating as middleware

Foambyte has free and paid plans, and some features (tank management, for example) belong to paid plans. Instead of scattering if (plan === ...) checks through handlers, a feature requirement is part of the route definition:

export function requireEntitlement(feature: keyof EntitlementFeatures) {
  return createMiddleware<AppBindings>(async (c, next) => {
    const user = c.get("user");
    if (!user?.id) return c.json({ message: "UNAUTHORIZED" }, 401);

    const entitlements = await getUserEntitlements(getDb(c.env), user.id);
    if (!entitlements.features[feature]) {
      return c.json({ message: "FORBIDDEN" }, 403);
    }
    await next();
  });
}
middleware: [requireSession, requireEntitlement("tankManagement")],

Two details make this hold up:

Takeaways


I’m Rodrigo Santiago, a product leader who builds. I co-founded Foambyte with my brother Diego, a brewmaster who leads the product as CPO, and I designed and built the platform: architecture, frontend, backend, mobile and infrastructure. If you need someone who can own both the product and the code, let’s talk on LinkedIn.


Share this post:

Previous Post
Migrating from SQLite to PlanetScale Postgres 18 and TimescaleDB on Cloudflare Workers
Next Post
End-to-End Type Safety Without tRPC: A Shared Hono + Zod OpenAPI Contract