A few weeks ago I shipped v3 of Coretours — the travel agency I run that organises festival trips to events like Defqon.1, Tomorrowland, and Thunderdome. The previous version was a WooCommerce site on a managed PHP host. The new one is one provider, end-to-end: Cloudflare.
This is the concrete companion to Why I’m Building Cloudflare-Native Systems. That post argues the constraint. This post is what the constraint actually buys you in production.
Table of contents
Open Table of contents
The stack, in one line
| Layer | Choice |
|---|---|
| Hosting + CDN | Cloudflare Pages |
| Server runtime | Cloudflare Workers via @astrojs/cloudflare |
| Frontend | Astro 5 (hybrid SSR + prerender) + Tailwind |
| API | Hono-flavoured routes under /api/v1/ (Astro server endpoints) |
| Database | Cloudflare D1 (SQLite, 15 tables) |
| Cache + sessions | Cloudflare KV (2 namespaces: rate limit + session) |
| Object storage | Cloudflare R2 (coretours-storage) |
| Background jobs | Cloudflare Queues (coretours-jobs) |
| Auth | JWT via Web Crypto API (HMAC-SHA256, no library) |
| Validation | Zod everywhere, phone numbers via libphonenumber-js |
| Payments (external) | Stripe |
| Transactional email (external) | Resend |
| AI chat (external) | OpenAI GPT-4 |
The honest framing: everything that runs my code, holds my data, or serves a byte is Cloudflare. The three external APIs (Stripe, Resend, OpenAI) live behind Worker fetches — they’re called from inside the Cloudflare boundary, not parts of the infra surface I have to operate. One bill for infra. One dashboard.
What this stack made possible
1. A booking endpoint that finishes faster than the old SSL handshake
The whole booking flow — validate phone, check capacity, create customer if new, write booking + passengers, update trip count, enqueue confirmation email — happens inside one Worker call:
// src/pages/api/v1/bookings/index.ts
export const POST: APIRoute = withErrorHandling(async ({ locals, request }) => {
const db = getDB(locals);
const body = await parseBody(request, createBookingSchema);
return db.batch([
db.prepare("INSERT INTO customers ...").bind(...),
db.prepare("INSERT INTO bookings ...").bind(...),
db.prepare("INSERT INTO passengers ...").bind(...),
db.prepare("UPDATE trips SET booked_count = booked_count + ?").bind(...),
]).then(async () => {
await locals.runtime.env.QUEUE.send({ type: "booking.confirmation", id });
return created({ booking_number, ... });
});
});
Two things matter here:
db.batch()runs the four statements in a single round-trip to D1 — no per-statement latency. On the old WP stack this was four MySQL queries to a Stockholm DB from a Dublin app server, plus a transaction lock, plus an email send blocking the response.- The queue handles confirmation email async. The HTTP response returns before Resend gets touched. If Resend is slow, the user doesn’t feel it.
Median booking-create response is now ~80ms p50 from inside Sweden. The old PHP/Woo flow was 1.2s on a good day.
2. KV-based rate limiting with zero infrastructure
Rate limiting is the canonical “do we need Redis?” problem. With Workers you don’t. The middleware checks KV with a per-IP key + 60-second TTL:
// src/middleware/index.ts (sketch)
const key = `rl:${ip}:${Math.floor(Date.now() / 60000)}`;
const count = parseInt((await env.KV.get(key)) ?? "0") + 1;
await env.KV.put(key, String(count), { expirationTtl: 60 });
if (count > 100) return new Response("Too many requests", { status: 429 });
KV is eventually consistent — fine for rate limiting, because allowing one extra request through a race is harmless. The same KV namespace doubles as session storage with a 5-minute TTL on cached API responses. Two use cases, one binding, no Redis cluster to operate.
3. Background jobs that survive the Worker dying mid-request
Confirmation emails, WooCommerce sync, OpenAI-generated trip recommendations — anything that takes longer than ~50ms goes onto Queues. The consumer is just another Worker:
export default {
async queue(batch: MessageBatch, env: Env) {
for (const msg of batch.messages) {
switch (msg.body.type) {
case "booking.confirmation":
await sendConfirmationEmail(msg.body.id, env);
break;
case "woo.sync":
await syncWooProduct(msg.body.id, env);
break;
}
msg.ack();
}
},
};
A failure on one message doesn’t poison the rest. Retries are built in. Max batch size 10, 30-second timeout. The previous version of this was a cron job on the PHP host that ran every 10 minutes and silently failed when SMTP was flaky.
What I kept external — and why that’s OK
Three SaaS APIs are still in the loop:
- Stripe — payments. PCI compliance isn’t a problem I want to own; Stripe handles card data, I just handle the
clientSecret. Webhooks come back into a Worker (/api/v1/webhooks/stripe) which verifies the signature and updates the booking. - Resend — transactional email. Cloudflare’s own Email Workers + MailChannels combo works, but Resend has better deliverability reports and a cleaner template API for the kind of multi-language confirmation emails we send. Pragmatic call.
- OpenAI — the AI chatbot on the booking page. Workers AI is genuinely close on model quality now, and I’ll likely migrate the chatbot to Workers AI in v3.1. For launch, OpenAI was the path of least resistance.
The “100% Cloudflare” claim is honest about scope: all the infrastructure my code runs on is Cloudflare. The SaaS APIs my code calls are not. That’s a defensible boundary — these are products I rent, not services I operate.
The migration: what was actually hard
The interesting failures, in order:
-
The product schema didn’t map. WooCommerce treats a “trip” as a product with variations (bus pickup, hotel package, accommodation). I unrolled this into 4 tables (
trips,pickup_points,bookings,passengers). The WooCommerce sync script (scripts/sync-woocommerce.js) still runs nightly so existing customers on the old admin see the same numbers. -
Stripe expects you to know the customer. WP stored customers as users; I had to design the customer table from scratch with a verification flow, password reset tokens, and an idempotent “create-or-find by email” path used by the booking endpoint.
-
Phone numbers. Customers fly to festivals from across Europe. The old site stored phones as strings. The new schema normalises everything to E.164 via
libphonenumber-jsat validation time — and rejects malformed numbers at the API boundary, not in Stripe. -
Swedish error messages. Coretours is a Swedish-language site. Every Zod schema returns
message: 'Ogiltig e-postadress'instead of'Invalid email'. Tedious, but the only invisible quality bar that customers actually notice.
The total rebuild was ~18,800 lines of TypeScript and Astro across 130-odd files. ~3 weeks of focused work, including the WooCommerce data migration.
What this case proves
The argument from Why I’m Building Cloudflare-Native Systems was: every new project that fits the Cloudflare envelope should run there because the compound effect on operational simplicity is enormous.
Coretours v3 is the test. Real customers, real money, real SLAs. After the rebuild:
- One bill for hosting + DB + CDN + storage + queues + cache. The WP host alone was more expensive than the entire Cloudflare stack now.
- One dashboard to check why something’s slow. The old stack had four different log destinations.
- One mental model for “where does this code run?” — at the edge, near the user.
- Deploy is 11 seconds end-to-end:
npm run build && wrangler pages deploy. The old WP site was 25 minutes of “is the FTP done yet.”
The next case I’m writing up is the multi-tenant event-ops platform underneath some of these festivals. Same playbook, more bindings, ten more workspaces.
If you’re staring at a WooCommerce site or a Heroku monolith wondering whether the migration is worth it: the answer depends entirely on how much you value operational simplicity. If “one bill, one log stream, one runtime” sounds like a competitive advantage to you, the answer is yes. If it sounds like a rounding error, stay where you are.
For the Coretours team, it was the difference between shipping features and babysitting infrastructure.