A full-stack Leptos 0.8 starter deployed to Cloudflare Workers. SSR on the edge, hydration in the browser, D1 for persistence, and an agent-first setup workflow that lets your AI coding assistant provision the entire Cloudflare stack for you.
The included example is a D1-backed todo app with list, create, toggle, and delete flows implemented entirely through Leptos server functions. It also includes a public /contact intake route that demonstrates bounded form handling, origin-checked server-function posts, honeypot filtering, and D1-backed submission caps without sending external email. The default demo keeps todos and contact submissions isolated to each browser session and only renders the newest todo slice so the starter stays bounded under public traffic.
Leptos compiles to WASM on both sides of the wire. The server renders HTML on Cloudflare's edge network, the client hydrates it with the same component code, and server functions give you a typed RPC boundary between the two. No JavaScript framework, no REST boilerplate, no separate API layer.
Cloudflare gives you the deployment surface: Workers for compute, D1 for SQL, Assets for static files, Tunnels for exposing local dev to the internet, and Containers for when you outgrow the Worker sandbox. All of it scales to zero and bills by usage.
What you get out of the box:
- Single Rust crate with feature-flagged SSR/hydration split
- Worker entrypoint using
workers-rs+axum - Leptos server functions as the full-stack boundary
- D1 access layer with prepared statements
- Public contact intake with server-side validation and session-scoped abuse caps
- Client-side optimistic UI with loading and error states
- Bootstrap scripts that verify your toolchain
- A setup flow designed for AI coding agents
git clone https://github.com/rogu3bear/leptos-cloudflare.git my-app
cd my-app
./scripts/init.sh my-app # strips the todo example, rewrites config
./scripts/bootstrap.sh # installs Rust toolchain + cargo-leptos
bunx wrangler@4.83.0 d1 create my-app-db
# paste the database_id into wrangler.toml
bunx wrangler@4.83.0 d1 migrations apply my-app-db --local
bash ./scripts/build-edge.sh
bunx wrangler@4.83.0 dev --local --ip 127.0.0.1 --port 57581init.sh removes the todo domain (components, server functions, migration, styles) and leaves a clean "It works." skeleton with all the wiring intact. See De-templating for details.
- Quick Start (New Project)
- Agent-First Setup
- Manual Setup
- Cloudflare API Tokens
- Local Development
- Deployment
- De-templating
- Cloudflare Tunnels
- Cloudflare Containers
- Project Structure
- Architecture Notes
The fastest path from clone to deploy is to hand the project to an AI coding agent (Claude Code, Codex, etc.) with a scoped Cloudflare API token. The agent can create your D1 database, patch wrangler.toml with real IDs, apply migrations, and deploy -- all without you touching the Cloudflare dashboard.
- A Cloudflare API token with the permissions described in Cloudflare API Tokens
- Your Cloudflare Account ID (visible at the top of any zone's overview page, or under Workers & Pages)
- The tools installed (Rust,
cargo-leptos, Bun) -- or let the agent run./scripts/bootstrap.sh
export CLOUDFLARE_API_TOKEN="your-scoped-token"
export CLOUDFLARE_ACCOUNT_ID="your-account-id"Bootstrap this project for Cloudflare. Create the D1 database, update wrangler.toml with the real database ID, apply migrations locally and remotely, build, and deploy.
The agent will execute something like:
# 1. Install toolchain
./scripts/bootstrap.sh
# 2. Create the D1 database
bunx wrangler@4.83.0 d1 create leptos-cf-db
# 3. Parse the database_id from the output and patch wrangler.toml
# 4. Apply migrations
bunx wrangler@4.83.0 d1 migrations apply leptos-cf-db --local
bunx wrangler@4.83.0 d1 migrations apply leptos-cf-db --remote
# 5. Build and deploy
bunx wrangler@4.83.0 deployThe placeholder 00000000-0000-0000-0000-000000000000 IDs in wrangler.toml are the signal -- the agent knows to replace them.
In this ~/dev workspace, live Cloudflare account reads, standards audits,
mutation planning, and post-change verification should use cfctl from
PATH, backed by /Users/star/dev/cloudflare. The raw Wrangler commands in
this template remain the generic starter flow; use cfctl when the task is
about this operator's Cloudflare account state:
cfctl doctor
cfctl standards audit /Users/star/dev/leptos-cfCloudflare's infrastructure is fully API-driven. Every dashboard action has a CLI or API equivalent. This means a coding agent with the right token can provision your entire stack -- databases, secrets, tunnels, DNS records -- without you context-switching to a browser. You stay in your editor, the agent does the ops.
rustup toolchain install stable
rustup target add wasm32-unknown-unknown
cargo install cargo-leptos --locked --version 0.3.5
cargo install worker-build --locked --version 0.7.5This template uses bunx wrangler@4.83.0, so a global Wrangler install is not required. You do need Bun. wasm-bindgen-cli is installed into var/cargo-tools/ from the version resolved in Cargo.lock, so the build does not depend on whichever global wasm-bindgen happens to be on PATH.
./scripts/check-deps.sh # verify all tools are present
./scripts/bootstrap.sh # install missing tools, then verifybunx wrangler@4.83.0 d1 create leptos-cf-dbWrangler prints a database_id. Copy it into both database_id and preview_database_id in wrangler.toml.
# Local (for wrangler dev)
bunx wrangler@4.83.0 d1 migrations apply leptos-cf-db --local
# Remote (for production)
bunx wrangler@4.83.0 d1 migrations apply leptos-cf-db --remotebash ./scripts/build-edge.sh
bunx wrangler@4.83.0 dev --local --ip 127.0.0.1 --port 57581bunx wrangler@4.83.0 deployScoped tokens are the key to safe agent-assisted workflows. You create a token with exactly the permissions needed, set an expiration, and hand it to the agent. When the token expires, the agent loses access automatically.
Go to My Profile > API Tokens > Create Token in the Cloudflare dashboard, or use the API.
Minimum permissions for this project:
| Permission | Access | Why |
|---|---|---|
| Account Settings | Read | Wrangler needs account introspection |
| Workers Scripts | Edit | Deploy the Worker |
| D1 | Edit | Create databases, apply migrations |
Additional permissions if you're using more of the platform:
| Permission | Access | Why |
|---|---|---|
| Workers R2 Storage | Edit | Create/manage R2 buckets |
| Cloudflare Tunnel | Edit | Create and configure tunnels |
| Workers KV Storage | Edit | Create/manage KV namespaces |
Always apply these:
- Expiration: Set
expires_onto 30-90 days. Tokens do not expire by default. - Account scope: Restrict to a single account, not "All accounts."
- IP filtering (optional): Lock to your office/home IP or CI runner CIDR range.
Cloudflare does not auto-rotate tokens, but the workflow is straightforward:
- Account-owned tokens (recommended for CI/CD and agents) support secret rolling via the API -- the token value regenerates while permissions stay intact.
- Create tokens with overlapping expiration windows: issue a new token before the old one expires, update your secrets, then let the old one die.
- For CI/CD, store
CLOUDFLARE_API_TOKENas a repository secret and rotate on a schedule. - For agents, set short-lived tokens (7-30 days) and re-issue as needed. The agent only needs the token during active development sessions.
Automated rotation via the API:
# Roll an account-owned token's secret (preserves permissions, generates new value)
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/{account_id}/tokens/{token_id}/value" \
-H "Authorization: Bearer {current_token}"- User-owned (
My Profile > API Tokens): tied to your user account. If you leave the org, the token dies. - Account-owned (
Manage Account > Account API Tokens): tied to the account, survives employee changes. Use this for CI/CD and persistent agent access.
Wrangler reads these automatically:
export CLOUDFLARE_API_TOKEN="..." # the scoped token
export CLOUDFLARE_ACCOUNT_ID="..." # your account IDSet these before any wrangler command for non-interactive (agent) usage.
bash ./scripts/build-edge.sh
bunx wrangler@4.83.0 dev --local --ip 127.0.0.1 --port 57581Wrangler serves the Worker and the asset bundle from target/site. The todo UI talks to D1 only through Leptos server functions.
Each browser session gets its own cookie-scoped todo queue and contact submission budget. The starter only renders the newest 100 todo rows from that queue so the public demo stays responsive.
Local secrets: Create a .dev.vars file (gitignored) for local environment variables:
SECRET_KEY=dev-value-here
Iterating: Run cargo leptos watch in a second terminal for automatic client rebuilds. The Worker itself needs a wrangler dev restart to pick up server-side changes.
Once wrangler.toml has a real D1 database ID and the remote migration has been applied:
bunx wrangler@4.83.0 deployWrangler runs the configured build command:
cargo leptos build --release-- compiles the client WASM + CSSbun ./scripts/hash-assets.mjs-- fingerprints the client JS/CSS/WASM and updates the SSR asset constantsworker-build --release --features ssr-- compiles the Worker bundle against those hashed asset namesbun ./scripts/write-worker-shim.mjs-- emitsbuild/_worker.js, a Module Worker router that sends static assets toenv.ASSETSand all SSR/server-function traffic to the compiled Leptos Worker handler
That produces:
- Client assets in
target/site/ - Asset manifest in
target/site/asset-manifest.json - Cloudflare cache header rules in
target/site/_headers - Compiled Leptos Worker module in
build/index.js, generated byworker-build - Worker shim/router in
build/_worker.js, which is themainentrypoint inwrangler.toml
Cache behavior is split cleanly:
- Cloudflare Workers Assets serves matching files from
target/sitedirectly through theASSETSbinding, without invoking the Rust SSR router - The generated
_worker.jskeeps the same separation when it receives asset-prefixed requests directly:/pkg/*, app icons,site.webmanifest, and/asset-manifest.jsongo toenv.ASSETS.fetch(request), while deep routes and/api/*fall through to Leptos SSR/server functions - WebSocket upgrades have one explicit template route,
/realtime/socket; all production realtime work should either keep request-scoped behavior there or graduate shared state to Durable Objects - The Rust route list includes an explicit Leptos catch-all route, so a pre-hydration click or browser history restore that becomes a full document request still receives the SSR shell instead of a platform 404
- Hashed
/pkg/*assets ship withCache-Control: public, max-age=31536000, immutable - Dynamic Worker responses (
/, route HTML, server functions) ship withCache-Control: no-store asset-manifest.jsonis alsono-store, so deploys never strand old asset pointers
See Realtime and WebSockets before adding chat, presence, collaboration, live dashboards, or any other WebSocket-backed feature. The short rule: request-scoped upgrades can stay in the Worker; shared state belongs in a Durable Object. The first shared-state example lives in Realtime Durable Object.
# Interactive (one at a time)
bunx wrangler@4.83.0 secret put SECRET_KEYbunx wrangler@4.83.0 deploy --dry-runThe included todo app is meant to be replaced. scripts/init.sh automates this:
./scripts/init.sh my-appThis does:
- Rewrites
Cargo.toml,wrangler.toml, andsrc/app.rswith your project name - Replaces the todo components, server functions, and D1 queries with a minimal "It works." scaffold
- Strips todo-specific CSS, keeps the design system foundation (variables, glass panels, typography, responsive breakpoints)
- Removes the todo migration (you'll create your own)
- Leaves the wiring intact:
AppState,server_errorhelper, shell, router, feature flags
The script refuses to run if wrangler.toml already has real D1 IDs (indicating the project was already initialized).
After running init.sh, your project compiles and renders a working page — the SSR, hydration, and server function plumbing is all in place. Add your own domain by following docs/building-features.md.
cloudflared creates outbound-only connections from your machine to Cloudflare's edge. This is useful for:
- Exposing your local dev server to the internet (webhook testing, OAuth callbacks, mobile testing)
- Giving teammates access to your local branch without deploying
- Connecting private services that Workers can route to in production
# Expose your local wrangler dev server instantly
cloudflared tunnel --url http://localhost:57581This gives you a random *.trycloudflare.com URL. No login, no config. Good for quick testing.
# Install
brew install cloudflare/cloudflare/cloudflared
# Authenticate (opens browser once)
cloudflared tunnel login
# Create a named tunnel
cloudflared tunnel create leptos-cf-dev
# Route a subdomain to it
cloudflared tunnel route dns leptos-cf-dev dev.yourdomain.com
# Run it
cloudflared tunnel run --url http://localhost:57581 leptos-cf-devAn agent with a Cloudflare Tunnel: Edit permission on its token can create and configure tunnels programmatically. For non-interactive use (CI, agents), use remotely-managed tunnels with a tunnel token instead of cloudflared tunnel login (which opens a browser):
# The tunnel token is available after creating a tunnel in the dashboard
# or via the API -- it bypasses the browser login flow
cloudflared tunnel run --token <TUNNEL_TOKEN>Status: Public Beta (launched June 2025). Workers Paid plan required ($5/month).
Containers run alongside Workers, built on Durable Objects. A Worker acts as the gateway; containers handle workloads that need a full Linux environment -- long-running processes, native binaries, GPU access, or anything that doesn't fit in the Worker sandbox.
Workers are perfect for the Leptos SSR + hydration model -- fast edge rendering, scale to zero. But as your app grows, you might need:
- Background job processing (PDF generation, image processing)
- Services that need native Linux dependencies
- Long-running WebSocket connections beyond Worker limits
- Sidecar services (Redis, Postgres, custom daemons)
Containers let you keep the Worker as your fast edge frontend while offloading heavier work to a container, all on the same platform.
- Your Worker handles HTTP requests (Leptos SSR, server functions)
- The Worker talks to a Container via Durable Object bindings
- The Container runs your Docker image with full Linux capabilities
- Containers scale to zero when idle -- you only pay for active time
Add to your wrangler.toml:
[[containers]]
class_name = "MyContainer"
image = "./Dockerfile"
max_instances = 10
[[durable_objects.bindings]]
name = "MY_CONTAINER"
class_name = "MyContainer"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyContainer"]Note: containers use new_sqlite_classes, not new_classes.
| Type | vCPU | Memory | Disk |
|---|---|---|---|
| lite | 1/16 | 256 MiB | -- |
| basic | 1/4 | 1 GiB | -- |
| standard-1 | 1/2 | 4 GiB | 8 GB |
| standard-2 | 1 | 8 GiB | 10 GB |
| standard-3 | 2 | 12 GiB | 15 GB |
| standard-4 | 4 | 12 GiB | 20 GB |
Pricing is scale-to-zero: memory, CPU (active usage only), and disk are billed per second with free tier included.
- No autoscaling or load balancing yet (manual scaling only)
- Cold starts typically 2-3 seconds
- Container images deploy gradually (not atomic with Worker code)
- Images must target
linux/amd64 - Docker must be running locally at deploy time
.
├── .cargo/config.toml # WASM target rustflags
├── Cargo.toml # single-crate config with feature flags
├── wrangler.toml # Worker + D1 + Assets config
├── migrations/
│ ├── 0001_init.sql # todos table + index
│ ├── 0002_session_scope.sql
│ └── 0003_contact_submissions.sql
├── scripts/
│ ├── bootstrap.sh # install missing tools
│ └── check-deps.sh # verify toolchain
├── src/
│ ├── main.rs # stub binary (entrypoint is in lib.rs)
│ ├── lib.rs # Worker fetch handler + hydrate()
│ ├── app.rs # Leptos App component, shell, router
│ ├── api.rs # shared types + #[server] functions
│ ├── components/
│ │ ├── mod.rs
│ │ ├── contact_page.rs # Public contact intake
│ │ └── todo_page.rs # TodoPage, TodoBoard, TodoRow
│ └── server/
│ ├── mod.rs # re-exports + server_error helper
│ ├── state.rs # AppState (LeptosOptions + worker::Env)
│ ├── contact.rs # Contact validation persistence + caps
│ └── todos.rs # D1 query layer
├── style/
│ └── main.css # hand-written CSS
└── assets/
├── favicon.svg
├── app-icon.svg
├── apple-touch-icon.png
├── app-icon-192.png
├── app-icon-512.png
└── site.webmanifest
The starter ships with a small Leptos CF mark in assets/favicon.svg and
assets/app-icon.svg. The shell links favicon.svg, apple-touch-icon.png,
and site.webmanifest, and the visible starter page reuses app-icon.svg in
the hero lockup. Replace those files together when turning the template into a
product so browser tabs, installed app icons, and the in-app header stay in
sync.
- It matches the proven Cloudflare Workers Leptos deployment model
- Feature flags (
ssr,hydrate) keep code paths explicit - Server functions, shared types, and UI live together without workspace overhead
cargo-leptoshandles the dual compilation (server WASM + client WASM)
Real applications need more than the minimal starter. See the patterns/ directory for battle-tested, well-documented solutions to common problems (dynamic entity detail, shared layouts, realtime Durable Objects, etc.). These are designed to be composed on top of the core template while keeping the starter itself lean.
Browser → Worker (Leptos SSR via axum) → server function → D1
↓
HTML response (first load)
or JSON response (after hydration)
Server functions are defined in src/api.rs with the #[server] macro. On the server, they execute inside a SendWrapper (required because Workers are single-threaded but Leptos server fns need Send). On the client, the macro generates an HTTP call to the server function endpoint.
| Feature | What it enables |
|---|---|
ssr |
axum, leptos_axum, worker, server-side Leptos rendering |
hydrate |
console_error_panic_hook, client-side Leptos hydration |
These are mutually exclusive at compile time. cargo-leptos builds the lib with hydrate (for the client WASM) and the bin with ssr (for the Worker).
The Worker's Env (which holds D1 bindings) is wrapped in Arc inside AppState, provided as axum state, and extracted in server functions via use_context::<AppState>(). All queries use prepared statements with bind_refs for parameterized SQL.
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0 CHECK (completed IN (0, 1)),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);The contact route adds a contact_submissions table keyed by the same browser-session cookie. It stores normalized form fields only; it does not store client IP addresses and it does not send email or webhooks. Add a Queue, email provider binding, Turnstile, or Cloudflare Rate Limiting rule before using it as a production intake path.
The complete local release readiness check:
./scripts/verify.shFor the individual pieces that the script orchestrates, see the script itself and AGENTS.md.
Commands you'll use most with this project:
| Command | What it does |
|---|---|
bunx wrangler@4.83.0 dev --local |
Start local dev server |
bunx wrangler@4.83.0 deploy |
Deploy to production |
bunx wrangler@4.83.0 deploy --dry-run |
Validate without deploying |
bunx wrangler@4.83.0 d1 create <name> |
Create a D1 database |
bunx wrangler@4.83.0 d1 migrations apply <db> --local |
Apply migrations locally |
bunx wrangler@4.83.0 d1 migrations apply <db> --remote |
Apply migrations to production |
bunx wrangler@4.83.0 d1 execute <db> --local --command "SQL" |
Run ad-hoc SQL locally |
bunx wrangler@4.83.0 d1 execute <db> --remote --command "SQL" |
Run ad-hoc SQL in production |
bunx wrangler@4.83.0 secret put <KEY> |
Set a secret (interactive) |
bunx wrangler@4.83.0 secret list |
List all secrets |
bunx wrangler@4.83.0 tail |
Stream live Worker logs |
MIT