A free, self-hosted alternative to bit.ly and Rebrandly — built on Cloudflare Workers with zero server costs
For full technical specifications and architecture details, see CLAUDE.md. For version history, see CHANGELOG.md.
A lightweight, high-performance edge router and URL shortener built on Cloudflare Workers and the Hono framework. Replace paid link shorteners like bit.ly, Rebrandly, and TinyURL with your own self-hosted solution. Manage URL redirects, reverse proxies, and R2 bucket file serving through a simple API — all configuration stored in Cloudflare KV for instant global propagation across 300+ edge locations.
| Bifrost | bit.ly / Rebrandly | YOURLS | Kutt | |
|---|---|---|---|---|
| Cost | Free (Cloudflare free tier) | $35-$300+/month | Free (self-hosted) | Free (self-hosted) |
| Infrastructure | Serverless (zero servers) | Managed SaaS | PHP + MySQL server | Node.js + Docker |
| Latency | ~30-90ms (edge cached) | ~100-200ms | ~200-500ms | ~100-300ms |
| Global CDN | 300+ Cloudflare locations | Yes | No (single server) | No (single server) |
| Custom domains | Unlimited | 1-10 (plan dependent) | 1 | Unlimited |
| Reverse proxy | Yes | No | No | No |
| R2 file serving | Yes | No | No | No |
| API management | Full REST API + MCP + Slack | REST API | REST API | REST API |
| Setup time | ~15 minutes | Instant (SaaS) | ~30 minutes | ~30 minutes |
- Dynamic Routing — Configure routes via API without redeployment
- Three Route Types:
redirect— URL redirects (301, 302, 307, 308)proxy— Reverse proxy to external URLsr2— Serve content from R2 buckets
- Case-Insensitive Paths — Visitors can use any case in the URL (
/LinkedIn,/LINKEDIN,/linkedinall match the same route) - KV-Powered — Route changes propagate globally in seconds
- Admin API — Full CRUD operations with API key authentication, search, and pagination
- Admin Dashboard — React SPA with Command Palette (Cmd+K), filters, analytics, R2 Storage browser with file preview (images, PDFs) and standalone target links
- MCP Server — AI-powered route and R2 storage management via Claude Code/Desktop (29 tools)
- QR Codes (v1.30.0) — unified QR resource (URL / text / Wi-Fi / vCard) with optional route linking (re-point, never reprint), a preset registry for your own branding, live preview, SVG + PNG export, and authed-only image serving
- User Guide (v1.30.0) — in-dashboard guide (11 task-first sections) with a first-visit welcome dialog, contextual ? help links, an MCP integration tab, and dated changelog
- Operational Analytics — domain-aware full URLs, redirect/proxy/service-page leaders, recent activity, period comparisons, and actionable traffic signals; Cloudflare Health Checks are excluded by default
- Wildcard Patterns — Support for path patterns like
/blog/* - R2 Storage Management — Browse, upload, download, rename, move, and delete R2 objects via API and dashboard
- CDN Cache Purge — Purge Cloudflare edge cache globally for R2 objects via Zone Cache Purge API
- Route Domain Transfer — Move routes between domains preserving configuration and audit trail
- R2 Backup System — Automated daily KV route backups with health monitoring (D1 covered by Time Travel)
- API Shield — OpenAPI schema validation at the Cloudflare edge
- Built on Hono — Fast, lightweight, TypeScript-first
- Multi-Domain Routing — Single worker handles multiple custom domains
- Domain-Restricted Admin API — Admin API only accessible from designated domain
- Timing-Safe Auth — API key comparison resistant to timing attacks
- SSRF Protection — Blocks proxy requests to private/internal IPs
- Path Traversal Protection — R2 keys sanitized to prevent directory traversal
- Rate Limiting — Via Cloudflare WAF (Worker middleware available if needed)
- Service-Binding Fetch Resilience — Worker-to-Worker service-binding calls are wrapped in
try/catchvia thesafeServiceFetchhelper, so URL-parse errors and binding failures become 404s + warn logs instead ofscriptThrewExceptionworker errors
bifrost/ # pnpm monorepo
├── src/ # Main edge router Worker
├── shared/ # Shared types, schemas, HTTP client
├── mcp/ # MCP server for AI route management
├── admin/ # React SPA admin dashboard
└── slackbot/ # Slack bot for route management
This repo is designed as a forkable template. Follow these steps to deploy your own instance.
- Node.js >= 24 (see
.nvmrc) - pnpm (
corepack enable && corepack prepare) - A Cloudflare account with Workers enabled (free plan works)
- Wrangler CLI authenticated (
wrangler login)
# Fork via GitHub UI, then clone your fork
git clone https://github.com/YOUR-USERNAME/bifrost-router.git
cd bifrost-router
pnpm installRun these commands to create the required Cloudflare resources. Save the IDs printed by each command.
# KV namespace for route storage
wrangler kv namespace create ROUTES
wrangler kv namespace create ROUTES --preview # For local dev
# D1 database for analytics
wrangler d1 create bifrost-analytics
# R2 buckets (create only the ones you need)
wrangler r2 bucket create files # Default file serving
wrangler r2 bucket create assets # Brand/static assets
wrangler r2 bucket create bifrost-backups # Automated backups
# Optional per-user buckets:
# wrangler r2 bucket create files-user1
# wrangler r2 bucket create files-user2Replace all placeholder IDs with the values from Step 2:
# KV namespace (paste your IDs)
[[kv_namespaces]]
binding = "ROUTES"
id = "paste-your-kv-namespace-id"
preview_id = "paste-your-kv-preview-id"
# D1 database (paste your ID)
[[d1_databases]]
binding = "DB"
database_name = "bifrost-analytics"
database_id = "paste-your-d1-database-id"
# R2 buckets (remove any you don't need)
[[r2_buckets]]
binding = "FILES_BUCKET"
bucket_name = "files"
[[r2_buckets]]
binding = "ASSETS_BUCKET"
bucket_name = "assets"
[[r2_buckets]]
binding = "BACKUP_BUCKET"
bucket_name = "bifrost-backups"
# Set your admin API domain
[vars]
ENVIRONMENT = "production"
ADMIN_API_DOMAIN = "bifrost.yourdomain.com"Also update the [env.dev] section with your dev domain.
Tip: Remove any R2 bucket bindings and service bindings you don't need. The worker only requires KV (ROUTES) and D1 (DB) as minimum bindings.
The example [env.dev] repeats every binding with isolated placeholder
resources because Wrangler environments do not inherit bindings. Replace both
production and development placeholders before using either deployment target;
keep the development D1/KV/R2/service resources separate from production.
Edit src/types.ts to list your domains:
export const SUPPORTED_DOMAINS = [
'yourdomain.com',
'link.yourdomain.com',
'bifrost.yourdomain.com', // Admin API domain
] as const;Also update the R2 bucket arrays and BUCKET_BINDINGS map if you changed the bucket configuration.
Optional: CDN Cache Purge — To enable global cache purge for R2 objects, configure zone IDs and R2 custom domains in src/types.ts:
export const CLOUDFLARE_ZONE_IDS: Record<string, string> = {
'yourdomain.com': 'your-zone-id-from-cloudflare-dashboard',
};
export const R2_BUCKET_CUSTOM_DOMAINS: Record<string, string[]> = {
files: ['files.yourdomain.com'], // If you have R2 custom domains
};Then set up Custom Domains in the Cloudflare Dashboard to route traffic from your domains to the worker.
# Apply all migrations to production D1
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0000_large_slipstream.sql
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0001_add_analytics_fields.sql
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0002_analytics_indexes.sql
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0003_add_query_string.sql
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0004_file_downloads.sql
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0005_proxy_requests.sql
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0006_audit_logs.sql
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0007_add_cache_status.sql
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0008_file_comments.sql
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0009_feedback.sql
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0010_external_audit_capture.sql
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0011_unified_traffic_events.sql
# For local dev, use --local instead of --remote# Set your admin API key (you'll be prompted to enter it)
wrangler secret put ADMIN_API_KEY
# Optional: Set Cloudflare API token for CDN cache purge
# (requires Zone > Cache Purge permission)
wrangler secret put CLOUDFLARE_API_TOKEN
# Deploy
pnpm run deploy# Health check
curl https://bifrost.yourdomain.com/health
# Create your first route
curl -X POST https://bifrost.yourdomain.com/api/routes \
-H "X-Admin-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"domain": "yourdomain.com",
"path": "/github",
"type": "redirect",
"target": "https://github.com/YOUR-USERNAME",
"statusCode": 302
}'The admin dashboard is a React SPA that connects to your Bifrost API.
Its home page is an operational overview rather than a raw event dump. It shows canonical source URLs (domain plus path), Top Routes - Redirect, Top Routes - Proxy, Top Website Pages for service-bound HTML, recent activity, period comparisons, leading domains/countries/referrers, and actionable signals such as proxy 5xx rates, low R2 cache-hit rates, scanner-like paths, and material traffic changes. Cloudflare Health Checks are excluded by default and can be restored with the labelled toggle. Dashboard analytics inherit the same admin API-key middleware as route management.
# Create admin/.env.local
cat > admin/.env.local << 'EOF'
VITE_API_URL=https://bifrost.yourdomain.com
VITE_ADMIN_API_KEY=your-admin-api-key
EOF
# Development
pnpm --filter admin dev # Runs on port 3001
# Production (Docker)
docker build \
--build-arg VITE_API_URL=https://bifrost.yourdomain.com \
--build-arg VITE_ADMIN_API_KEY=your-api-key \
-f admin/Dockerfile \
-t bifrost-dashboard:latest .The MCP server lets you manage routes through Claude Code or Claude Desktop using natural language.
# Build the MCP server
pnpm -C shared build
pnpm -C mcp buildAdd to your Claude Code config (~/.claude.json):
{
"mcpServers": {
"bifrost": {
"command": "node",
"args": ["/absolute/path/to/bifrost-router/mcp/dist/index.js"],
"env": {
"EDGE_ROUTER_API_KEY": "your-admin-api-key",
"EDGE_ROUTER_URL": "https://bifrost.yourdomain.com",
"EDGE_ROUTER_DOMAIN": "yourdomain.com"
}
}
}
}For Claude Desktop, add the same entry (with full executable paths) to ~/Library/Application Support/Claude/claude_desktop_config.json and restart the app. Or open this repo in Claude Code and ask it to "install mcp" — it will configure both surfaces for you.
See mcp/README.md for full setup and the 29-tool reference.
The Slackbot lets your team manage routes via Slack messages.
- Create a Slack App with Events API enabled
- Create a KV namespace for permissions:
wrangler kv namespace create SLACK_PERMISSIONS - Update
slackbot/wrangler.tomlwith your KV, D1 IDs andEDGE_ROUTER_URL - Set secrets:
cd slackbot wrangler secret put SLACK_SIGNING_SECRET wrangler secret put SLACK_BOT_TOKEN wrangler secret put ADMIN_API_KEY - Deploy:
wrangler deploy(fromslackbot/directory)
A GitHub Actions template is provided at .github/workflows/ci-cd.yml.example.
- Rename to
ci-cd.yml - Add repository secrets:
CLOUDFLARE_API_TOKEN— Cloudflare API token with Workers Edit scopeCLOUDFLARE_ACCOUNT_ID— Your Cloudflare account IDADMIN_API_KEY— For admin dashboard build
The active CI pipeline (.github/workflows/ci.yml) runs secret and public-sanitisation
scans, lint/format/type checks, tests with locked coverage floors, the dashboard
build, analytics/routing/dormant-path performance gates, and production plus
development Wrangler dry-runs on every PR and push. It does not deploy.
By default, the audit log only records operations made through Bifrost. This optional feature also captures R2 changes made outside it — Cloudflare dashboard uploads, Wrangler commands, direct S3/REST API keys — into the same audit page, labelled by source. It ships dormant (both flags "off"); enabling it is a two-layer opt-in:
Layer 1 — object-level capture (requires the Workers Paid plan). Cloudflare Queues are paid-plan-only. On the free plan, wrangler queues create fails with a payment-required error — that is the expected gate, not breakage; skip to Layer 2, which works on any plan.
# 1. Create the queues (60s delivery delay is load-bearing — do not omit it)
wrangler queues create bifrost-r2-events --delivery-delay-secs 60
wrangler queues create bifrost-r2-events-dlq
# 2. Attach notification rules to each bucket you want monitored
wrangler r2 bucket notification create <bucket> \
--event-types object-create object-delete --queue bifrost-r2-events
# 3. Uncomment the [[queues.consumers]] block in wrangler.toml
# 4. Apply the migration (once per environment)
wrangler d1 execute bifrost-analytics --remote --file=./drizzle/0010_external_audit_capture.sql
# 5. Set R2_EVENT_AUDIT = "on" in wrangler.toml [vars] and deployExternal writes then appear on the audit page within ~2 minutes as External (unattributed) — Cloudflare's event payloads carry no actor identity on any plan, so what/when/where is captured but who is not (that's what Layer 2 adds for config changes).
Layer 2 — config-change capture with real actor attribution (works on the free plan). A */30 cron polls your account's audit logs for R2/queue-scoped changes (bucket settings, notification rules, token changes) and records them with the actor's email/IP — it also tamper-protects Layer 1, since deleting the notification rules is itself a captured change.
- Create a Cloudflare API token — two non-obvious traps here:
- It must be a user-level token (My Profile → API Tokens → Create Custom Token). Account-owned tokens (created from the account-level API Tokens page,
cfat_prefix) are rejected by the audit-logs endpoint even when valid. - There is no dedicated "audit logs" permission. The permission you need is Account → Account Settings → Read ("Access: Audit Logs Read" is the Zero Trust product's login logs — the wrong one).
- It must be a user-level token (My Profile → API Tokens → Create Custom Token). Account-owned tokens (created from the account-level API Tokens page,
wrangler secret put CF_AUDIT_API_TOKEN(paste the token), setCF_ACCOUNT_IDin[vars].- Apply the migration (step 4 above, if you haven't), set
CF_AUDIT_POLL = "on", deploy.
Rollback for either layer: flip its flag to "off" and redeploy (~90s). Notification rules and queues can stay.
curl -X POST https://bifrost.yourdomain.com/api/routes \
-H "X-Admin-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"path": "/github",
"type": "redirect",
"target": "https://github.com/your-username"
}'curl -X POST https://bifrost.yourdomain.com/api/routes \
-H "X-Admin-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"path": "/blog/*",
"type": "proxy",
"target": "https://your-blog.com",
"preservePath": true,
"cacheControl": "public, max-age=60"
}'curl -X POST "https://bifrost.yourdomain.com/api/routes/migrate?domain=yourdomain.com&oldPath=/old&newPath=/new" \
-H "X-Admin-Key: your-api-key"All admin endpoints require X-Admin-Key header or Authorization: Bearer <key>.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/routes |
List routes (?search=, ?type=, ?enabled=, ?limit=, ?offset=, ?domain=) |
GET |
/api/routes?path= |
Get single route |
POST |
/api/routes |
Create route |
PUT |
/api/routes?path= |
Update route |
DELETE |
/api/routes?path= |
Delete route |
POST |
/api/routes/migrate |
Migrate route to new path |
POST |
/api/routes/transfer |
Transfer route between domains |
POST |
/api/routes/normalize-case |
One-time migration: convert all route paths to lowercase (run after upgrading to v1.22.0+ if you have pre-existing uppercase routes) |
GET |
/api/routes/by-target |
Find routes serving an R2 object (?bucket=&target=) |
POST |
/api/routes/seed |
Bulk import routes |
GET |
/api/analytics/summary |
Domain-aware operational overview (?domain=&days=&country=&search=&includeMonitoring=) |
GET |
/api/analytics/clicks |
Click records (paginated) |
GET |
/api/analytics/views |
View records (paginated) |
GET |
/api/analytics/clicks/:slug |
Stats for specific link |
GET |
/api/storage/buckets |
List all R2 buckets |
GET |
/api/storage/:bucket/objects |
List objects (?prefix=, ?cursor=, ?limit=, ?delimiter=) |
GET |
/api/storage/:bucket/meta/:key |
Get object metadata |
GET |
/api/storage/:bucket/objects/:key |
Download object |
POST |
/api/storage/:bucket/upload |
Upload object (multipart, 100MB max) |
DELETE |
/api/storage/:bucket/objects/:key |
Delete object |
POST |
/api/storage/:bucket/rename |
Rename object within bucket |
POST |
/api/storage/:bucket/move |
Move object to different bucket |
PUT |
/api/storage/:bucket/metadata/:key |
Update object HTTP metadata |
POST |
/api/storage/:bucket/purge-cache/:key |
Purge CDN cache for R2 object |
Migration 0011 adds a privacy-bounded request stream that can measure public
traffic beyond the four legacy event tables. It ships dormant and does not alter
headline totals. To evaluate it safely, apply the migration, set an RFC3339 UTC
UNIFIED_TRAFFIC_CUTOVER_AT, then set UNIFIED_TRAFFIC_MODE = "shadow". The
stream stores domain, normalised path, response classification, coarse country,
cache status, and bounded latency; it does not store query strings, IP addresses,
referrers, User-Agent strings, or target URLs. Set the mode back to "off" to
stop capture. On an environment with the daily 0 20 * * * cron, retention
pruning continues after a valid cutover even while capture is off. The example
development environment has no cron triggers; add that schedule if you keep a
development shadow stream enabled beyond short-lived testing.
{
path: string; // Route path (e.g., "/blog", "/docs/*")
type: "redirect" | "proxy" | "r2";
target: string; // Target URL or R2 key
statusCode?: 301 | 302 | 307 | 308; // Redirect status (default: 302)
preserveQuery?: boolean; // Pass query params (default: true)
preservePath?: boolean; // Preserve path for wildcards (default: false)
hostHeader?: string; // Override Host header for proxy routes
forceDownload?: boolean; // Force download for R2 routes (default: false)
bucket?: string; // R2 bucket name (default: "files")
cacheControl?: string; // Cache-Control header
enabled?: boolean; // Enable/disable (default: true)
}pnpm run dev # Local dev server (localhost:8787)
pnpm run check # Full quality, test, build, performance, and dry-run gate
pnpm run typecheck # TypeScript check
pnpm run lint # Lint all packages
pnpm run deploy:dev # Deploy to dev environment| Layer | Technology | Version |
|---|---|---|
| Language | TypeScript | 5.9.3 |
| Framework | Hono | 4.12.34 |
| Runtime | Cloudflare Workers | — |
| CLI | Wrangler | 4.114.0 |
| Validation | Zod | 4.4.3 |
| ORM | Drizzle ORM | 0.45.2 |
| Storage | Cloudflare KV | — |
| Database | Cloudflare D1 (analytics) | — |
| Object Storage | Cloudflare R2 | — |
| Testing | Vitest + @cloudflare/vitest-pool-workers | 4.1.10 / 0.18.8 |
| Linting | Oxlint + Biome (formatter) | — |
| Package Manager | pnpm (workspaces) | 10.33.0 |
| Admin Dashboard | React 19 + Vite 8 + Tailwind CSS 4 + shadcn/ui | — |
MIT
Built with Cloudflare Workers and Hono
