DEV Community

Cover image for I built a privacy-first URL shortener that runs on plain shared hosting
Simran Kaur
Simran Kaur

Posted on

I built a privacy-first URL shortener that runs on plain shared hosting

Every URL shortener tutorial reaches for Redis, a queue, or a paid link API. I wanted one that survives on the cheapest shared hosting a hobby project can afford: plain PHP and MySQL, nothing else. No background workers, no external services on the hot path, and still real click analytics.

Here is how I built it, and you can try the result here: Free URL Shortener with Analytics.

The constraints

Shared hosting is a humbling place to deploy. You usually get:

  • PHP and MySQL, and that is basically it.
  • No Redis, no Memcached, no cron you can trust to the second.
  • No long-running processes, so no queue consumers.
  • A CDN in front (Cloudflare here) that will happily cache your redirects if you let it.

So the design rule was simple: everything the redirect needs must be one indexed SQL lookup, and every extra feature has to be free of infrastructure.

The data model

Two tables. One for links, one for click events.

CREATE TABLE short_urls (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  code VARCHAR(20) NOT NULL,
  long_url TEXT NOT NULL,
  clicks INT UNSIGNED NOT NULL DEFAULT 0,
  created_at DATETIME NOT NULL,
  creator_ip VARCHAR(45) NOT NULL DEFAULT '',
  expires_at DATETIME NOT NULL,
  disabled TINYINT(1) NOT NULL DEFAULT 0,
  UNIQUE KEY code (code),
  KEY expires_at (expires_at)
);
Enter fullscreen mode Exit fullscreen mode

The UNIQUE KEY on code is the quiet hero. It handles custom aliases without a race: two people can try to claim my-brand at the same moment, and the database rejects the loser instead of my application logic having to lock anything.

The redirect is just a request interceptor

This runs on WordPress, so the redirect lives on the init hook. Any path that looks like /s/<code> is caught before the page renders, resolved, and sent on its way.

add_action('init', function () {
    $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
    if (!preg_match('#^/s/([A-Za-z0-9_-]{3,20})/?$#', $path, $m)) return;

    // stop any cache from ever storing a redirect
    header('X-Robots-Tag: noindex, nofollow', true);
    nocache_headers();

    $row = get_link($m[1]);
    if (!$row)            return show_status(404, 'Link not found');
    if ($row->disabled)   return show_status(410, 'Link disabled');
    if (expired($row))    { delete($row); return show_status(410, 'Link expired'); }

    log_click($row);                 // one insert, no queue
    wp_redirect($row->long_url, 302);
    exit;
});
Enter fullscreen mode Exit fullscreen mode

nocache_headers() is what keeps Cloudflare and LiteSpeed honest. Without it a cached 302 would send everyone to the first destination a code ever had. With it, the CDN marks the response DYNAMIC and passes every hit through, which is exactly what you want for a link that can expire or be disabled.

Expiry without a cron job

There is no scheduled cleanup. Instead, expiry is enforced in two places that already run:

  1. On every redirect, an expired link is deleted the moment someone hits it, and returns a 410.
  2. On every create and every stats lookup, a one-line sweep clears anything past its date.
$wpdb->query("DELETE FROM short_urls WHERE expires_at < UTC_TIMESTAMP()");
Enter fullscreen mode Exit fullscreen mode

An expired link therefore never redirects, even if the row lingers for a few minutes until the next bit of traffic. For a tool where links live 24 hours to 5 days, that is more than good enough, and it costs zero infrastructure.

Analytics that respect the visitor

Most free shorteners either hide analytics behind a login or track people to resell the data. I wanted the numbers without the surveillance, so each click stores only three things:

  • a timestamp,
  • the referring domain (host only, so l.facebook.com becomes Facebook),
  • a device bucket derived from the user agent (mobile, tablet, desktop).

No IP addresses, no full user agents, no cookies, no profiles. The tracker then aggregates those rows into a 7-day chart, a top-referrers list, and a device split, all with plain GROUP BY queries. It is enough to answer "did my post work" without becoming a thing I would be uncomfortable running.

Normalizing referrers was a small but satisfying detail. Facebook alone sends traffic from l.facebook.com, m.facebook.com, and lm.facebook.com, so without grouping your top-referrer list is noise. A short lookup table collapses the known social and search hosts into clean brand names.

Keeping it from becoming a spam cannon

An open shortener is a gift to phishers, and one abuse report can get your whole domain flagged. Two cheap defenses do most of the work:

  • Google Safe Browsing checks every destination at creation time. A malware or phishing URL is rejected before a short link exists.
  • Cloudflare Turnstile sits on the form to keep bots from mass-generating links. It is free and, since the site was already on Cloudflare, it was a five-minute add.

Both are optional in the code and fail open if unconfigured, so the tool still runs locally without keys, but on production they are the difference between a useful tool and a liability.

What I would tell my past self

  • Put the unique constraint in the database, not in your PHP. It is faster and it is correct under concurrency.
  • Send no-cache headers on anything that redirects, or your CDN will make debugging surreal.
  • You do not need a cron for time-based cleanup if normal traffic already visits the rows.
  • Privacy-first analytics are not harder to build, they are just a decision to store less.

The whole thing is live and free to use, with custom aliases, expiring links, and the click analytics described above: try the URL shortener. Happy to answer questions about any of the pieces in the comments.

Top comments (0)