Adding a serverless backend to Hugo: How I built payment flows without losing static site speed
You know how it goes. You build a Hugo site, it’s blazing fast, and then you add a services page. Now you need payments. Webhooks. Email confirmations. Suddenly your “static site” needs a backend.
I run vitthalmirji.com on Hugo. For a while I used pre-created PayU/Cashfree links stored in YAML. It worked, but it
was slow and error‑prone. Every new service meant logging into dashboards, creating links manually, and copying URLs.
I wanted a real flow:
- create links dynamically,
- accept webhooks safely,
- store transaction history for accounting,
- keep the static site fast.
This post is how I did it without turning Hugo into a server app.
Why Hugo can’t do this
Hugo is a static site generator. It renders Markdown into HTML at build time. When a user visits your site, they’re just getting cached HTML files from a CDN. There’s no long‑running process that can call a payment API or accept webhooks.
That’s why Hugo sites are fast. But it’s also why they can’t:
- run server-side logic at request time,
- receive payment gateway callbacks,
- store anything dynamic.
So the real question is not “how do I make Hugo do payments?” It’s: how do I add a backend without ruining the static site?
The options (and why most don’t work)
I looked at four ways to “bolt on” a backend.
Option 1: Build-time data fetching
Hugo can fetch APIs at build time with resources.GetRemote:
| |
What’s happening:
- API fetch happens during
hugo build. - Data is baked into static HTML.
- Page loads never hit the API.
This works for content that changes daily. It fails for payment links that need to be created per customer and webhooks that arrive after the build.
Option 2: Third‑party form services
Formspree, Netlify Forms, and friends are fine for a contact form. They aren’t payment backends. No webhook signature validation, no state machine, no transaction audit trail.
Option 3: A full backend server
Yes, you can run a Go/Node server. But for a personal services page, it’s overkill:
- patching and security updates,
- scaling and health checks,
- long‑running processes,
- infrastructure overhead for a tiny API.
Option 4: Serverless functions
Serverless functions are the sweet spot. You write a few endpoints, deploy them, and let the platform run them on demand.
This is where Cloudflare Workers fit best for me: same vendor as my CDN/DNS, low cold‑start time, and easy routing from
/api/*.
The approach I settled on
I kept the static site as‑is, and added a tiny backend that does only the dynamic parts. Two deployments, one repo, routed together at the edge.
The architecture (two parts, one codebase)
flowchart TB
subgraph BROWSER["User’s browser"]
USER["User visits site"]
end
subgraph EDGE["Cloudflare edge"]
ROUTER["Route by path"]
end
subgraph STATIC["Static site (GitHub Pages)"]
HTML["Hugo HTML + CSS + JS"]
end
subgraph API["Workers backend"]
WORKER["/api/* endpoints"]
end
subgraph STORAGE["Cloudflare storage"]
D1["D1 (SQLite)"]
R2["R2 (files)"]
end
subgraph EXTERNAL["External services"]
PAYU["PayU"]
CASHFREE["Cashfree"]
RESEND["Resend email"]
end
USER --> ROUTER
ROUTER -->|" vitthalmirji.com/* "| HTML
ROUTER -->|" vitthalmirji.com/api/* "| WORKER
WORKER --> D1
WORKER --> R2
WORKER --> PAYU
WORKER --> CASHFREE
WORKER --> RESEND
style USER fill: #e1f5ff, stroke: #0066cc, color: #000
style ROUTER fill: #fff4e1, stroke: #cc8800, color: #000
style HTML fill: #e1ffe1, stroke: #2d7a2d, color: #000
style WORKER fill: #f0e1ff, stroke: #8800cc, color: #000
style D1 fill: #e1f5ff, stroke: #0066cc, color: #000
style R2 fill: #e1f5ff, stroke: #0066cc, color: #000
style PAYU fill: #ffe1e1, stroke: #cc0000, color: #000
style CASHFREE fill: #ffe1e1, stroke: #cc0000, color: #000
style RESEND fill: #ffe1e1, stroke: #cc0000, color: #000
Static stays on GitHub Pages. Dynamic calls go to Workers. Cloudflare routes the two behind the same domain.
Why I chose Cloudflare Workers
I was already using Cloudflare for DNS and CDN proxy, so Workers gave me:
- No new vendor for hosting.
- Simple routing from
/api/*. - Edge execution close to users.
- V8 isolate cold starts that feel instant.
Here’s the quick comparison I used:
| Platform | Cold start | Operational overhead | Fit for tiny APIs |
|---|---|---|---|
| Cloudflare Workers | Very low | Low | Excellent |
| Netlify Functions | Medium | Low | Good |
| Vercel Functions | Medium | Low | Good |
| AWS Lambda | Variable | High | Overkill for small sites |
If a user is about to pay, I want the gateway call to be the slow part, not my runtime waking up.
The backend surface area
I forced myself to keep this tiny. The API is six endpoints.
| Endpoint | Method | Caller | Protection | Purpose |
|---|---|---|---|---|
/api/health | GET | Anyone | None | Health + DB init guard |
/api/payment/create-link | POST | Browser | Rate limiting (edge) | Create PayU/Cashfree link |
/api/card/verify/session | POST | Browser | Rate limiting (edge) | ₹1 card verification session |
/api/contact/submit | POST | Browser | Rate limiting (edge) | Contact form |
/api/payment/webhook/payu | POST | PayU | Signature | Payment confirmation |
/api/payment/webhook/cashfree | POST | Cashfree | Signature | Payment confirmation + card verify |
That’s the full backend. No user auth, no carts, no subscriptions. Just what the site needs.
Why I used Effect TypeScript
Traditional serverless code hides errors inside try/catch. That’s risky for payment flows.
Effect makes error types explicit and forces you to handle them. This matters when failures mean lost money.
The contrast
Promise‑style:
| |
Effect‑style:
| |
What’s happening:
- The type signature lists possible errors.
- The compiler forces you to handle all of them.
- Dependencies are injected via
Context.Tag.
This is boring in the best way. Payments shouldn’t be “hope and pray” code.
Details
The database: auto‑init with a guard
I still use CREATE TABLE IF NOT EXISTS, but I don’t run it on every request anymore. I added a one‑time guard so
only the first request in an isolate runs schema setup.
| |
What’s happening:
- First request triggers DB setup.
- Concurrent requests wait on the same promise.
- Later requests skip it entirely.
This keeps the “self‑healing” property without paying the cost on every call.
CREATE TABLE IF NOT EXISTS won’t
magically backfill it.
For schema evolution, you still want real D1 migrations (ALTER TABLE, backfills, indexes).Details
| |
The schema I ended up with
At minimum, I need these tables:
transactionsfor payment links + statuswebhook_eventsfor idempotency and audit trailservicesfor service definitions and pricingcontact_submissionsfor the contact formcard_verificationsfor ₹1 pre‑auth + void tracking
That last table is important. Card verification is a separate flow and deserves its own audit trail.
Schema snapshot (actual SQL)
Here’s the shape I’m actually running in D1 (trimmed to the essentials):
| |
What’s happening here:
transactionsis the core ledger for payment links + status.webhook_eventsis my idempotency wall for gateway retries.serviceslets the backend own pricing (not the frontend).contact_submissionsis a small audit trail (no spam loss).card_verificationsisolates ₹1 pre‑auth from real payments.
Details
| |
This is the exact schema and index set from the migrations (initial schema + later ALTER TABLE for expires_at). It’s
long, but it’s the truth on disk.
The data model (visual)
erDiagram
TRANSACTIONS {
text id PK
text txn_id UK
text service_key
integer amount
text currency
text gateway
text status
text customer_email
text customer_name
text gateway_response
text payment_url
text idempotency_key UK
integer email_sent_at
integer expires_at
integer created_at
integer updated_at
}
WEBHOOK_EVENTS {
text id PK
text event_id UK
text gateway
text txn_id
text payload_hash
text status
integer received_at
}
SERVICES {
text key PK
text name
text description
integer price_inr
boolean enabled
integer created_at
}
CONTACT_SUBMISSIONS {
text id PK
text name
text email
text message
text ip_hash
integer created_at
}
CARD_VERIFICATIONS {
text id PK
text order_id UK
text gateway
integer amount
text currency
text status
text customer_email
text customer_name
text customer_phone
text cf_payment_id
text gateway_response
integer created_at
integer updated_at
integer voided_at
}
SERVICES ||--o{ TRANSACTIONS: priced_by
TRANSACTIONS ||--o{ WEBHOOK_EVENTS: updated_by
CARD_VERIFICATIONS ||--o{ WEBHOOK_EVENTS: audit
What’s happening here:
servicesis the source of truth for pricing.transactionsare payment link sessions.expires_atmakes link reuse deterministic (don’t hand out stale links).webhook_eventsgive me idempotency + audit.card_verificationsare isolated from real money flows.
Payment links: dynamic creation
This is the main endpoint the front‑end calls.
Two small UX details that matter more than they sound:
- I set the link expiry to 15 minutes and also append a human hint like “expires in 15 minutes” in the gateway description/purpose.
- I pass customer name/email/phone in the link creation request so the gateway page can prefill.
Payment link lifecycle (visual)
flowchart TD
START["Browser submits /api/payment/create-link"] --> EDGE["Cloudflare rate limit (zone rule)"]
EDGE --> VALIDATE["Validate input (name/email/phone)"]
VALIDATE --> SERVICE["Load service + price from D1"]
SERVICE --> IDEMPOTENT{"Idempotency key exists?"}
IDEMPOTENT -->|Yes| CACHED["Return cached payment_url"]
IDEMPOTENT -->|No| REUSE{"Recent unexpired link (5-15m)?"}
REUSE -->|Yes| REUSED["Return recent payment_url"]
REUSE -->|No| GATEWAY["Select gateway (PayU/Cashfree)"]
GATEWAY --> LINK["Create link (expires in 15 min)"]
LINK --> STORE["Store transaction (expires_at) in D1"]
STORE --> RETURNURL["Set return URLs to /api/payment/return/*"]
STORE --> RESP["Return payment_url + link_id"]
style START fill: #e1f5ff, stroke: #0066cc, color: #000
style EDGE fill: #fff4e1, stroke: #cc8800, color: #000
style VALIDATE fill: #fff4e1, stroke: #cc8800, color: #000
style IDEMPOTENT fill: #fff4e1, stroke: #cc8800, color: #000
style CACHED fill: #e1ffe1, stroke: #2d7a2d, color: #000
style SERVICE fill: #f0e1ff, stroke: #8800cc, color: #000
style REUSE fill: #fff4e1, stroke: #cc8800, color: #000
style REUSED fill: #e1ffe1, stroke: #2d7a2d, color: #000
style GATEWAY fill: #f0e1ff, stroke: #8800cc, color: #000
style LINK fill: #f0e1ff, stroke: #8800cc, color: #000
style STORE fill: #e1f5ff, stroke: #0066cc, color: #000
style RETURNURL fill: #f0e1ff, stroke: #8800cc, color: #000
style RESP fill: #e1ffe1, stroke: #2d7a2d, color: #000
What’s happening here:
- Validation happens before any gateway call.
- Idempotency short‑circuits duplicates.
- Only the last step returns the link.
| |
What’s happening:
- Validate input.
- Check idempotency key.
- Fetch service price from D1.
- Create a link in PayU or Cashfree.
- Store the transaction.
This is why Effect helps: every failure case is explicit.
Payment UX: keep the user calm
The technical part is only half the job. The other half is: don’t make the user wonder if their payment “worked”.
Here’s what I ended up doing:
- A dedicated
/paypage that collects name/email/phone (so the gateway can prefill). - A visible 15-minute countdown while the link is being created.
- A
/pay/statuspage that shows whatever the gateway returns on redirect (without exposing PII in query params).
Redirect loop (visual)
sequenceDiagram
participant Browser
participant Worker as Workers API
participant Gateway as PayU/Cashfree
participant Status as /pay/status
Browser ->> Worker: POST /api/payment/create-link
Worker -->> Browser: payment_url (expires in 15 min)
Browser ->> Gateway: Redirect to payment_url
Gateway -->> Worker: Return callback (GET or POST)
Worker -->> Browser: 303 redirect to /pay/status (GET)
Browser ->> Status: GET /pay/status?gateway=...&status=...
What gets prefilled (and what doesn’t)
Prefilled: customer name, email, phone. That means the gateway page feels “connected” to your site, not like a random checkout link.
Not included in redirects: email/phone/name.
Return URLs carry only identifiers like gateway, service, link_id / invoice / order_id.
Details
Webhooks: idempotent and state‑safe
Gateways retry webhooks. Some send duplicates. I store every webhook event with a hash and skip duplicates.
Webhook verification + processing (visual)
flowchart TD
START["Webhook arrives"] --> PARSE["Parse body"]
PARSE --> SIGN{"Signature valid?"}
SIGN -->|No| REJECT["Reject (401)"]
SIGN -->|Yes| DEDUPE{"Duplicate event?"}
DEDUPE -->|Yes| OK["Return 200 (already processed)"]
DEDUPE -->|No| STORE["Store webhook event (hash)"]
STORE --> LOAD["Load transaction / verification"]
LOAD --> STATE{"Valid state transition?"}
STATE -->|No| BAD["Reject (400)"]
STATE -->|Yes| UPDATE["Update status in D1"]
UPDATE --> SIDEFX["Side effects (email / void)"]
SIDEFX --> DONE["Return 200 OK"]
style START fill: #e1f5ff, stroke: #0066cc, color: #000
style PARSE fill: #fff4e1, stroke: #cc8800, color: #000
style SIGN fill: #fff4e1, stroke: #cc8800, color: #000
style REJECT fill: #ffe1e1, stroke: #cc0000, color: #000
style DEDUPE fill: #fff4e1, stroke: #cc8800, color: #000
style OK fill: #e1ffe1, stroke: #2d7a2d, color: #000
style STORE fill: #e1f5ff, stroke: #0066cc, color: #000
style LOAD fill: #f0e1ff, stroke: #8800cc, color: #000
style STATE fill: #fff4e1, stroke: #cc8800, color: #000
style BAD fill: #ffe1e1, stroke: #cc0000, color: #000
style UPDATE fill: #f0e1ff, stroke: #8800cc, color: #000
style SIDEFX fill: #f0e1ff, stroke: #8800cc, color: #000
style DONE fill: #e1ffe1, stroke: #2d7a2d, color: #000
What’s happening here:
- Signature check is first to block spoofed payloads.
- Idempotency check stops retries from double-processing.
- State machine prevents invalid transitions.
| |
For payments, I also enforce a state machine:
| |
That prevents nonsense like success -> pending if a gateway sends weird retries.
Transaction state machine (visual)
stateDiagram-v2
[*] --> created
created --> pending
created --> expired
pending --> success
pending --> failed
pending --> expired
success --> refunded
Why this matters:
- Webhooks are messy; gateways retry with different statuses.
- The state machine blocks invalid transitions by design.
Card verification: ₹1 pre‑auth + auto‑void
I needed a “verify card” flow that charges ₹1 and reverses it automatically. The Payment Links API doesn’t do that, so I added a separate Orders flow in Cashfree.
The flow
sequenceDiagram
participant Browser
participant Worker
participant Cashfree
participant D1
Browser ->> Worker: POST /api/card/verify/session
Worker ->> Cashfree: Create order (₹1, cc+dc)
Cashfree -->> Worker: payment_session_id
Worker ->> D1: insert card_verification
Worker -->> Browser: payment_session_id
Cashfree ->> Worker: webhook (AUTHORIZED/SUCCESS)
Worker ->> D1: update status
Worker ->> Cashfree: VOID authorization
Cashfree -->> Worker: void result
Worker ->> D1: mark voided
The endpoint
| |
What’s happening:
- The browser gets a payment_session_id, not a link.
- The webhook updates status and immediately voids successful auths.
- All records are stored in
card_verifications.
Storage choices (what goes where)
I kept storage simple and explicit.
flowchart TD
START["What are you storing?"] --> Q1{"Need SQL + ACID?"}
Q1 -->|Yes| D1["D1 (transactions, services, webhooks)"]
Q1 -->|No| Q2{"Binary files?"}
Q2 -->|Yes| R2["R2 (PDFs, files)"]
Q2 -->|No| Q3{"Read-heavy cache?"}
Q3 -->|Yes| KV["KV (sessions, cached config)"]
Q3 -->|No| WARN["Don’t use KV for counters"]
style START fill: #e1f5ff, stroke: #0066cc, color: #000
style Q1 fill: #fff4e1, stroke: #cc8800, color: #000
style Q2 fill: #fff4e1, stroke: #cc8800, color: #000
style Q3 fill: #fff4e1, stroke: #cc8800, color: #000
style D1 fill: #e1ffe1, stroke: #2d7a2d, color: #000
style R2 fill: #e1ffe1, stroke: #2d7a2d, color: #000
style KV fill: #e1ffe1, stroke: #2d7a2d, color: #000
style WARN fill: #ffe1e1, stroke: #cc0000, color: #000
I don’t use KV for rate limiting. It’s eventually consistent, so counters are wrong during spikes. WAF rules are better for edge‑level protection.
The anti‑abuse layer
Public endpoints attract bots. I layered protection:
- Cloudflare rate limiting (zone rules) on public POST endpoints.
- Idempotency + reuse guards inside the backend.
- Turnstile (optional) if you can afford UX friction.
This is the bare minimum. It’s enough for a services page without bringing in Durable Objects.
Anti‑abuse flow (visual)
flowchart TD
REQ["Public POST request"] --> WAF["Edge rate limit (Cloudflare)"]
WAF -->|Throttled| THROTTLE["Reject request (429)"]
WAF -->|OK| MAYBE["Optional Turnstile (feature flag)"]
MAYBE -->|Disabled| IDEMP["Idempotency + reuse guards (payments only)"]
MAYBE -->|Enabled + fail| BLOCK["Reject request (403)"]
MAYBE -->|Enabled + pass| IDEMP["Idempotency + reuse guards (payments only)"]
IDEMP -->|Duplicate| CACHED["Return cached response"]
IDEMP -->|Fresh| PROCESS["Process request"]
style REQ fill: #e1f5ff, stroke: #0066cc, color: #000
style WAF fill: #fff4e1, stroke: #cc8800, color: #000
style THROTTLE fill: #ffe1e1, stroke: #cc0000, color: #000
style MAYBE fill: #fff4e1, stroke: #cc8800, color: #000
style BLOCK fill: #ffe1e1, stroke: #cc0000, color: #000
style IDEMP fill: #f0e1ff, stroke: #8800cc, color: #000
style CACHED fill: #e1ffe1, stroke: #2d7a2d, color: #000
style PROCESS fill: #e1ffe1, stroke: #2d7a2d, color: #000
What’s happening here:
- Rate limiting blocks cheap abuse at the edge.
- Backend guards keep link creation deterministic (idempotency, reuse window, expires_at).
- Turnstile stays available, but it’s not the only line of defence.
Deployment: the real version
Two pipelines. Same repo. Two deploys.
1) Static site (GitHub Pages)
- GitHub Actions builds Hugo
- Output is published to GitHub Pages (
vim89/vim89) - Cloudflare proxies it under
vitthalmirji.com
2) Workers backend
- Deployed from
workers/vianpm run deploy:production - Uses
workers/wrangler.toml - Routes
/api/*to the Worker
Centralize config in one place so you don’t scatter IDs and secrets across docs.
Deployment “grid” (who does what)
| Part | Where it runs | What triggers deploy | Tooling |
|---|---|---|---|
| Static site | GitHub Pages | GitHub Actions | Hugo + Pagefind |
| Backend API | Cloudflare Workers | Manual (from workers/) | Wrangler |
I kept Workers deploy manual on purpose. It avoids accidental API pushes on content‑only changes.
CI/CD excerpt (static site pipeline)
This is the actual GitHub Actions flow that builds and publishes the static site. I’ve kept the InfinityFree steps in the workflow for history, but they’re disabled by a flag and never run by default.
| |
What’s happening here:
- It builds Hugo with a pinned version from
.github/hugo.env. - It runs tests and Pagefind for search.
- It publishes to GitHub Pages (external repo).
- InfinityFree deploy steps are present but gated by
ENABLE_INFINITYFREE_DEPLOY=false.
Details
| |
What I’m not building (scope matters)
Not building:
- carts or subscriptions,
- user accounts,
- refunds and disputes,
- anything that needs long‑running jobs.
Building:
- payment links,
- webhooks,
- logs and audit trails,
- a clean place for the contact form.
Scope discipline is why this stays manageable.
The honest tradeoffs
What you lose
- Debugging is harder: you tail logs, you don’t step‑debug in prod.
- Vendor coupling: D1/R2 are Cloudflare‑specific.
- Limits exist: CPU time and DB size caps mean you can’t build everything here.
What you gain
- No server ops: deploy and forget.
- Fast by default: requests run at the edge.
- Safer code: error handling is forced at compile time.
- Minimal moving parts: fewer systems to keep alive.
What I’d do differently next time
- Start with a scope doc instead of tweaking as I go.
- Separate payment links and card verification earlier (it affects data model).
- Add the smoke‑test script on day one, not at the end.
TL;DR
- Hugo is static HTML. Payments and webhooks need a backend.
- The clean split is: GitHub Pages for static, Workers for
/api/*. - Effect TypeScript makes errors explicit and forces proper handling.
- D1 can bootstrap schema once per isolate, but schema changes still need migrations (
ALTER TABLE, backfills, indexes). - Card verification is a separate Cashfree Orders flow (₹1 pre‑auth + auto‑void).
- Payment links expire in 15 minutes; store
expires_atand reuse links only within a safe window. - For abuse: rate limit at the edge first; keep Turnstile optional.
- Keep the scope tiny. That’s the whole point.
