|

Ship It — Deploying to Cloudflare Workers (Part 6/6)

We made it. Five parts of building, encrypting, testing, and polishing. Now let’s put this thing on the internet.

Deploying a full-stack application — SPA frontend plus edge Worker backend — in a single command sounds too good to be true. With Cloudflare Workers and Wrangler, it actually is that simple. Let me show you the whole pipeline from zero to live.

What we’re deploying

Let’s recap the moving parts:

  1. SPA (React + Vite) → built to /dist, served as static assets
  2. Worker (Hono) → thin Upstash auth proxy with SSE streaming
  3. Secrets → Upstash REST URL and token, stored in Cloudflare’s secret store
  4. Database → Upstash Redis (hosted separately, free tier)

The goal: one wrangler deploy command that builds the SPA, bundles the Worker, uploads both, and serves them from the same origin.

Step 1: Set up Upstash Redis

Head to upstash.com and create a free Redis database. You’ll need two pieces:

  • REST URLhttps://your-db.upstash.io
  • Token — a long alphanumeric string

The free tier gives you 10,000 commands per day. For a planning poker app, that’s enough for a team doing several sprint plannings a week. Each room lifecycle (create, join, vote, reveal, leave) uses roughly 10-20 commands per player.

Step 2: Store secrets in Cloudflare

Secrets are injected at runtime — they never appear in your code, your config files, or your git history:

npx wrangler secret put UPSTASH_URL
# paste your URL when prompted

npx wrangler secret put UPSTASH_TOKEN
# paste your token when prompted

These are stored encrypted in Cloudflare’s infrastructure and are only available to your Worker at runtime via env.UPSTASH_URL and env.UPSTASH_TOKEN. They don’t exist in wrangler.jsonc, they don’t exist in .env, they don’t exist anywhere a human or a CI pipeline can accidentally expose them.

Step 3: Understand the wrangler config

{
  "name": "planning-poker",
  "main": "worker/src/index.ts",
  "compatibility_date": "2025-01-01",
  "compatibility_flags": ["nodejs_compat"],
  "assets": {
    "directory": "./dist",
    "not_found_handling": "single-page-application",
    "binding": "ASSETS"
  },
  "observability": { "enabled": true },
  "build": {
    "command": "npm install --prefix worker && pnpm build"
  }
}

Let’s break this down:

main: "worker/src/index.ts" — The Worker entry point. Wrangler bundles this with esbuild before deploying.

assets.directory: "./dist" — The Vite build output. These files get uploaded to Cloudflare’s edge and served by the ASSETS binding.

assets.not_found_handling: "single-page-application" — This tells Cloudflare: “If a request doesn’t match a static file, serve index.html.” Critical for React Router — without this, navigating to /planning-poker/room/abc123 directly would 404.

assets.binding: "ASSETS" — Makes the static assets available as a Cloudflare binding. The Worker can call env.ASSETS.fetch(req) to programmatically serve static files (which it does for the SPA fallback and cache control).

build.command — Runs automatically before wrangler deploy. It installs the Worker’s own dependencies (npm install --prefix worker) and builds the SPA (pnpm build). One command does everything.

observability.enabled: true — Turns on Cloudflare’s built-in Worker analytics. Request counts, error rates, latency — without adding any instrumentation code.

Step 4: The build pipeline

When wrangler deploy runs, here’s what happens:

1. wrangler runs the build command:
     npm install --prefix worker   → installs Hono + hono/cors in worker/
     pnpm build                    → tsc + vite build → /dist

2. wrangler bundles worker/src/index.ts with esbuild
     → single JS file for the Worker

