brunocordeiro.dev

Link Charts — case study

A URL shortener with click analytics that I run solo in production since 2025 — Laravel, Next.js and blue/green deploys by tag

Visit linkcharts.com.brv2.20.0Release in production · updated on Aug 2026

System scope

Every click is enriched with geography, device, time and traffic quality before it is stored, and feeds five analytics dashboards.

Beyond shortening, the product has redirects with Open Graph previews for WhatsApp and Telegram bots, an anti-fraud quality score per click, custom subdomains with a link-in-bio page, a public API, QR codes, reports with CSV export, and link passwords, expiration and scheduling. Monetization comes from AdSense and Google Ads.

That adds up to about 1,929 commits of mine across 3 repositories between March 2025 and August 2026, alongside a full-time job, with 50 releases under independent semver per repository (backend at v2.16.0, frontend at v1.19.0).

Architecture

ArchitectureThe path of one request, from the browser to the queue worker.Browserdashboards · link-in-bioBotsWhatsApp · TelegramCloudflareTLS · CDN · client real-ipDigitalOcean · one dropletnginxblue/green upstream · zero-downtime cutoverNext.js 15 · React 19App Router · strict TypeScriptISR with cache tags · TanStack QueryAuth0 · CSP/HSTS in middlewareApexCharts · LeafletLaravel 12 · PHP 8.2Controller → Service → Repositorydependency injection · API keys/r/{slug}responds before it tracksproxy via rewritesJWT httpOnly · no CORS302 · humanHTML + OG · botPostgreSQL 15links · clicks · rollupsRedis 7cache · job queueClick workerdedup_key with a unique indexwrites the click — a retry never duplicatesX-Request-Id — propagated from the browser to the queue worker
synchronousasynchronous, after the responseThe path of one request, from the browser to the queue worker.

The backend is Laravel 12 with PostgreSQL 15 and Redis 7, layered into Controller, Service and Repository with dependency injection. The critical /r/{slug} route answers HTML with Open Graph for bots and a 302 for humans; tracking runs afterwards in an idempotent async job, where a dedup_key column with a unique index guarantees a retry never duplicates a click.

The frontend is Next.js 15 with React 19 and strict TypeScript, TanStack Query, ISR with cache tags, two-language i18n, Auth0 and CSP/HSTS in middleware. Charts use ApexCharts; maps use Leaflet.

Integration goes through rewrite proxying, with no CORS, JWT in an httpOnly cookie and X-Request-Id propagated from the browser to the queue worker to correlate logs end to end.

Tests and static analysis

PHPUnit tests
902 methods across 133 files (36 unit, 97 feature)
CI database matrix
the suite runs 2× per push: SQLite and real PostgreSQL 15
Migrations
56, zero destructive — MigrationSafetyTest rejects dropColumn in up()
Static analysis
PHPStan/Larastan level 5 with baseline
E2E
Playwright, 6 projects (320/375/desktop × public/authenticated)
Abuse & resilience
16 named rate limiters + IP spoofing and queue retry tests

CI checks before merge

Before integrating, every push goes through a local hook and CI, with the suite running twice on two different databases. A merge publishes nothing: only a tag produces a deploy.

Blue/green deploy by tag

Publishing takes a tag push. The image builds on the GitHub runner in 2m03s, never on the server, and the colour swap happens without dropping a request: measured downtime went from ~5min to 0s.

Blue/green deploy by tagFrom branch to colour swap — and why each step works this way.branchcommitpushCImergepublication starts here — the trigger is the tagtagv2.16.0, by handbuildGitHub runner · 2m03sghcrversioned imagersyncdeploy artefacts onlynginxgraceful upstream cutoverblue · previous versionstops taking new requestsfinishes the ones still in flightdrain 30s → stopgreen · new versionwarm-up: backward-compatible migrate + caches/health in a loop, up to 30× every 2sfails? it aborts and blue stays up1,035/1,035 samples returned HTTP 200 during the cutover — measured downtime: 0sbuild runs on the GitHub runner: the droplet has 2 vCPU and 3.8 GB — compiling there would starve live trafficrsync ships composes, scripts and config only — source and toolchain never reach the serverthe infra stack (postgres, redis, alloy) is left alone: the old deploy took the database down with the appan online migrate must be backward-compatible; a destructive one goes offline, ~20s, flagged by a testthe health check hits nginx and a Laravel route — a 200 from nginx does not prove PHP-FPM came uprollback is the same pipeline with the older tag — a separate emergency path would be the least-tested code
livedrainingFrom branch to colour swap — and why each step works this way.

Observability

