A self-hosted URL shortener with an analytics dashboard and a Discord message that keeps itself up to date.
Built on Deno 2 + Hono + SQLite. One process, one database file, two dependencies. No build step, no client-side JavaScript, no Redis, no Postgres.
ββββββββββββ 302 ββββββββββββββββ batched writes ββββββββββββββ
β visitor β ββββββββΆ β Deno app β ββββββββββββββββββΆ β SQLite/WAL β
ββββββββββββ β Hono router β ββββββββββββββ
ββββββββ¬ββββββββ β
β PATCH the same message β read
βΌ every 5 min βΌ
ββββββββββββββββ ββββββββββββββ
β Discord β β dashboard β
ββββββββββββββββ ββββββββββββββ
- Shortening β random slugs from a CSPRNG, or your own custom slug
- Analytics β clicks, unique visitors, referrers, countries, browsers, OS, device type, daily and hourly time series
- Channels β tag a link with where you published it, get per-network stats, and see when the referrers disagree. The network list is yours to edit
- Dashboard β server-rendered, dark/light, works without JavaScript
- Social cards β per-link Open Graph title, description and image, so a short link shows your preview on Discord, X, Slack and the rest
- Discord β a single embed message edited in place, never spammed
- QR codes β SVG, generated server-side, scale to print
- Expiry & disable β per-link, enforced on redirect
- CSV export β per link or everything
- JSON API β with revocable API keys
- Privacy by default β no cookies for visitors, no IP addresses stored
| Choice | Reason |
|---|---|
| Deno 2 | Permissions are enforced by the runtime, not by convention. The process is launched with --allow-read=/var/lib/shorturl and physically cannot read your SSH keys, whatever a dependency tries. Also: TypeScript with no build step, and deno compile yields one static binary. |
| SQLite (WAL) | A shortener is read-heavy with tiny rows. SQLite does hundreds of thousands of redirects per second on one core, has no daemon to patch, and backs up with one command. Postgres here would be a second thing to keep alive for no gain. |
| Hono | ~14 kB router, and portable across Deno/Node/Bun β if Deno ever stops suiting you, the app moves without a rewrite. |
| Discord webhook, not a bot | No bot token to leak, no gateway socket to keep alive, no privileged intents. If the process dies, the next tick simply edits the message again. |
| No client JavaScript | Lets the app ship script-src 'none' in its CSP, which removes XSS as a category rather than as a bug to hunt. Charts are inline SVG. |
Total third-party code: hono and qrcode-generator. Everything else β password hashing, rate
limiting, User-Agent parsing, QR rendering, CSV β is in src/ where you can read it.
deno task hash-passwordcp .env.example .envPut the printed ADMIN_PASSWORD_HASH into .env, then:
deno task startOpen http://localhost:8000/dashboard.
Other tasks:
deno task devdeno task testdeno task checkEverything comes from the environment (or .env). See .env.example for the
annotated list. The ones that matter:
| Variable | Default | Notes |
|---|---|---|
BASE_URL |
http://localhost:8000 |
Public origin. Used for short links, QR payloads and the CSRF origin check. |
HOST / PORT |
127.0.0.1 / 8000 |
Keep on loopback behind a reverse proxy. |
ADMIN_PASSWORD_HASH |
β | From deno task hash-password. Preferred over ADMIN_PASSWORD. |
TRUST_PROXY |
false |
Turn on only behind a proxy you control. See below. |
PUBLIC_SHORTENING |
false |
Let anonymous visitors create links. |
ALLOW_PRIVATE_TARGETS |
false |
Permit shortening to RFC1918 / loopback addresses. |
REDIRECT_STATUS |
302 |
301/308 are cached by browsers forever, which silently stops your analytics. |
ANALYTICS_RETENTION_DAYS |
400 |
0 keeps click rows forever. |
DISCORD_WEBHOOK_URL |
β | Empty disables the reporter. |
DISCORD_UPDATE_INTERVAL_SECONDS |
300 |
Minimum 60. |
X-Forwarded-For is a header β anyone can send one. It is read only when TRUST_PROXY=true,
because trusting it unconditionally would let a visitor spoof their address and thereby defeat both
rate limiting and unique-visitor counting. Set it to true when, and only when, a proxy in front of
the app overwrites that header.
Fill Card title, Card description and Card image URL on a link, and sharing that short link shows your own preview instead of the destination's.
How it works: a 302 carries no body, so a preview crawler following a short link reads the
destination's Open Graph tags. To show a card of our own the crawler has to receive HTML β so when
a link has card data and the caller matches the named crawler list in
src/util/user-agent.ts, it gets an HTML wrapper instead of a redirect.
Everyone else β every human, every script, every uptime monitor β keeps getting the plain 302.
Three properties worth stating plainly, because "serve crawlers something different" deserves scrutiny:
- The wrapper points at the same destination as the redirect, via a meta refresh and a real anchor. Sending crawlers elsewhere would be cloaking and would get the domain flagged; this doesn't.
- It ships
noindex, nofollowβ addressed to Googlebot, Bingbot and Applebot by name rather than as a blanketrobotstag, because preview crawlers read that tag too and X drops the card when it finds one. It never competes with the destination in search results either way:robots.txtkeeps search engines off the whole host. - Leave all three fields empty and nothing changes: the link redirects exactly as it did before. Existing links are untouched by the upgrade.
robots.txt is Disallow: / for everyone except the preview agents, which get their own
Allow: / group. X and LinkedIn obey robots.txt and will not fetch a wrapper they are told to
skip β a blanket disallow means no card on those platforms, while Discord and Facebook, which ignore
robots.txt, would still work and hide the problem. Naming them exposes nothing: a preview crawler
only visits a slug it was handed, and no page here links to another, so the slug space stays
unwalkable.
Practical notes: use https for the image (many platforms silently drop http ones) and aim for
1200Γ630 β X crops to 1.91:1, so a squarer image loses its top and bottom. Prefer PNG or JPEG;
WebP is documented as supported and often is not, in practice. X also shows only the image, the
title and the domain β the description is rendered by Discord and Slack, not by X, and the domain
shown is the shortener's, since the wrapper answers 200 and the crawler never sees the
destination.
Crawlers cache hard β an edit can take minutes to appear, and a platform that already cached the old
card may keep showing it until its own cache expires. X caches per URL for days and has retired its
card validator, so the way to force a re-crawl is a URL it has not seen: append ?v=2, which
changes nothing about the routing. Posting the link in a fresh Discord channel is the quickest way
to see the current version.
The trade-off of the named-crawler list: a brand-new platform shows the destination's card until its agent is added. That is a missing card, never a broken link.
Pick a channel when you create a link and you get per-network numbers on the dashboard, plus a
prefixed slug so the URL says where it went: Twitter yields /twA8f3k, YouTube /ytQ7bnx. The
prefix is cosmetic β the random part keeps its full 7 characters, so a prefixed slug is exactly as
unguessable as a plain one. A custom slug is never rewritten.
The dashboard reports two columns side by side, and they are not the same measurement:
| Comes from | Reliability | |
|---|---|---|
| Declared | the channel you picked on the link | exact β it is your own statement |
| Detected | the Referer header on each click |
partial, and unevenly so |
Detected always reads low. Twitter rewrites links through t.co, and its mobile apps β like most
native apps β send no Referer at all, so those clicks are unattributable by construction. That is
why declaring the channel exists: it is the only figure you can plan against.
The comparison earns its keep in one specific case. A network appearing under detected that you never declared means the link is being shared somewhere you did not put it. Each link's page states this in a sentence, e.g. 20 % of referred clicks came from another network β probably reshared. A low detected count on its own is the normal case and means nothing.
Twelve networks ship by default β X/Twitter, YouTube, Discord, Instagram, GitHub, TikTok, Pinterest,
LinkedIn, Reddit, Facebook, Newsletter, Website/blog. They are ordinary rows in the channels
table, seeded on first run and not privileged afterwards: rename, re-prefix, re-icon or delete
any of them under Settings β Channels, and add your own.
A channel needs a name, a 2β3 letter prefix and an icon. Referrer hosts are optional β without them
the channel still works, you just get no declared-versus-detected comparison for it. Paste what you
have; https://www.twitch.tv/videos/1 is stored as twitch.tv, and subdomains match automatically.
Two rules the interface enforces, both for the same reason β a slug has to survive being read aloud:
- prefixes are unique, so two networks can never produce the same-looking URL
lis not allowed in a prefix, since/bl1abcis unreadable
Some things deliberately do not change when you edit a channel:
| Edit | Effect |
|---|---|
| Rename or re-icon | Relabels every statistic, history included β rows group by an internal id that never changes |
| Change prefix | Applies only to slugs generated from then on; existing short links are never rewritten |
| Delete | Its links become unattributed. Links, slugs and every recorded click are kept, and you can reassign them |
Channels are a dashboard concept: the JSON API neither accepts nor returns the field.
Create a webhook in Server Settings β Integrations β Webhooks, copy the URL into
DISCORD_WEBHOOK_URL, restart.
On first run the app posts one embed and remembers its message id; from then on it edits that same message. Delete the message in Discord and it posts a fresh one on the next tick. Change the webhook URL and it starts over in the new channel.
The embed carries total/24 h/7 d clicks and visitors, link counts, top links, top referrers, top countries, and a 24-hour Unicode sparkline. Failures are logged and retried on the next tick β Discord being down never affects redirects.
Force a refresh (e.g. from CI after a deploy):
curl -X POST -H "Authorization: Bearer $API_KEY" https://s.example.com/api/discord/refreshCreate a key under Settings in the dashboard. It is shown once.
curl -X POST https://s.example.com/api/links -H "Authorization: Bearer sud_..." -H "Content-Type: application/json" -d '{"target":"https://example.com/page","slug":"launch","note":"newsletter"}'Card fields go in the same body β og_title, og_description, og_image:
curl -X POST https://s.example.com/api/links -H "Authorization: Bearer sud_..." -H "Content-Type: application/json" -d '{"target":"https://ko-fi.com/you","slug":"kofi","og_title":"Support my work","og_description":"Every coffee helps.","og_image":"https://example.com/card.png"}'On PATCH, an absent card key is left alone and an explicit null clears it β so updating the
target never disturbs the card, and {"og_image":null} drops just the image.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/links |
List (?q=, ?limit=, ?offset=, ?sort=) |
POST |
/api/links |
Create |
GET |
/api/links/:slug |
One link plus its stats |
PATCH |
/api/links/:slug |
Update target, note, expiry, disabled |
DELETE |
/api/links/:slug |
Delete link and its clicks |
GET |
/api/links/:slug/clicks.csv |
Raw click export |
GET |
/api/stats |
Global stats |
POST |
/api/discord/refresh |
Publish the Discord embed now |
GET |
/health |
Liveness, no auth |
sudo ./deploy/install-fedora.shThat compiles a static binary to /usr/local/bin/shorturl, writes /etc/shorturl/shorturl.env,
installs a hardened systemd unit, sets the SELinux boolean the reverse proxy needs, and opens 80/443
in firewalld.
Then:
sudo systemctl enable --now shorturlPut Caddy in front for automatic TLS β see deploy/Caddyfile.
The unit runs under DynamicUser=yes with ProtectSystem=strict, an empty capability set,
SystemCallFilter=@system-service and write access to nothing but /var/lib/shorturl.
MemoryDenyWriteExecute is deliberately left off: V8's JIT needs W+X pages and the process will not
start with it enabled.
Backups:
sudo ./deploy/backup.sh /var/backups/shorturlUses SQLite's online backup API, so it is consistent without stopping the service. Wire it to a systemd timer or cron.
What the app does, and why:
- Passwords β scrypt (N=2ΒΉβ΅, r=8), 16-byte random salt, constant-time compare. A tampered hash string with an absurd work factor is rejected rather than honoured, so it can't be turned into a memory bomb.
- Sessions β 32 random bytes; only the SHA-256 is stored, so a leaked database backup hands over
no live sessions. Cookies are
HttpOnly,SameSite=Lax, andSecurewheneverBASE_URLis https. - CSRF β every cookie-authenticated write checks the
Originheader (falling back toReferer). API-key requests are exempt: browsers never attach anAuthorizationheader cross-origin without a preflight. - Open-redirect abuse β targets are restricted to
http/https, may not embed credentials, and by default may not point at loopback, RFC1918, link-local (including169.254.169.254), CGNAT or multicast addresses. That last rule is what stops your shortener being used to CSRF someone's router. - Slugs β rejection-sampled from a CSPRNG over a 58-character alphabet, so they are neither guessable nor enumerable.
- Rate limits β login 8/15 min, link creation 60/h, API 240/min, redirects 600/min, all per IP.
- CSP β
default-src 'none'; script-srcabsent entirely; no inline styles. The one exception is the link detail page when a card image is set:img-srcwidens tohttps:so the image can be previewed.script-srcstays absent, so the image still cannot execute anything β the residual cost is that the image host learns the admin opened that page. - Social cards β the title and description are user-controlled text emitted into
content="β¦"attributes, so they are HTML-escaped; a test asserts a"><script>payload cannot break out. The image URL goes through the same scheme allowlist as a destination, which rules outjavascript:anddata:in a meta tag. - Headers β
nosniff,X-Frame-Options: DENY, HSTS on https, andReferrer-Policy: no-referreron the redirect so the destination never learns which short link (or campaign) sent the visitor. - CSV export β cells beginning
=,+,-or@are prefixed with an apostrophe, so a hostile referrer string cannot execute when the export is opened in Excel.
No cookie is ever set on a visitor. No IP address is written to disk.
Unique visitors are counted as sha256(daily_salt || ip || user_agent) truncated to 16 hex
characters. The salt is 32 random bytes, regenerated every UTC day, and old salts are deleted by the
nightly maintenance pass. Once yesterday's salt is gone, yesterday's visitor ids cannot be linked to
today's or back to any person β which is the point.
Referrers are stored as a bare hostname, never the full URL: referring pages routinely carry session tokens and personal data in their query strings.
Country is taken from a GeoIP header set by your CDN/proxy (CF-IPCountry and friends) if present,
otherwise guessed from the Accept-Language region subtag. No GeoIP database ships with the app.
One asymmetry worth knowing: a link's total clicks comes from a lifetime counter and survives the retention purge, while unique visitors is computed over the retained click rows and therefore only covers the retention window. Counting lifetime uniques would require keeping every click row forever, which is exactly what retention exists to avoid.
src/
main.ts bootstrap, graceful shutdown, maintenance loop
app.ts middleware, routing, security headers
config.ts environment parsing, validated at boot
db.ts SQLite schema, migrations, queries, batched click writes
service.ts link creation/validation shared by dashboard and API
auth.ts sessions, API keys, CSRF
discord.ts the self-editing webhook message
routes/ dashboard.ts (HTML), api.ts (JSON)
views/ server-rendered pages, SVG charts, the stylesheet
util/ crypto, URL validation, UA parsing, channels, rate limiting, QR
tests/ 119 tests: unit, storage, and end-to-end HTTP
deploy/ systemd unit, Caddyfile, Fedora installer, backup script
Works as-is for development:
deno task startSIGTERM doesn't exist on Windows, so the shutdown handler listens for SIGINT/SIGBREAK there
and SIGINT/SIGTERM elsewhere.
MIT β see LICENSE.