3. wrangler uploads:
     → Worker bundle    → Cloudflare edge (global)
     → /dist/* assets   → Cloudflare edge (global)

4. The Worker is live at:
     https://planning-poker.oltionzefi.workers.dev

The pnpm build step runs tsc && vite build — TypeScript type-checking first (catch errors before they ship), then Vite’s production build with tree-shaking, minification, and code-splitting.

Step 5: Deploy

npx wrangler deploy

That’s it. Seriously. One command. The output looks something like:

🌀 Building...
✨ Compiled Worker successfully
📦 Published planning-poker (2.3 seconds)
  planning-poker.oltionzefi.workers.dev

The app is live. Open the URL. Create a room. Share the link. Estimate some stories.

Local development flow

For day-to-day development, you don’t deploy to Cloudflare. You run locally with direct Upstash access:

# .env (git-ignored)
VITE_UPSTASH_URL=https://your-db.upstash.io
VITE_UPSTASH_TOKEN=your-token
VITE_DEBUG_LOGGING=true
pnpm dev

This starts Vite’s dev server at http://localhost:5173. The adapter auto-detects the VITE_UPSTASH_* variables and switches to direct mode — the browser talks to Upstash directly, no Worker needed.

The VITE_DEBUG_LOGGING=true flag enables the logger utility, giving you detailed console output for every SSE message, state update, and encryption operation. Super helpful for debugging real-time sync issues.

Environment variable summary

Here’s the complete picture:

VariableWherePurpose
VITE_UPSTASH_URL.env (local dev only)Direct Upstash REST URL
VITE_UPSTASH_TOKEN.env (local dev only)Direct Upstash token
VITE_WORKER_URL.env (optional)Override Worker base URL
VITE_BASE_URL.env (optional)Base path (default /)
VITE_DEBUG_LOGGING.env (dev only)Enable verbose console output
UPSTASH_URLwrangler secretUpstash REST URL for the Worker
UPSTASH_TOKENwrangler secretUpstash token for the Worker

Production has zero VITE_* variables. The SPA detects same-origin deployment and routes everything through /upstash. Clean.

Keeping things clean: the pre-commit workflow

Before deploying, I always run:

pnpm format        # Prettier — consistent code style
pnpm lint          # ESLint — zero warnings
pnpm test          # Vitest — all tests pass
pnpm build         # TypeScript + Vite — no type errors

And if you push to GitHub, the CI pipeline (ci.yml) runs format:check, lint, and test automatically on every push and PR. Nothing broken merges to main.

Dependabot

The project uses GitHub’s Dependabot to keep dependencies fresh:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"

Weekly PRs for outdated packages. Merge the ones that pass CI, review the ones that don’t. Low effort, big payoff for security patches.

Cost breakdown

This entire stack costs $0 for a small team:

ServiceFree TierEnough for…
Cloudflare Workers100K requests/day~50 sprint plannings/day
Upstash Redis10K commands/day~10 concurrent rooms
GitHub Actions2K minutes/month~200 CI runs/month

No credit card required for any of these. Seriously.

If you outgrow the free tier (congrats!), Cloudflare Workers Paid is $5/month for 10M requests. Upstash Pay-as-you-go is $0.2 per 100K commands. Still basically free.

What we built — the full picture

Let’s zoom out and appreciate what we’ve created over these 6 parts:

🃏 A real-time planning poker app with Fibonacci, T-Shirt, Powers of 2, and custom decks

🔐 End-to-end encrypted with AES-GCM-256 — the server literally cannot read your data

Instant updates via SSE streaming through a Cloudflare Worker

🎭 Smart roles — facilitator, voter, observer, with automatic handoff

Countdown timer with auto-reveal and preset chips

📊 Live results with vote distribution charts and consensus detection

📜 Session history — every round archived in your browser session

🔗 One-click sharing — encryption key embedded in the URL hash

🧪 Tested — Vitest + Testing Library with 80% coverage thresholds

🚀 Deployed — one wrangler deploy to 300+ edge locations worldwide

🆓 Free — no accounts, no sign-up, no cost

The entire codebase is about 2,500 lines of TypeScript across the SPA and Worker. No server-side business logic. No database migrations. No user management. Just a browser, some crypto, and a thin pipe to Redis.

What I’d add next

If I were to keep building:

  • Custom timer durations — let the facilitator type an arbitrary number of seconds
  • Spectator count — show how many observers are watching
  • Export session history — download as CSV or JSON
  • Sound effects — a subtle ding when all votes are in 🔔
  • Custom themes — let teams pick their accent color

But honestly? It works. The team uses it. The data is private. And it costs nothing.

Try it yourself

The app is live at planning-poker.oltionzefi.workers.dev.

Open it. Create a room. Share the link. Estimate some stories. If it works for your team — and I think it will — you just got a free, encrypted, real-time planning poker tool. No strings attached.

Keep pushing forward and savor every step of your coding journey.