Cloudsteading
Product image still needed. This listing has source documentation, but no reviewed screenshot yet.

Relay

Manage short links and inspect clicks with a Worker API and static admin page.

Relay is a self-hosted Bitly/Rebrandly alternative built on Cloudflare (D1, Workers). Free tier eligible within limits. Inspect the source and license in the linked repository.

Source & license

Upstream license: MIT

License TL;DR

You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.

Explain MIT in plain English →

Summary of the main license. Separate packages and assets can have different terms.

Inspect repository ↗Read this project’s actual license ↗

Repository owner

@YuriCrystal

See the upstream repository for the original creator and contributors.

Maintain this project? Maintainer verification →

Cloudflare hosting

Free tier eligible within limits

The documented Relay deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs.

Hosting requirements
  • Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits.
  • Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows.
  • Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations.
Check current pricing ↗
Sources checked 01/10/2026

Repository snapshot: 7fb2f44. Hosting eligibility reflects the deployment documentation and listed assumptions.

  • bitly ↗

    A link shortener you fully own, running on the Cloudflare edge. A **single Worker** is both the redirect engine and the admin API, data lives in **D1**, and the admin dashboard is a **single `index.html`** (zero build, zero external dependencies).

  • rebrandly ↗

    A link shortener you fully own, running on the Cloudflare edge. A **single Worker** is both the redirect engine and the admin API, data lives in **D1**, and the admin dashboard is a **single `index.html`** (zero build, zero external dependencies).

  • short-io ↗

    A link shortener you fully own, running on the Cloudflare edge. A **single Worker** is both the redirect engine and the admin API, data lives in **D1**, and the admin dashboard is a **single `index.html`** (zero build, zero external dependencies).

  • workers ↗

    name = "relay" main = "worker.js" compatibility_date = "2024-09-23" # ── D1 資料庫綁定 ────────────────────────────────────────────── # 用「Deploy to Cloudflare」按鈕或 `wrangler deploy` 部署時,沒填 database_id 會自動建立一個。 # 手動 CLI:先 `wrangler d1 create relay`,把回傳的 id 解開下面那行貼上。 [[d1_databases]] binding = "DB" database_name = "relay" # database_id = "<your-d1-database-id>" # ── 非機密變數

  • d1 ↗

    y to Cloudflare」按鈕或 `wrangler deploy` 部署時,沒填 database_id 會自動建立一個。 # 手動 CLI:先 `wrangler d1 create relay`,把回傳的 id 解開下面那行貼上。 [[d1_databases]] binding = "DB" database_name = "relay" # database_id = "<your-d1-database-id>" # ── 非機密變數 ───────────────────────────────────────────────── [vars] # 後台網域;留 "*" 放行所有來源(API 仍需 Bearer token)。上線後建議改成你的後台網址。 DASH_ORIGIN = "*" # 後台統計時區偏移(小時)。台灣 +8;其他地區改成你的時區(例:美西 -8、UTC 0)。 TZ_OFFSET = "8" # 目的地網域黑名單(逗號分隔,含子網域)。留空=不啟用。例:"bit.ly,grabify.link" BLOCKLIST = "" # 尊重 Do-N

  • free-tier-eligible ↗

    name = "relay" main = "worker.js" compatibility_date = "2024-09-23" # ── D1 資料庫綁定 ────────────────────────────────────────────── # 用「Deploy to Cloudflare」按鈕或 `wrangler deploy` 部署時,沒填 database_id 會自動建立一個。 # 手動 CLI:先 `wrangler d1 create relay`,把回傳的 id 解開下面那行貼上。 [[d1_databases]] binding = "DB" database_name = "relay" # database_id = "<your-d1-database-id>" # ── 非機密變數

  • free-tier-eligible ↗

    y to Cloudflare」按鈕或 `wrangler deploy` 部署時,沒填 database_id 會自動建立一個。 # 手動 CLI:先 `wrangler d1 create relay`,把回傳的 id 解開下面那行貼上。 [[d1_databases]] binding = "DB" database_name = "relay" # database_id = "<your-d1-database-id>" # ── 非機密變數 ───────────────────────────────────────────────── [vars] # 後台網域;留 "*" 放行所有來源(API 仍需 Bearer token)。上線後建議改成你的後台網址。 DASH_ORIGIN = "*" # 後台統計時區偏移(小時)。台灣 +8;其他地區改成你的時區(例:美西 -8、UTC 0)。 TZ_OFFSET = "8" # 目的地網域黑名單(逗號分隔,含子網域)。留空=不啟用。例:"bit.ly,grabify.link" BLOCKLIST = "" # 尊重 Do-N

  • free-tier-eligible ↗

    up>1, 2, 3, 4</sup> | Duration | CPU time | | --- | --- | --- | --- | | **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation | | **Standard** | 10 million included per month <br> +$0.30 per additional million | No charge or limit for duration | 30 million CPU milliseconds included per month<br> +$0.02 per additional million CPU milliseconds<br><br> Max of [5 minutes of CPU time](https://developers.cloudflare.com/workers/platform/limits/#account-plan-limits) per invocation (default: 30 seconds)<br> Max of 15 minutes of CPU time per [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) or [Queue Consumer](https://developers.cloudflare.co

  • free-tier-eligible ↗

    oudflare.com/workers/platform/pricing/#workers) | | --- | --- | --- | | Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows | | Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows | | Storage (per GB stored) | 5 GB (total) | First 5 GB included + $0.75 / GB-mo | Track your D1 usage To accurately track your usage, use the [meta object](https://developers.cloudflare.com/d1/worker-api/return-object/), [GraphQL Analytics API](https://developers.cloudflare.com/d1/observability/metrics-analytics/#query-via-the-graphql-api), or the [Cloudflare dashboard ↗︎](https://dash.cloudflare.com/?to=/:account/workers/d1/). Select your D1 database, then vie

  • MIT ↗

    MIT License Copyright (c) 2026 Relay Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMEN

  • architecture ↗

    name = "relay" main = "worker.js" compatibility_date = "2024-09-23" # ── D1 資料庫綁定 ────────────────────────────────────────────── # 用「Deploy to Cloudflare」按鈕或 `wrangler deploy` 部署時,沒填 database_id 會自動建立一個。 # 手動 CLI:先 `wrangler d1 create relay`,把回傳的 id 解開下面那行貼上。 [[d1_databases]] binding = "DB" database_name = "relay" # database_id = "<your-d1-database-id>" # ── 非機密變數

  • architecture ↗

    y to Cloudflare」按鈕或 `wrangler deploy` 部署時,沒填 database_id 會自動建立一個。 # 手動 CLI:先 `wrangler d1 create relay`,把回傳的 id 解開下面那行貼上。 [[d1_databases]] binding = "DB" database_name = "relay" # database_id = "<your-d1-database-id>" # ── 非機密變數 ───────────────────────────────────────────────── [vars] # 後台網域;留 "*" 放行所有來源(API 仍需 Bearer token)。上線後建議改成你的後台網址。 DASH_ORIGIN = "*" # 後台統計時區偏移(小時)。台灣 +8;其他地區改成你的時區(例:美西 -8、UTC 0)。 TZ_OFFSET = "8" # 目的地網域黑名單(逗號分隔,含子網域)。留空=不啟用。例:"bit.ly,grabify.link" BLOCKLIST = "" # 尊重 Do-N

What it can replace

Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.

Bitly logoBitly ↗

Short-link management, redirect routing and click reporting; complete commercial integrations, enterprise controls and managed operations are excluded.

See supporting source ↗
Short.io logoShort.io ↗

Short-link management, redirect routing and click reporting; complete commercial integrations, enterprise controls and managed operations are excluded.

See supporting source ↗
external SaaS target
varies
→ D1 + Workers
external SaaS target
varies
→ D1 + Workers
external SaaS target
varies
→ D1 + Workers

How it works

The shape of Relay on Cloudflare, and how it stacks up against the rented tools it replaces.

Architecture

Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.

View upstream source ↗
Public interface
Configured entry points1
relay
wrangler.toml
↓
App
relay
entry
Cloudflare Workers
Entrypoint: worker.js
↓

Configuration and workflow sources

Reviewed commit 7fb2f441e19e. Files were read as data; upstream applications and CI jobs were not executed.

Deployment configuration · 1 files
wrangler.toml ↗

Cloudflare Workers · compatibility 2024-09-23

relay · default

Entrypoint: worker.js

  • DB → D1

Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.

Runtime source · handlers, binding usage and workflow steps

Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.

worker.js ↗
  • L17 · fetch handler exported · calls filter, url.pathname.split, handleApi, parts.slice, handleTrack, handleRedirect
  • L50 · scheduled handler exported · calls ctx.waitUntil, pruneOld
  • L57 · handleRedirect calls (conditional paths may differ): RESERVED.has, notFound, lookupLink, Date.parse, Date.now, htmlResponse, pageExpired, url.searchParams.get, sha256, url.searchParams.has, pagePassword, request.headers.get, parseUA, pickTarget, applyUtm, isBot, optedOut, ctx.waitUntil, recordClick, pageInterstitial
  • L108 · pickTarget calls (conditional paths may differ): safeParse, variants.filter, valid.reduce, Number, Math.random, toUpperCase, String, Array.isArray
  • L142 · applyUtm calls (conditional paths may differ): safeParse, Object.entries, u.searchParams.set, u.toString
  • L160 · recordClick calls (conditional paths may differ): now.toISOString, localDay, now.getTime, request.headers.get, refHost, slice, sha256, run, bind, env.DB.prepare, suffix.slice, localHour, referrer.slice, console.error
  • L194 · handleTrack calls (conditional paths may differ): request.headers.get, Object.fromEntries, ct.includes, request.json, request.formData, normSlug, lookupLink, run, bind, env.DB.prepare, slice, String, Number, now.toISOString, localDay, now.getTime, console.error
  • L242 · convStats calls (conditional paths may differ): first, bind, env.DB.prepare, all
  • L262 · handleApi calls (conditional paths may differ): corsHeaders, request.headers.get, auth.replace, json, apiOverview, apiList, apiCreate, request.json, Number, apiGet, apiUpdate, apiDelete, url.searchParams.get, apiStats, apiExport, String
  • L311 · apiOverview calls (conditional paths may differ): first, env.DB.prepare, localDay, Date.now, bind, uniqueCount, all, daysAgo
  • L345 · apiList calls (conditional paths may differ): all, env.DB.prepare, map
  • L354 · apiGet calls (conditional paths may differ): first, bind, env.DB.prepare, shapeLink
  • L360 · apiCreate calls (conditional paths may differ): normSlug, first, bind, env.DB.prepare, toISOString, normFields, run
  • L381 · apiUpdate calls (conditional paths may differ): first, bind, env.DB.prepare, normSlug, normFields, toISOString, run, invalidate
  • L414 · apiDelete calls (conditional paths may differ): first, bind, env.DB.prepare, run, invalidate
  • L423 · apiStats calls (conditional paths may differ): first, bind, env.DB.prepare, daysAgo, all, Promise.all, grp, uniqueCount, convStats, shapeLink
  • L475 · normFields calls (conditional paths may differ): includes, String, Array.isArray, safeParse, map, arr.filter, Number, valid.forEach, assertHttp, JSON.stringify, filter, slice, toUpperCase, rules.forEach, hostOf, blocklistedHost, safeBrowsingBad, sha256, normSlug, pick
  • L553 · shapeLink calls (conditional paths may differ): safeParse
  • L579 · pageInterstitial calls (conditional paths may differ): JSON.stringify, escapeAttr, encodeURIComponent
  • L603 · pagePassword calls (conditional paths may differ): escapeAttr
  • L625 · parseUA calls (conditional paths may differ): ua.toLowerCase, test
  • L642 · isBot calls (conditional paths may differ): test
  • L653 · optedOut calls (conditional paths may differ): request.headers.get
  • L658 · sha256 calls (conditional paths may differ): crypto.subtle.digest, encode, join, map, padStart, b.toString
  • L663 · normSlug calls (conditional paths may differ): replace, trim, String
  • L670 · safeParse calls (conditional paths may differ): JSON.parse
  • L675 · pick calls (conditional paths may differ): String
  • L681 · tzOffsetHours calls (conditional paths may differ): Number, Number.isFinite
  • L685 · localDay calls (conditional paths may differ): slice, toISOString, tzOffsetHours
  • L688 · localHour calls (conditional paths may differ): getUTCHours, tzOffsetHours
  • L691 · daysAgo calls (conditional paths may differ): localDay, Date.now
  • L696 · assertHttp calls (conditional paths may differ): String
  • L705 · hostOf calls (conditional paths may differ): hostname.toLowerCase, String
  • L710 · blocklistedHost calls (conditional paths may differ): String, filter, map, raw.split, toLowerCase, s.trim, list.some, host.endsWith
  • L718 · safeBrowsingBad calls (conditional paths may differ): fetch, encodeURIComponent, JSON.stringify, urls.map, res.json, bad.add
  • L748 · lookupLink calls (conditional paths may differ): env.LINKS_KV.get, first, bind, env.DB.prepare, Math.max, Number, env.LINKS_KV.put, JSON.stringify
  • L763 · invalidate calls (conditional paths may differ): env.LINKS_KV.delete
  • L770 · uniqueCount calls (conditional paths may differ): first, bind, env.DB.prepare
  • L780 · apiExport calls (conditional paths may differ): url.searchParams.get, Number, conds.push, binds.push, Number.isFinite, daysAgo, conds.join, all, bind, env.DB.prepare, cols.join, toLowerCase, JSON.stringify, String, test, s.replace, lines.push, join, cols.map, esc
  • L808 · pruneOld calls (conditional paths may differ): Number, Number.isFinite, daysAgo, run, bind, env.DB.prepare, console.error

Environment references: env.SALT · env.ADMIN_TOKEN · env.DB · env.CONVERSION_TOKEN · env.RESPECT_DNT · env.TZ_OFFSET · env.BLOCKLIST · env.SAFEBROWSING_KEY · env.LINKS_KV · env.CACHE_TTL · env.RETENTION_DAYS · env.DASH_ORIGIN

Build and deployment pipeline · 1 GitHub Actions workflows

Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.

CI · .github/workflows/ci.yml ↗

Triggers: push, pull_request

test · no job dependencies declared

  1. actions/checkout@v4actions/checkout@v4
  2. actions/setup-node@v4actions/setup-node@v4
  3. Shell commandnode --test

Full upstream document by @YuriCrystal · README.md · snapshot 7fb2f44

Relay · self-hosted link shortener

English · 繁體中文

CI Deploy to Cloudflare

A link shortener you fully own, running on the Cloudflare edge. A single Worker is both the redirect engine and the admin API, data lives in D1, and the admin dashboard is a single index.html (zero build, zero external dependencies).

Just open index.html in a browser to try it — it defaults to DEMO mode (fake data; create / edit / view analytics). Follow the steps below to go live for real in about 10 minutes.

Core idea: a short link isn't just "making a URL shorter" — it's a relay station you control. Every link passes through your station before redirecting out, so you can track performance, change destinations any time, and build a retargeting audience.


Why Relay?

Link shorteners are everywhere — so why self-host one? Because with a free link shortener, you're really paying with your own traffic data — and the features you actually want are usually locked behind a paywall. Relay puts that relay station back in your own hands:

Change the destination without changing the link Campaign rotates, landing page moves, a partner drops out — repoint the link from the dashboard any time. Links you've already posted or printed never need to change.

See which channel actually drives clicks Add a suffix to the same link to split sources: /spring/ig, /spring/threads, /spring/edm each count separately. Which platform, which creator works — read it off the numbers instead of guessing.

Know which channel actually converts Clicks are only half the story. Report signups / sales back with a cookieless /track postback and the dashboard shows conversions and conversion rate per /suffix — so you see which channel drives results, not just traffic. No cookies, no cross-site identity: the thing cookie-based tools can't do privacy-first.

Turn clickers into your retargeting audience Links can carry FB Pixel / GA4 / GTM — visitors are written into your retargeting list before they even reach the destination. Every click becomes an audience touchpoint instead of leaking away.

A/B test, and route by device or country Weighted split to see which variant converts; or send traffic to different destinations by device (iOS / Android) and by country (request.cf.country) — one link, the right landing page per audience.

Your data, 100% yours Links, clicks, and audience all go into your own Cloudflare D1. No third party reading your traffic, nobody moving features behind a paywall, no links dying because a service shut down.

Free, no limits, your domain Runs on your own Cloudflare free tier (100k redirects/day is plenty), no cap on the number of links, and you can attach your own short domain (go.yourbrand.com).

Who it's for

  • Creators, marketers, and small teams running multiple platforms / accounts who want to know where attention and results come from
  • People who value data ownership and don't want to be locked into — or priced up by — a SaaS
  • Anyone who wants short-link infrastructure they fully own and can modify and extend

Who it's not for (straight talk)

  • If you just shorten the occasional URL and don't care about data — a ready-made service like Bitly is less hassle
  • If you don't want to touch a terminal at all — Relay needs a few wrangler commands (~10 min) to go live

Features

  • Link redirect — edge redirects, low latency worldwide
  • Change destination without changing the link — repoint a posted link any time from the dashboard
  • Weighted A/B split — randomly send the same link to different versions by weight
  • Device & geo routing — send iOS / Android and per-country (request.cf) traffic to different destinations
  • /suffix source tracking — add a suffix (e.g. /spring/ig) to count sources separately and see which channel works
  • Marketing pixel interstitial — FB Pixel / GA4 / GTM: clickers are added to your retargeting list before they reach the site
  • Password protection, expiry, 301 / 302
  • UTM builder, QR code
  • Analytics — device / OS / referrer / country / hour / suffix / A-B variant
  • No link cap, custom domain, data 100% in your own hands
  • Cookieless conversion tracking — attribute signups/sales to links and channels via a /track postback, zero cookies
  • Unique visitors — total clicks plus a privacy-preserving daily unique count (IP / UA never stored)
  • CSV / JSON export — download your raw click data any time
  • Optional edge cache & auto-retention — KV-cached redirects for scale; Cron-pruned old clicks

Files

relay/
├─ worker.js       redirect engine + admin API (deploys to Cloudflare Workers)
├─ schema.sql      D1 tables
├─ wrangler.toml   Worker config (committed; D1 auto-provisions, secrets stay out)
├─ index.html      single-file admin (drop on Cloudflare Pages, or open locally)
└─ README.md

Deploy

One-click: click the Deploy to Cloudflare button above — it forks the repo, provisions D1 (and KV if you enable it), and deploys. Then run the two commands below to load the schema and set your ADMIN_TOKEN.

Or step by step (~10 minutes):

0. Prerequisites

npm i -g wrangler
wrangler login

1. Create D1 and import the schema

wrangler d1 create relay
# Paste the returned database_id into wrangler.toml (uncomment the database_id line)

wrangler d1 execute relay --remote --file=./schema.sql        # cloud
# wrangler d1 execute relay --local  --file=./schema.sql       # local testing

2. Set the admin secret

wrangler secret put ADMIN_TOKEN
# Enter a long random string — this is your admin login key

3. Deploy the Worker

wrangler deploy
# You get a URL, e.g. https://relay.<your-subdomain>.workers.dev

4. Connect the dashboard

  1. Open index.html in a browser (or drop it on Cloudflare Pages).
  2. Go to Settings on the left and fill in:
    • Worker API URL: the URL from the previous step
    • Admin key: the ADMIN_TOKEN you set in step 2
  3. Click Test connection — success means you're done. The key lives only in your browser's localStorage.

Custom short domain (optional)

  1. Add your domain to Cloudflare (e.g. relay.to).
  2. Uncomment the [[routes]] block at the bottom of wrangler.toml and set pattern.
  3. wrangler deploy. Your short links are now https://relay.to/spring.

API (all require Authorization: Bearer <ADMIN_TOKEN>)

Method Path Description
GET /api/overview summary numbers + 14-day trend + top links
GET /api/links link list (with click counts)
POST /api/links create a link
GET /api/links/:id single link
PATCH /api/links/:id update
DELETE /api/links/:id delete (along with its click records)
GET /api/stats/:id?days=30 trend + device/OS/country/referrer/suffix/variant/hour
GET /api/export?format=csv|json&id=&days= export clicks (CSV or JSON; optional id / days filters)

Public redirect: GET /:slug or GET /:slug/:suffix (suffix tracks the source, e.g. /spring/ig).

Conversion postback (public, no Bearer): POST /track or GET /track?slug=… — report a conversion for a slug / suffix (see Conversion tracking). Optionally gated by a CONVERSION_TOKEN.


Before going live

  1. Destination URLs: on create/update, only http(s) is accepted, blocking abusable schemes like javascript: / data: at the source.
  2. Abuse protection: a destination-domain blocklist is built in — set BLOCKLIST = "a.com,b.com" in wrangler.toml to block those domains (and subdomains), zero key needed. For more, set SAFEBROWSING_KEY (wrangler secret put) and links are checked against Google Safe Browsing on creation; leave it unset = disabled, still runs fine.
  3. Timezone: dashboard stats (today's clicks / daily trend / hour heatmap) use TZ_OFFSET in wrangler.toml, default +8 (Taiwan). Change it to your timezone elsewhere.

The QR code is generated locally in the browser by the inlined qrcode-generator (MIT) — it hits no third-party endpoint, so your link targets never leak and you don't depend on an external service staying up.


Security notes

  • The admin API requires a Bearer token throughout; the token is stored via wrangler secret, never committed.
  • Link passwords are stored hashed as sha256(slug + ':' + password), never in plaintext.
  • All D1 queries use bound prepared statements to avoid SQL injection.
  • Destination URLs on the interstitial page are safely embedded via JSON.stringify / attribute escaping to avoid XSS.
  • Destinations only allow http / https, blocking dangerous schemes like javascript: / data: at the source.
  • The QR code is generated locally in the browser (inlined qrcode-generator), hitting no external endpoint.
  • robots.txt defaults to Disallow: /, so short links aren't indexed by search engines.

Conversion tracking (cookieless)

Relay attributes conversions to links without a single cookie. When someone completes an action on your destination (signup, purchase…), your site reports it back — server-to-server, aggregate, no cross-site identity:

curl -X POST https://<your-relay>/track \
  -H 'content-type: application/json' \
  -d '{"slug":"spring","suffix":"ig","event":"signup"}'

Fields: slug (required), suffix (the channel/KOL tag), variant, event (e.g. signup/purchase), value (optional number). A GET /track?slug=… with query params also works (for navigator.sendBeacon / pixels).

The dashboard then shows conversions, conversion rate, and a clicks→conversions table per channel — so you see which /suffix actually converts, not just which gets traffic. That's the thing cookie-based tools can't do privacy-first.

Abuse protection (optional): set a CONVERSION_TOKEN secret (wrangler secret put CONVERSION_TOKEN) and send it as the X-Conversion-Token header (or token field) — recommended for server-side postbacks. Unset = open beacon (fine for trusted/internal use).


Content loop — which channel to double down on

tools/rank.mjs turns your click + conversion data into a ranked channel scoreboard, so you know which /suffix to push next — not just which gets traffic, but which actually converts.

# from a deployed Relay (per-link stats):
curl -s "https://<your-relay>/api/stats/1?days=30" -H "Authorization: Bearer $ADMIN_TOKEN" \
  | node tools/rank.mjs -

# or from an exported file / a hand-built {channels:[...]} JSON:
node tools/rank.mjs data.json

It ranks channels by a Wilson lower bound on conversion rate (so a lucky "1 click, 1 conversion = 100%" never beats a proven channel), flags high-traffic-but-low-conversion sources, and tells you where to lean in. Zero dependencies. See tools/sample.stats.json for the input shape.

Then tools/pick-formula.mjs recommends which social-post formula to write the next post in to maximize reach/CTR — ranked by real break-out-of-follower-bubble evidence, optionally chained to the scoreboard (--data=rank.json):

node tools/pick-formula.mjs --goal=reach        # which formula gets seen by new people
node tools/pick-formula.mjs --no-hype           # skip hype-heavy formulas

The 27-formula framework is in Traditional Chinese and is derived from Hao0321/claude-skill-social-post (MIT) — see tools/CREDITS.md.


Privacy

Relay is built to be privacy-friendly by default — it tracks link clicks, not people:

  • No cookies, no tracking script. Clicks are counted server-side at redirect time; nothing runs in the visitor's browser and no cross-site identifier is set.
  • No IP stored. Only a coarse country code (from Cloudflare's edge) is kept — never the raw IP address.
  • No raw User-Agent stored. Only the derived device / OS / browser is kept; the full UA string (a fingerprinting vector) is discarded.
  • Referrer reduced to its domain. Only the source host (e.g. google.com) is stored — never the full URL with its path or query string.
  • Bots excluded. Link-preview crawlers (Facebook, Slack, Discord, Telegram, etc.) are still redirected so previews work, but aren't counted and don't fire your pixels — so your numbers are real human clicks.
  • Honors opt-out (optional). Set RESPECT_DNT = "1" and visitors sending DNT / Sec-GPC are redirected without being recorded.
  • Your data, your server. Everything lives in your own Cloudflare D1; nobody else can read it.

Marketing pixels (FB / GA4 / GTM) are opt-in per link — only links you attach a pixel to load one, and only for real human visitors.


Scaling & upgrading (optional)

  • Edge cache — bind a KV namespace as LINKS_KV (see wrangler.toml) and redirects read from KV first, cutting D1 reads and latency at scale. Edits still take effect within CACHE_TTL (default 60s). Not bound = always read D1 (instant, current behavior).
  • Auto-retention — set RETENTION_DAYS and enable the [triggers] cron; clicks older than N days are pruned daily. Unset = keep forever.

Upgrading from an earlier version? Run this once against your D1, then re-run schema.sql to add the new conversions table (it uses CREATE TABLE IF NOT EXISTS, so it won't touch existing tables):

ALTER TABLE clicks ADD COLUMN visitor_hash TEXT DEFAULT '';

Fresh installs already include it via schema.sql. Without it, click recording fails silently until the column is added.


Cost

Fully self-hosted, running on your own Cloudflare account — there's no central server, and the author doesn't pay for anyone. For most people it's $0:

  • Workers free tier: 100k requests/day
  • D1 free tier: 5GB storage, millions of row reads per day
  • Pages (hosts the index.html admin): free

You only pay past the free tier, and you pay your own Cloudflare bill — nothing to do with the author or other users. Fork it, fill in your own database_id and ADMIN_TOKEN, and it's 100% yours.


Development

Pure node:test unit tests cover the redirect / privacy / parsing helpers — zero dependencies:

node --test

CI runs them on every push and PR (.github/workflows/ci.yml).


License

MIT © 2026

Free to use, modify, distribute, and sell — just keep the copyright notice. Forks, stars, and issues welcome.

Frequently asked about Relay

What is Relay?+

Relay is a self-hosted Bitly/Rebrandly alternative built on the Cloudflare developer platform. Manage short links and inspect clicks with a Worker API and static admin page.

What does Relay replace?+

Relay is listed as an alternative to Bitly, Rebrandly, Short.io. Compare the features and tradeoffs before migrating.

What Cloudflare primitives does Relay use?+

Relay is built on D1, Workers.

How much does Relay cost to run?+

The documented Relay deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs. Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits. Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows. Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations. Check current Cloudflare pricing before deploying.

Is Relay open source?+

The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/YuriCrystal/relay/7fb2f441e19e7ba446bfbd0cd0e3ca0f53edfb45/LICENSE. Source code and contributor credit are available at https://github.com/YuriCrystal/relay.

Discussion · 0

sign in to comment →
No comments yet — be the first.