Self-Hosting Cloudflare Workers & TanStack Start with celld on Bare-Metal Linux
A complete operational blueprint for deploying, tuning, and stress-testing Cloudflare Workers, D1, and KV applications on private Ubuntu VMs using Deno's celld runtime, including performance benchmarks and a comprehensive cost analysis versus Cloudflare edge.
Overview
Modern web applications increasingly target serverless edge runtimes such as Cloudflare Workers for their fast cold starts and edge-adjacent data stores (D1, KV, Durable Objects). However, at high throughput, request-based billing can scale aggressively, and regulatory constraints (such as healthcare HIPAA or GDPR data sovereignty) often mandate keeping sensitive patient records on-premises.
celld is an open-source, self-hosted distributed runtime developed by Deno that runs Cloudflare Workers and Durable Objects on bare-metal servers or private virtual machines with native SQLite, V8 isolates, and Tokio.
This learning documents the complete procedure for bundling, hosting, tuning, and benchmarking a full-stack TanStack Start application on a raw Ubuntu virtual machine using celld, along with an empirical cost analysis.
Architecture & Theory
flowchart TD
subgraph Host["Developer Workstation"]
Build["Vite+ & TanStack Start Build"]
Esbuild["esbuild (Externalize node:*)"]
LoadGen["autocannon Benchmark Suite"]
end
subgraph VM["Private VM / Bare-Metal Server (2 vCPUs, 2 GB RAM)"]
Systemd["systemd (clinic-app.service)"]
subgraph Celld["celld Runtime v0.6.3"]
Isolate["V8 Isolate Engine"]
Assets["Static Asset File Server"]
D1["Embedded SQLite D1 Database"]
KV["Local KV Cache Cell"]
end
end
Build --> Esbuild
Esbuild -->|Deploy Single ESM Bundle| VM
Systemd --> Celld
LoadGen -->|Stress Tests (0.25ms Latency)| Celld
Isolate --> Assets
Isolate --> D1
Isolate --> KV
How celld Works
- Worker Lifecycle:
celldspins up V8 isolates governed by a Tokio asynchronous event loop in Rust. - Storage Replication:
celld dev: Single-node mode with an embedded local SQLite object store. Zero cloud or external S3 dependency.celld --bucket <s3://...>: Multi-node fleet mode using S3/R2/GCS for conditional writes and lease consensus.
- Compatibility Boundary: Implements standard Cloudflare Worker APIs (
fetch,Request,Response,Web Crypto,Streams,node:compat), D1 (SQLite), KV, Queues, Workflows, Durable Objects, and Static Assets.
Step-by-Step Implementation Guide
1. Preparing the Raw Linux Server
Tune Linux network sockets and file descriptors to handle high concurrent HTTP traffic:
# 1. Elevate socket connection backlog
sudo tee /etc/sysctl.d/99-tuning.conf << 'EOF'
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
fs.file-max = 2097152
EOF
sudo sysctl --system
# 2. Elevate open file limits
sudo tee -a /etc/security/limits.conf << 'EOF'
* soft nofile 65535
* hard nofile 65535
root soft nofile 65535
root hard nofile 65535
EOF
2. Installing celld
Download the precompiled binary from GitHub releases and place it on system path:
curl -fSL https://github.com/denoland/celld/releases/download/v0.6.3/celld-x86_64-unknown-linux-gnu.gz -o /tmp/celld.gz
gzip -dc /tmp/celld.gz | sudo tee /usr/local/bin/celld > /dev/null
sudo chmod +x /usr/local/bin/celld
celld --version
3. Bundling TanStack Start for celld
@cloudflare/vite-plugin outputs split ESM chunks (dist/server/index.js and dist/server/assets/*.js). While Cloudflare’s platform resolves internal chunk manifests, celld requires either explicit module resolution or a single self-contained bundle when "no_bundle": true is specified.
Furthermore, node:* modules must be marked external so they resolve to celld’s internal Node compatibility layer:
# 1. Build the production client and server via Vite+
pnpm run build
# 2. Bundle server entry into a single self-contained ESM worker
pnpm dlx esbuild apps/web/dist/server/index.js \
--bundle \
--platform=neutral \
--target=es2022 \
--format=esm \
--external:node:* \
--outfile=/tmp/deploy/index.js
# 3. Copy client static assets (IMPORTANT: Remove .assetsignore if present)
cp -r apps/web/dist/client /tmp/deploy/client
rm -f /tmp/deploy/client/.assetsignore
4. Authoring a Strict wrangler.jsonc
celld deploy strictly enforces top-level configuration keys and rejects unrecognized build properties (like configPath or definedEnvironments generated by some Vite plugins).
Create a sanitized wrangler.jsonc:
{
"name": "clinic-appointment-app",
"compatibility_date": "2025-09-02",
"compatibility_flags": ["nodejs_compat"],
"main": "index.js",
"no_bundle": true,
"assets": {
"directory": "./client"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "clinic-db",
"database_id": "clinic-db-local"
}
],
"kv_namespaces": [
{
"binding": "SLOT_CACHE",
"id": "clinic-slot-cache-local"
}
],
"vars": {
"RESEND_API_KEY": "re_test_key",
"TWILIO_ACCOUNT_SID": "AC00000000000000000000000000000000"
}
}
5. Running as a Production systemd Service
Create /etc/systemd/system/clinic-app.service:
[Unit]
Description=Clinic Appointment App on celld
After=network.target
[Service]
Type=simple
User=vmuser
WorkingDirectory=/opt/clinic-appointment-app
ExecStart=/usr/local/bin/celld dev . --host 0.0.0.0 --port 8080 --no-watch --logs
Restart=always
RestartSec=2
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
Enable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now clinic-app
sudo systemctl status clinic-app
Empirical Benchmark Results (2 vCPUs, 2 GB RAM)
Stress tests executed from a connected host using autocannon:
| Test Profile | Target Endpoint | Concurrency | Requests / Sec | Average Latency | p99 Latency | Notes |
|---|---|---|---|---|---|---|
| Static Assets | GET /assets/styles-*.css (70 KB) |
50 (pipe=10) | 2,772 req/s | 178 ms | 287 ms | 196 MB/s throughput |
| JSON RPC API | GET /_serverFn/... (getServicesFn) |
50 | 2,895 req/s | 16.7 ms | 43 ms | 43k requests in 15s |
| Homepage SSR | GET / (React 19 HTML stream) |
25 | 232 req/s | 106 ms | 242 ms | Pure CPU-bound rendering |
| Homepage SSR | GET / (React 19 HTML stream) |
50 | 241 req/s | 205 ms | 379 ms | Zero 5xx errors |
| Homepage SSR | GET / (React 19 HTML stream) |
100 | 235 req/s | 418 ms | 827 ms | Sub-second at 100 concurrency |
| Homepage SSR | GET / (React 19 HTML stream) |
150 | 214 req/s | 685 ms | 1,254 ms | Queue saturation headroom |
| Booking SSR | GET /book/srv-general-consult |
25 | 185 req/s | 134 ms | 258 ms | Dynamic slot calculations |
Memory & Stability Footprint
- Idle Memory: Only 37.3 MB.
- Peak RSS Under Load (100k+ requests): Worker V8 isolate stabilized at 166 MB RSS.
- Total VM memory usage never exceeded 470 MB / 1900 MB, leaving 1.43 GB RAM completely free.
Detailed Cost Analysis: Cloudflare Workers vs. Self-Hosted Celld
Workload Scale: 20M Dynamic SSR Views/Day + 250M API Calls/Day
- Monthly Requests: (600\text{M (SSR)} + 7,500\text{M (API)} = \mathbf{8.1\text{ Billion requests / month}}).
- Monthly Bandwidth: (\sim 28.8\text{ TB / month}).
Cloudflare Workers Paid Plan Calculation
- Request Invocations: 8,090M billable requests (\times $0.30 / \text{1M} = \mathbf{$2,427.00}).
- Active CPU Time:
- SSR: (600\text{M} \times 15\text{ ms} = 9,000\text{M CPU ms}).
- API: (7,500\text{M} \times 1.5\text{ ms} = 11,250\text{M CPU ms}).
- Total overage: 20,220M CPU ms (\times $0.02 / \text{1M ms} = \mathbf{$404.40}).
- Workers KV Reads: ~20% hitting cache ((1,490\text{M billable} \times $0.50 / \text{1M} = \mathbf{$745.00})).
- Base Plan: $5.00.
- Total Monthly Cloudflare Cost: $3,581.40 / month ($42,976 / year).
Self-Hosted Celld on Dedicated Hardware
- Required Compute: Average 3,125 req/s, peaking at ~7,800 req/s.
- Hardware: 1 Dedicated Server (AMD Ryzen 7 7700, 8 cores / 16 threads, 64 GB RAM, 1 Gbps unmetered network): ~$55 / month.
- Storage / State Checkpoints: Local MinIO or Cloudflare R2 backup (~10 GB): ~$2 / month.
- Total Self-Hosted Cost: ~$57 / month ($684 / year).
| Financial Metric | Cloudflare Workers Edge | Self-Hosted Celld Server | Total Savings |
|---|---|---|---|
| Monthly Cost | $3,581 / mo | $57 / mo | $3,524 / mo (98.4% reduction) |
| Annual Cost | $42,976 / yr | $684 / yr | $42,292 / yr saved |
Key Takeaways & Best Practices
- When to Self-Host: For predictable, high-volume workloads (>50M requests/month) and privacy-sensitive healthcare data, self-hosting with
celldprovides orders of magnitude in cost savings with zero vendor lock-in. - Bundling Rules: Always bundle SSR server chunks into a single file with
--external:node:*prior to deploying tocelldif using"no_bundle": true. - Asset Routing: Strip
.assetsignorebefore deploying assets tocelld—celldautomatically prioritizes static asset matches over worker execution forGET/HEADrequests. - Hybrid Edge Recommendation: To combine the best of both worlds, place a free or basic ($20/mo) Cloudflare proxy in front for global DDoS mitigation and static asset caching, while routing dynamic SSR and database writes directly to private
celldnodes on-premises.