● Case study · Backend · Rails

Short links, honest stats.

rails-url-shortener turns long addresses into short ones and reports how they're used, including unique visitors, without storing anyone's IP address.

Rodz·3 min read·Ruby · Rails 8.1
LONG LINK IN. SHORT LINK OUT. EVERY CLICK COUNTED. https://rubyonrails.org/doctrine /doctrine 302 NOT 301

Short links are everywhere: on posters, in social posts, behind QR codes. The shortening is the easy part. The useful part is knowing whether anyone used them, and that is usually where tools start tracking people far more than they need to.

rails-url-shortener is a small, production-shaped service built with Rails 8.1. It creates short links, redirects visitors, and answers the questions that matter (how many clicks, from where, how many different people today) while deliberately keeping as little personal data as possible.

3.5 tnpossible 7-character links
30new links per minute per IP
0IP addresses stored

01What it does

curl
# create
curl -s -X POST localhost:3000/api/v1/links -H 'Content-Type: application/json' \
  -d '{"url":"https://rubyonrails.org/doctrine","slug":"doctrine"}'
→ {"slug":"doctrine","short_url":"http://localhost:3000/doctrine",...}

# follow (302) — every visit is recorded
curl -i localhost:3000/doctrine

# stats
curl -s localhost:3000/api/v1/links/doctrine/stats
→ {"total_clicks":1,"unique_visitors_today":1,"last_7_days":[...],"top_referrers":[...]}
The whole lifecycle in three requests, from the project's README.

02What happens on a click

FIG. 01 — GET /doctrine visitor GET /doctrine look up slug unknown → 404 expired → 410 Gone valid record the click referrer user agent visitor_hash = SHA-256(secret | date | IP | agent) IP discarded clicks 302 → https://rubyonrails.org/doctrine GET …/doctrine/stats totals · unique today · 7 days · top 5 referrers
The redirect path does one insert and one counter update. The visitor's IP is used to compute the hash and is never saved.

The interesting part is the visitor id. To count unique visitors, you need to recognise the same person twice; the lazy way is to store their IP address. Instead, each click stores a SHA-256 hash of a server secret, today's date, the IP and the browser's user agent. Two visits from the same IP and browser on the same day produce the same hash, so they count once. Tomorrow the date changes, and so does the hash.

Enough to count visitors. Not enough to follow them from day to day.

03Decisions I made, and why

04Production-shaped

It is small, but built the way a real service would be. It ships with a production Dockerfile, and every push runs the linter (RuboCop), a static security scan (Brakeman) and the test suite: 18 tests and 95 assertions covering the models and the HTTP endpoints. It uses SQLite by default, and switching databases is a one-line configuration change because nothing in the code depends on SQLite.

05Known limits

The rate-limit counter lives in each server process's memory, so with several processes it should be pointed at a shared store. There is no web interface; it is an API, meant to sit behind an app, a bot or a script.

Try it or read the code

Open source under the MIT licence. Needs Ruby 3.3:

git clone https://github.com/Rodzman/rails-url-shortener
cd rails-url-shortener && bin/setup --skip-server && bin/rails server
Ruby 3.3Rails 8.1API-onlySQLiteMinitestRuboCopBrakemanDocker