OpenTelemetry exports traces, metrics and logs to Grafana Cloud through Grafana Alloy, with tail sampling that keeps 100% of errors and slow requests. Every trace carries the deploy SHA, so a regression points straight at the release that introduced it.

Logs are split into 8 per-domain channels with automatic PII redaction. Faro RUM covers the frontend and Pyroscope continuously profiles the PHP. The 4 dashboards and 9 alert rules live as JSON in the repository; nothing is configured by hand in the UI.

Outside the infrastructure, a probe runs every 5 minutes and opens an incident issue on its own if the service goes down — it covers the total outage, where internal alerts would have nothing left to report from.

Production metrics

The panels below consume the Prometheus API of my Grafana Cloud workspace, the same one monitoring Link Charts in production. When the API is unreachable, a panel degrades to a versioned snapshot and says so in its badge. Uptime comes from the external GitHub Actions probe.

Grafana · linkcharts · productionLast 30 daysupdated 8/1/26, 12:00 AM UTCdata via Grafana Cloud · Prometheus
uptime 30dGitHub Actions
99.0%external probe every 5 min — opens an incident issue on its own
p95 · redirectsnapshot
180 mscritical route /r/{slug}, last 24h
5xx errorssnapshot
0.4%share of requests, last 24h
requests/minsnapshot
12.0average over the last 24h
commits per monthgit log
real git history of the Link Charts repositories, Mar 2025–Aug 2026
1,035/1,035 deploy samples with HTTP 200 · 9 alert rules and 4 dashboards versioned as JSON in the repository — zero UI config

Postmortems

In July 2026, a deploy under the old model, built on the server itself, returned 918 HTTP 502 responses — one of them to a real visitor. That was the trigger to rewrite releases as tag-driven blue/green, with warm-up, health checks in a loop and automatic abort on failure.

A missing build-arg in the Dockerfile compiled the Google Ads conversion labels as empty strings, and campaigns ran for weeks without registering a conversion. The fix became a CI gate: a script compares the NEXT_PUBLIC_* used in code against the Dockerfile's ARGs and blocks the build if any is missing.

The client IP was spoofable in logs and rate limiters. I fixed it with Cloudflare's real-ip and left an automated test so the flaw can't slip back in unnoticed.

A deploy broke mid-pipeline while an external meter hit the site every 2 seconds: 156 out of 156 samples answered 200 — a failing release aborts before the cutover, so the running version is never touched.

Development with AI agents

I built Link Charts in a spec-driven flow — brainstorm, design doc, plan, execution — using AI agents to write most of the code. A versioned architecture context file guides the agents, and ADRs and postmortems feed that context back over time.

The /ship command goes from commit to deploy with at most two self-correction attempts per step; if that fails, it stops and hands control back to me. A rule that has to hold becomes a CI test, not a wiki page.

That is about 1,929 solo commits in 17 months under the same gates: the 902 tests, PHPStan and the zero-warning limit blocked merges the whole way.

The repositories in numbers

Both product repositories are public. The numbers below come straight from the GitHub API — commits and tags counted via the pagination header, languages by byte share of the code.

linkchart-frontend ↗Next.js 15 · React 19 · TypeScript
live
commits
1,039
git tags
30
latest tag
v1.21.0
last push
08/31/2026
languages
  • TypeScript 96%
  • CSS 2%
  • JavaScript 2%
  • Shell <1%
  • Dockerfile <1%
linkchart-backend ↗Laravel 12 · PHP 8.2
live
commits
731
git tags
28
latest tag
v2.20.0
last push
08/31/2026
languages
  • PHP 96%
  • Blade 2%
  • Shell <1%
  • HTML <1%
  • Dockerfile <1%
  • PLpgSQL <1%

Stack

Frontend

  • Next.js 15 (App Router)
  • React 19
  • strict TypeScript
  • MUI 6
  • TanStack Query 5
  • ApexCharts
  • Leaflet
  • Auth0

Backend

  • Laravel 12
  • PHP 8.2
  • PostgreSQL 15
  • Redis 7
  • async queues
  • public API with API keys

Infra

  • Docker multi-stage
  • GHCR
  • nginx
  • DigitalOcean
  • Cloudflare

Observability

  • OpenTelemetry
  • Grafana Cloud
  • Grafana Alloy
  • Faro RUM
  • Pyroscope
  • alerts as code

CI/CD

  • GitHub Actions
  • tag-driven blue/green deploys
  • rollback via the same pipeline

Quality

  • PHPUnit (902 tests)
  • PHPStan level 5
  • Laravel Pint
  • ESLint 0 warnings
  • Playwright (6 projects)
← Back to contact