Case study 04 · In use

Link service

A self-hosted short-link service that passes campaign parameters on to the destination, keeps click analytics for every link and has a token-protected API that other tools can call to create links. It runs on several domains: adding a domain sets up DNS, SSL, cache rules and a certificate in one step, and redirects are served from the edge cache. The admin console has roles, an audit log and two-factor sign-in.

Results at a glance

1 actionadds a domain with DNS, SSL, cache rules and a certificate
2 cache layersbetween a visitor and the app: Cloudflare's edge and nginx
25 server actionsbehind the console, each re-checking the signed-in user's rights

Project facts

RoleDesigned and built with AI coding agents. I wrote the specs, reviewed and gated every release, and run it.
PeriodVersion history from April to October 2026
StackNext.js 15, TypeScript, Tailwind CSS, Auth.js, Prisma, PostgreSQL, Docker, nginx
IntegrationsCloudflare API (DNS, SSL, Cache Rules, purge), Let's Encrypt certificates, a token-protected API for creating links
01

Problem

The team needed short links on several domains, with campaign parameters passed on to the destination and click numbers for each link.

Setting up a domain by hand means a Cloudflare zone, a DNS record, an SSL mode, cache rules, a TLS certificate and a web server config. And a redirect should be cached to be fast, yet a link that was edited, archived or has expired must not keep sending visitors to its old destination.

02

What I built

A Next.js 15 app in Docker with PostgreSQL through Prisma. It decides by hostname: the admin hostnames open the console, and every other hostname is a link domain served by the redirect handler. Operators manage domains and links in the console, and other tools can call a token-protected API to create links.

A short-link request passes two caches before it reaches the app:

Request path of a short link A visitor opens a short link. The request first reaches Cloudflare's edge cache, which answers a hit with the cached 302 redirect. On a miss it goes to the nginx cache on the server, which also answers hits. On a second miss the app's redirect handler looks the link up in the database and checks its expiry date and click cap. It answers 302 to the destination, cached for up to one hour; 404 for an unknown or archived link; or 410 for an expired link or a reached click cap. 404 and 410 are never cached. On the way back the 302 is stored in both caches. Editing, archiving, restoring or deleting a link purges it from both caches. Visitoropens a link Cloudflare edge cachehit: 302 from cache nginx cachehit: 302 from cache Redirect handlerexpiry · click cap Databaselink lookup 302 to the destination · cached up to 1 hour404 unknown or archived link · not cached410 expired or click cap reached · not cached miss miss 302 stored in both caches on the way back Editing, archiving, restoring or deleting a link purges it from both caches.

Illustration. Simplified request path of a short link.

03

Key parts

Redirects served from cache, purged when a link changes

A link lookup ends in one of three answers, and each carries its own Cache-Control header.

302redirect to the destination, cached for up to one hour
404unknown or archived link, never cached
410expired link or click cap reached, never cached
  1. Two cache layers sit in front of the app: Cloudflare's edge cache and an nginx cache on the server. Cloudflare's cache rules make the edge follow the app's own Cache-Control headers, and a cached redirect is answered without reaching the app.
  2. The cache lifetime is at most one hour and shrinks to the time left before a link expires. When fewer than 50 clicks remain under a click cap, the redirect is not cached at all, so every click near the cap reaches the app and the link stops at its cap.
  3. Link domains never go through sign-in, so redirect responses set no cookie that would stop the caches from storing them.
  4. Editing, archiving, restoring or deleting a link purges it from both caches: the matching nginx cache file and the URL in Cloudflare. Each link also has a manual purge button, and a script can purge a whole domain.

Domain setup through the Cloudflare API

Adding a domain is one action in the console. The app creates the Cloudflare zone, the DNS record, the SSL mode and the cache rules through the Cloudflare API; then a small service on the server issues a TLS certificate from Let's Encrypt and writes the nginx config for the domain.

Domains page for managing custom domains with automatic Cloudflare setup, listing three active demo domains with their link counts, creation dates and a delete action
Demo data. Domains, each set up in one step.
  1. A new domain is saved as pending and marked active when the Cloudflare steps are done. If the zone, DNS or SSL step fails, the app removes the new zone and the domain record again, so the operator can simply retry; a problem with the cache rules is logged and does not block the domain.
  2. Subdomains of an existing domain reuse its zone and need only a new DNS record. A domain cannot be deleted while subdomains depend on it.
  3. The service on the server tests the new nginx config before reloading and restores the previous config if the test fails. Certificates that are still pending are retried by a scheduled script.
  4. Cloudflare is called only from server code, and the API token never leaves the server.

Admin console, roles, audit log and API

Operators work in the console, where roles decide what each user can see and change. The API is token-protected, and both the console and the API write to the audit log.

Audit log table with 25 actions by two demo users, including link creation through the API, link updates, archive and restore, a CSV import, a domain creation and enabling two-factor sign-in, each with time, entity, detail and a documentation-range IP address
Demo data. Audit log with who did what, when and from where.
  1. Two roles: admins manage domains and see all links; users see and edit only their own links. There are 25 server actions behind the console, each re-checking the signed-in user's rights.
  2. Two-factor sign-in with an authenticator app, set up by scanning a QR code. Sign-in is limited to 5 attempts per 15 minutes.
  3. The audit log records link and domain changes, imports, failed sign-ins and rate-limit hits with the user, the time and the IP address. Usernames stay readable after a user is deleted, and a failed log write never blocks the user's action.
  4. A token-protected API that other tools can call to create links. Asking again for the same destination returns the existing link instead of a duplicate.
  5. The console can move to a new hostname without downtime: admin hostnames are kept in the database, and a fixed fallback hostname always works.
04

Reliability and guardrails

  1. Admin hostnames are looked up with a 30-second cache, and concurrent lookups share one request. On an error the last known list is kept and the lookup retries after 5 seconds; the fallback hostname always works. 11 tests cover this lookup and the validation of domain input, with a simulated clock.
  2. Adding a domain or a subdomain undoes its own partial work when a step fails: the new zone, DNS record or database row.
  3. Redirects are limited to 100 requests a minute per IP address, and sign-in to 5 attempts per 15 minutes.
  4. Deploys save a snapshot of the current source, rebuild the container and wait up to 60 seconds for it to report healthy. The app and PostgreSQL both have health checks, and the image is a multi-stage Docker build.
  5. CI on GitHub Actions runs the lint and the build. Tokens and passwords are read from the server's environment and are not in the repository.
05

Results

The service runs on one Docker host: the app and PostgreSQL in containers, nginx and the certificates on the host. Operators add domains and links in the console without opening Cloudflare or the server, and links can also be created through the API. On 9 October 2026 the admin-hostname and subdomain features went from two written specs and a plan to working code: 18 of the 24 commits in the history landed that day.

06

What's next

  1. Run the 11 tests in CI as well, so they gate every change, not only the lint and the build.
  2. Add tests for the redirect handler (status codes, cache headers, parameter forwarding) and for the Cloudflare client.
  3. Bring the setup docs up to date: some still describe features that were removed.
  4. Replace the schema sync at start-up with versioned database migrations.

← Back to all work