After comparing so many URL shorteners, one architectural pattern keeps coming back.
Some apps want a local Docker stack: container, database, Redis, object storage, reverse proxy, backups, ports, volumes, upgrades.
Other apps fit almost perfectly into a serverless edge shape:
compute + relational data + cache + files
On Cloudflare, that means:
Workers + D1 + KV + R2
If your app really fits those primitives, it can be serverless in the strongest practical sense: no VM, no Docker daemon, no OS patching, no database instance to resize, no Nginx config, no container healthchecks, and no VPS to babysit.
You write the app, bind the resources in wrangler.jsonc or wrangler.toml, and deploy.
The Cloudflare Serverless Stack
The useful mental model is:
| Traditional local stack | Cloudflare serverless equivalent |
|---|---|
| Node/Go/Python container | Workers |
| SQLite/Postgres/MySQL for app data | D1 |
| Redis/key-value cache | KV, sometimes Durable Objects |
| Local uploads/MinIO/S3 | R2 |
| Cron container/systemd timer | Cron Triggers |
| Queue worker container | Queues |
| Nginx/Caddy/Traefik/TLS | Cloudflare routing, TLS, and domain bindings |
That does not mean every Docker app can move to Cloudflare.
It means apps that already look like request-response web services, CRUD APIs, link shorteners, forms, webhooks, content tools, dashboards, and small multi-tenant SaaS backends may not need a traditional server at all.
Useful official docs:
- Cloudflare Workers Limits
- Cloudflare D1 Limits
- Cloudflare KV Limits
- Cloudflare R2 Pricing
- Cloudflare R2 S3 Compatibility
Why It Is Truly Serverless
Serverless is an overloaded word, but this stack earns the label.
No Infrastructure Management
There is no database server to provision, no pooler, no container restart policy, no disk resizing, no package updates on the host, and no reverse proxy file to maintain.
The infrastructure shape is declared as bindings.
Your code sees those bindings as env.DB, env.CACHE, or env.FILES.
Scale-to-Zero Compute
Workers run when requests arrive.
There is no long-running app process reserved for your project when nobody is using it. Storage can still exist and may still count against quota or billing, but your compute is request-driven.
That is very different from a VPS where a container idles all month even when traffic is zero.
Global Edge Execution
Workers run on Cloudflare’s edge runtime, built around V8 isolates rather than a traditional VM-per-function model.
For small APIs and short request handlers, that removes a lot of the cold-start friction people associate with older serverless platforms.
Unified Control Plane
Routing, TLS, DNS, DDoS mitigation, custom domains, Workers, D1, KV, and R2 live in one platform.
For small projects, that is a serious reduction in operational surface area.
What Each Primitive Does
Workers: Compute
Workers are the request handlers.
They are good for:
- API routes;
- redirects;
- webhooks;
- auth callbacks;
- form handlers;
- HTML rendering;
- lightweight scheduled jobs;
- edge middleware;
- proxying and transforming responses.
The catch: this is not a normal Linux server.
There is no persistent local disk, no background process that runs forever, and strict runtime limits. Current Cloudflare docs list Workers Free at 100,000 requests per day, 10 ms CPU time, and 128 MB memory; Workers Paid has no request limit listed, 5 min CPU time, and the same 128 MB memory limit.
That is enough for many web requests.
It is not where you run FFmpeg jobs, a Python data pipeline, a long polling loop, or a stateful daemon.
D1: Relational Data
D1 is Cloudflare’s serverless SQL database built around SQLite semantics.
It is a good fit for:
- users;
- teams;
- links;
- posts;
- tags;
- settings;
- API keys;
- basic audit logs;
- small SaaS tenant data;
- standard CRUD tables.
The catch: D1 is not Postgres, and it is not ClickHouse.
Current docs list maximum database size as 500 MB on Free and 10 GB per database on Workers Paid. D1 is designed around many smaller databases, and each individual D1 database processes queries one at a time.
That makes it useful for many application backends.
It is not a fit for multi-terabyte analytics, heavy OLAP, or complex database features that assume a full Postgres/MySQL server.
KV: Edge Cache and Configuration
KV is globally distributed key-value storage.
It is excellent for:
- redirect lookup caches;
- feature flags;
- app configuration;
- public cache entries;
- low-write metadata;
- expensive lookup results;
- tenant settings that can tolerate eventual consistency.
The catch is consistency.
KV is eventually consistent globally. Cloudflare documents that writes are immediately visible in the same global network location, but can take up to 60 seconds to appear elsewhere. KV also has a maximum of one write per second to the same key.
So do not use KV as your primary transactional database.
Use D1 for correctness. Use KV when low-latency reads matter more than instant global write consistency.
R2: Object Storage
R2 is object storage.
It is good for:
- avatars;
- uploads;
- generated images;
- exports;
- backups;
- static assets;
- user files;
- S3-compatible workflows.
The catch: R2 is not a mounted filesystem.
It is object storage. You put, get, and delete objects. You do not treat it like /uploads inside a container.
The upside is strong: R2 has S3-compatible access patterns and Cloudflare currently prices internet egress as free for R2 standard and infrequent access storage classes.
A Minimal wrangler.jsonc
A Worker app using D1, KV, and R2 might bind resources like this:
{
"name": "links-api",
"main": "src/index.ts",
"compatibility_date": "2026-10-06",
"d1_databases": [
{
"binding": "DB",
"database_name": "links-prod",
"database_id": "00000000-0000-0000-0000-000000000000"
}
],
"kv_namespaces": [
{
"binding": "CACHE",
"id": "00000000000000000000000000000000"
}
],
"r2_buckets": [
{
"binding": "FILES",
"bucket_name": "links-files"
}
]
}
Then your Worker code can use those bindings:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const slug = url.pathname.slice(1);
const cached = await env.CACHE.get(`link:${slug}`);
if (cached) {
return Response.redirect(cached, 302);
}
const row = await env.DB
.prepare("SELECT target_url FROM links WHERE slug = ?")
.bind(slug)
.first<{ target_url: string }>();
if (!row) {
return new Response("Not found", { status: 404 });
}
await env.CACHE.put(`link:${slug}`, row.target_url, {
expirationTtl: 300
});
return Response.redirect(row.target_url, 302);
}
};
That tiny example shows the core shape:
- Worker handles the request.
- KV checks the fast cache.
- D1 is the source of truth.
- KV is refreshed after a miss.
If the app also stores uploaded files or generated Open Graph images, R2 becomes the object store.
How This Relates to the Link Shortener Series
The cleanest example we covered is Sink .
Sink is Cloudflare-native:
| Need | Sink’s Cloudflare primitive |
|---|---|
| Runtime | Workers |
| Relational link data | D1 |
| Hot redirect cache | KV |
| Analytics | Analytics Engine |
| Backups/images | R2 |
| Optional AI features | Workers AI |
That is why Sink is not a normal Docker Compose app.
It is self-hosted in the sense that you deploy and own your instance, but you are deploying it onto Cloudflare’s serverless platform rather than onto a VPS.
That also explains Slite .
Slite is basically the local-stack sibling idea:
| Cloudflare-native Sink | Local Slite equivalent |
|---|---|
| Workers | Node/Nitro server |
| D1 | SQLite |
| KV | In-process memory cache |
| Analytics Engine | DuckDB |
| R2 | Local filesystem |
Same product family direction, different operations model.
When Cloudflare Is Better Than a Local Stack
Use the Workers/D1/KV/R2 model when:
- the app is request-response;
- the database fits D1 limits;
- you do not need server-side filesystem semantics;
- traffic is bursty or low most of the time;
- global latency matters;
- you want managed TLS, routing, and DDoS protection;
- you are comfortable with Cloudflare bindings and
wrangler; - your cache can tolerate KV’s consistency model.
Good examples:
- URL shorteners;
- webhook receivers;
- form backends;
- link-in-bio tools;
- small dashboards;
- public APIs;
- redirect services;
- small SaaS admin panels;
- static sites with dynamic API routes;
- content tools with uploaded assets in R2.
For these, a VPS can be more infrastructure than product.
When a Local Stack Is Better
Keep Docker, a VPS, or a local server when:
- the app assumes a long-running process;
- it needs local disk writes like a normal filesystem;
- it needs heavy background jobs;
- it depends on unsupported native binaries;
- it needs full Postgres/MySQL semantics;
- it performs heavy OLAP analytics;
- it needs LAN access to devices, serial ports, USB, cameras, or local files;
- it must stay portable across providers;
- vendor lock-in is unacceptable.
That is why tools like Shlink , Kutt , YOURLS , Snapp , and Slash still make sense locally.
They are conventional web apps.
They expect a running process, a database, and a familiar deployment target.
The Main Tradeoff: Lock-In
The biggest tradeoff is not performance.
It is lock-in.
A normal app might say:
await db.query("SELECT * FROM links WHERE slug = $1", [slug]);
A Cloudflare-native app says:
await env.DB.prepare("SELECT * FROM links WHERE slug = ?")
.bind(slug)
.all();
That env.DB binding is Cloudflare-specific.
env.CACHE and env.FILES are also Cloudflare-specific.
You can isolate some of that behind adapters, but a truly Cloudflare-native app is not portable in the same way a Docker Compose app is portable.
In exchange, you get a lot of infrastructure removed from your life.
FAQ
Is Workers plus D1 plus KV plus R2 really serverless?
Yes, if your app fits the model. You are not managing VMs, containers, operating systems, database instances, reverse proxies, TLS renewals, or disk volumes. You deploy code and bind managed resources.
Is this self-hosting?
It depends how strict you are with the term. It is self-deployed and self-managed at the application layer, but it is not self-hosted on your own server. I would call it platform-hosted open-source deployment rather than homelab self-hosting.
Can this replace Docker Compose?
For some apps, yes. For others, no. If the app is mostly HTTP handlers plus SQL plus cache plus files, Cloudflare can be cleaner. If the app needs a normal Linux process, local filesystem, background workers, or full database control, Docker Compose is still the better fit.
Is D1 just SQLite?
D1 uses SQLite semantics and SQL, but it is not the same as mounting a local SQLite file. It is a managed serverless database with Cloudflare-specific limits, bindings, replication behavior, backup/restore tooling, and operational constraints.
Should KV store my primary data?
No. KV is best for low-latency reads, cache, and configuration. It is eventually consistent globally and has a one-write-per-second limit to the same key. Use D1 or Durable Objects for data that needs stronger correctness.
Is R2 a filesystem?
No. R2 is object storage with S3-compatible access patterns. It is great for blobs and files, but you should not design code that expects local POSIX filesystem behavior.
What about analytics?
D1 can store normal application events at small scale, but it is not a warehouse. For heavier analytics, use a purpose-built system such as ClickHouse, Tinybird, DuckDB exports, or another analytics backend.
Which previous project best demonstrates this stack?
Sink is the best Cloudflare-native example from this series.
Sink uses:
| Sink requirement | Cloudflare primitive |
|---|---|
| Runtime/API/redirect logic | Workers |
| Relational link data | D1 |
| Hot redirect cache | KV |
| Click analytics | optional Analytics Engine |
| Backups/images | optional R2 |
| AI helpers | optional Workers AI |
Slite is the local sibling comparison. It keeps the same general product direction, but replaces the Cloudflare stack with local components:
| Cloudflare-native Sink | Local Slite equivalent |
|---|---|
| Workers | Node/Nitro server |
| D1 | SQLite |
| KV | In-process memory cache |
| Analytics Engine | DuckDB |
| R2 | Local filesystem |
That pair is the cleanest example we have covered so far: one app family shows the serverless Cloudflare deployment model, and the sibling shows the fully local Docker-style model.
What local OSS stack can replace the Cloudflare pieces?
If you want an exit path from Cloudflare, design around boundaries that can be mapped to open-source, self-hostable components.
The common migration map looks like this:
| Cloudflare primitive | Local/self-hostable replacement | Backup/migration note |
|---|---|---|
| Workers | Node.js/Nitro, Hono on Node, Go, Rust, Python, or another HTTP server | Keep business logic separated from env.* bindings so it can move behind normal adapters |
| D1 | SQLite for single-node apps, PostgreSQL/MySQL/MariaDB for server DBs | Export relational data as SQL/CSV/JSON; test schema differences instead of assuming D1 is identical to local SQLite |
| KV | Redis, Valkey, KeyDB, Dragonfly, or an in-process cache | Treat KV as rebuildable cache when possible; avoid making it the only source of truth |
| R2 | MinIO, Garage, SeaweedFS, local filesystem, or any S3-compatible object store | Keep object keys predictable and store metadata in the relational DB |
| Analytics Engine | DuckDB for local analytics, ClickHouse for heavier event analytics, Matomo for web analytics | Export events regularly if analytics history matters |
| Queues | RabbitMQ, NATS, Redis/Valkey queues, BullMQ, Faktory, or Sidekiq-style workers | Make jobs idempotent so retries work across platforms |
| Cron Triggers | cron, systemd timers, Ofelia, or app-level scheduled workers | Keep scheduled jobs callable from a CLI or HTTP endpoint |
| Workers AI | Ollama, OpenAI-compatible local APIs, LiteLLM, LocalAI, or hosted model APIs | Keep model calls behind an adapter because prompts and response formats change |
For backups, the important rule is simple: do not let Cloudflare-only services become invisible state.
Keep D1 as the source of truth for relational data, make KV rebuildable, keep R2 object keys documented, and export analytics if you care about long-term history. That makes migration from a Cloudflare-native app to an OSS/local stack realistic instead of a rewrite from memory.
What is the most important design rule?
Use each primitive for its intended job. Workers handle requests. D1 stores relational source-of-truth data. KV caches read-heavy or eventually consistent values. R2 stores objects. Once you fight those boundaries, a local stack starts looking better again.
Conclusion
Cloudflare Workers, D1, KV, and R2 can be a real alternative to a local stack.
Not for everything.
But for small web apps, link tools, CRUD APIs, webhook processors, and file-backed serverless products, the model is compelling: no VM, no Docker, no OS updates, no database server, no reverse proxy, and no idle container.
The tradeoff is that you are writing for Cloudflare’s runtime and storage model.
That is the decision: operational simplicity versus platform specificity.
For some projects, especially things like Sink, that trade is exactly the point.
Comments