Skip to content
jccbbb
Go back

How I Rebuilt Coretours on 100% Cloudflare

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

LayerChoice
Hosting + CDNCloudflare Pages
Server runtimeCloudflare Workers via @astrojs/cloudflare
FrontendAstro 5 (hybrid SSR + prerender) + Tailwind
APIHono-flavoured routes under /api/v1/ (Astro server endpoints)
DatabaseCloudflare D1 (SQLite, 15 tables)
Cache + sessionsCloudflare KV (2 namespaces: rate limit + session)
Object storageCloudflare R2 (coretours-storage)
Background jobsCloudflare Queues (coretours-jobs)
AuthJWT via Web Crypto API (HMAC-SHA256, no library)
ValidationZod 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:

  1. 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.
  2. 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:

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:

  1. 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.

  2. 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.

  3. 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-js at validation time — and rejects malformed numbers at the API boundary, not in Stripe.

  4. 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:

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.


Share this post:

Next Post
Astro + Cloudflare: My Preferred Stack for Modern Content Sites