A simple, fast, open-source link shortener powered by Cloudflare Workers and KV.
Built for SillyLittleTech at share.sillylittle.tech, but designed as a reusable template for anyone who wants to host their own link shortener on Cloudflare's edge network.
| Feature | Details |
|---|---|
| β‘ Edge-fast redirects | Served from Cloudflare's global network |
| π Click analytics | Per-link click counter in the admin dashboard |
| π Password protection | Require a password before redirecting |
| β° Link expiry | Set an expiry date/time; expired links are cleaned up automatically |
| ποΈ Folders | Optional folder pages (e.g. /referrals/) that list links |
| π§Ύ Audit log | Tracks create/update/delete events with actor IP |
| β»οΈ Safe deletes | Links are tombstoned and purged automatically after 3 days |
| π Dark / light mode | Follows system preference with a manual toggle |
| π Admin authentication | HTTP Basic Auth secured by a Wrangler secret (defense-in-depth even if you use Cloudflare Access) |
Visitor β share.sillylittle.tech/my-link
β Cloudflare Worker (nearest edge PoP)
β KV namespace LINKIVERSE
β 302 redirect to destination URL
Links are stored in a Cloudflare KV namespace as JSON values under a host-scoped key:
link:{host}:{slug}(e.g.link:share.sillylittle.tech:my-link)
Click Fork on GitHub and clone your fork locally.
npm install# Production namespace
npx wrangler kv namespace create LINKIVERSE
# Development / preview namespace (used by `wrangler dev`)
npx wrangler kv namespace create LINKIVERSE --previewCopy the id values printed by the commands above and update wrangler.toml:
[[kv_namespaces]]
binding = "LINKIVERSE"
id = "your-production-namespace-id"
preview_id = "your-preview-namespace-id"You may have noticed wrangler.toml has two cousins, wrangler.toml.cloud.bac and wrangler.toml.local.bac, This is because when we add a custom domain for production in routes, WRANGLER is really eager to use it, even in dev.
Running npm run toml:toggle or use dev and prod to switch between the versions. Make sure you enforce parody!
Production recommendation: protect the admin dashboard (/admin) at the edge (for
example, using Cloudflare Access). All /api/* routes always require HTTP Basic
Auth and therefore require ADMIN_SECRET to be set.
To enable local Basic Auth instead of an edge solution (for development or if you don't have an edge auth configured), uncomment the local auth block in src/router.js and set the secret:
npx wrangler secret put ADMIN_SECRET
# β Enter your chosen password when promptedThen enable the local flag (in .dev.vars or your environment):
ADMIN_SECRET=your-local-password
ENABLE_LOCAL_ADMIN_AUTH=true
When enabled, visiting /admin will prompt for Basic Auth (any username + the
password you set). By default the local auth block is commented out in the code
to avoid accidental exposure in productionβuncomment it only if you intend to
use local Basic Auth.
Plummer supports serving and managing links across multiple configured hostnames (domains/subdomains).
Set ALLOWED_HOSTS_JSON in wrangler.toml as a JSON array of hostnames:
[vars]
ALLOWED_HOSTS_JSON = "[\"share.sillylittle.tech\",\"links.sillylittle.tech\",\"links.share.sillylittle.tech\"]"The /admin UI will show these in a dropdown when creating links.
If you leave ALLOWED_HOSTS_JSON unset/empty, API writes are restricted to the current request host as a safer default.
To use a custom domain (e.g. share.sillylittle.tech), uncomment and update the
[[routes]] block in wrangler.toml and set the correct zone_id:
[[routes]]
pattern = "share.sillylittle.tech/*"
zone_id = "..."The domain must be added to your Cloudflare account and DNS must point to Cloudflare.
npm run deploy
# or: npx wrangler deployFor local development:
npm run dev
# or: npx wrangler devFor local dev secrets, you can also use a .dev.vars file (Wrangler reads it automatically):
ADMIN_SECRET=your-local-password
ENABLE_DEBUG_ENDPOINTS=true
FORCE_DELETE_KEY=optional-testing-keyThe included workflow (.github/workflows/deploy.yml) automatically deploys to
Cloudflare Workers on every push to main.
Add the following repository secrets in your GitHub repo settings (Settings β Secrets and variables β Actions):
| Secret | Where to find it |
|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare Dashboard β My Profile β API Tokens β create a token with Workers: Edit permission |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare Dashboard β right-hand sidebar on the Workers overview page |
Note:
ADMIN_SECRETis stored as a Wrangler secret (step 4 above) and is not a GitHub Actions secret β Wrangler secrets are separate from GitHub secrets.
plummer/
βββ src/
β βββ index.js # Worker entrypoint (fetch + scheduled purge)
β βββ router.js # Routing for /admin, /api, redirects, folders, debug endpoints
β βββ security.js # Basic auth + response security headers
β βββ kv.js # KV storage helpers (host-scoped keys)
β βββ audit.js # Audit event storage + listing
β βββ pages/ # HTML pages (home/admin/errors/folders)
βββ .github/
β βββ workflows/
β βββ deploy.yml # GitHub Actions β Cloudflare Workers
βββ wrangler.toml # Wrangler / Worker configuration
βββ package.json
βββ README.md
All API routes require HTTP Basic Auth (same credentials as the admin dashboard).
| Method | Path | Description |
|---|---|---|
GET |
/api/links |
List all links (JSON array) |
POST |
/api/links |
Create a new link (JSON body) |
PATCH |
/api/links/:slug |
Update a link (destination, expiry, folder, password, status) |
POST |
/api/links/:slug/rename |
Rename a link slug |
DELETE |
/api/links/:slug?host=... |
Schedule deletion (3-day retention) |
GET |
/api/folders?host=... |
List folders for a host |
POST |
/api/folders |
Create folder |
PATCH |
/api/folders/:slug |
Update folder (name, listingEnabled, password) |
DELETE |
/api/folders/:slug?host=... |
Delete folder |
GET |
/api/audit?limit=... |
List recent audit events |
Debug endpoints are disabled by default. To enable them, set ENABLE_DEBUG_ENDPOINTS=true.
Some debug endpoints may additionally require FORCE_DELETE_KEY.
{
"slug": "my-link", // required β letters, numbers, - and _ only
"guest": "https://...", // required β must be http or https
"expiresAt": 1714600000000, // optional β Unix ms timestamp (must be future)
"password": "secret123" // optional β plain text; stored as SHA-256 hash
}MIT β see LICENSE.
{ "host": "share.sillylittle.tech", "slug": "my-link", "guest": "https://example.com", "passwordHash": null, // SHA-256 of password, or null "expiresAt": null, // Unix ms timestamp, or null "clicks": 42, "createdAt": 1714500000000 }