Shorten any URL in one click. See exactly where every click came from.
golinkgone.com · Redirects served at tryglg.ink
GoLinkGone turns any long URL into a clean short link under tryglg.ink — with a built-in QR code and a full analytics
dashboard. No setup required to shorten a link. Sign up to track every click by country, city, device, and visitor
uniqueness.
- One-click shortening — paste a URL, hit Generate, get a short link and a print-ready QR code instantly
- Downloadable QR codes — every link ships with a 200×200 PNG, ready for print or sharing offline
- Sub-second redirects — backed by an in-memory key store so every click lands instantly, no database round-trip
- Per-link analytics dashboard with four views:
- 24-hour hourly breakdown
- 7-day and 30-day daily timelines
- All-time monthly chart
- Three visitor metrics per time bucket — Total Clicks, New Visitors, and Unique Visitors (deduped by hashed IP + User-Agent)
- Top 50 countries and top 15 cities ranked by clicks
- Device breakdown — Phone, Tablet, Desktop, Unknown — shown as a donut chart with percentages
- Timezone-aware charts — all buckets align to the browser's local timezone, including half-hour offsets
- My Links — paginated table to manage, copy, and delete all your short links
- Full account deletion — removes your auth account and cascades through every link and analytics row
Browser → Cloudflare edge → Caddy (TLS) → Spring Boot JVM → Supabase Postgres
- Frontend — React + Vite, hosted on Cloudflare Pages at
golinkgone.com - Backend API — Spring Boot on a DigitalOcean droplet, reachable at
api.golinkgone.com - Redirects — same backend, separate Cloudflare zone at
tryglg.ink - Auth + Database — Supabase (Postgres + ES256 JWT)
- Geo-IP — MaxMind GeoLite2, memory-mapped, refreshed weekly
The JVM is never exposed to the public internet directly — only Caddy listens on 80/443, and the droplet firewall allows inbound traffic only from Cloudflare's IP ranges.
| Layer | Technology |
|---|---|
| Language | Java 21 |
| Framework | Spring Boot 4.0.3 |
| Security | Spring Security · OAuth2 Resource Server (ES256 JWT) |
| Database | Supabase Postgres — Spring Data JPA + JdbcTemplate |
| Connection pool | HikariCP |
| Caching | Caffeine (URL cache · ownership cache · dashboard caches) |
| Rate limiting | Bucket4j — token bucket, per IP |
| Geo-IP | MaxMind GeoLite2-City (memory-mapped · weekly auto-update) |
| QR codes | ZXing |
| Primary keys | UUID v7 (time-ordered) via uuid-creator |
| Async / threads | Java 21 virtual threads |
| Frontend | React 18 · Vite · Tailwind CSS · Recharts |
| Frontend hosting | Cloudflare Pages |
| Backend hosting | DigitalOcean droplet · Docker · Caddy |
| CI/CD | GitHub Actions → GitHub Container Registry |
All short keys are loaded into a ConcurrentHashMap at startup — collision checks and existence lookups are O(1) with
no database access. Resolved URLs are cached in Caffeine for 24 hours. A typical redirect touches no database at all.
The redirect handler enqueues a ClickEventDTO into a bounded in-memory queue and returns the 302 immediately. A
background consumer drains the queue in batches every 3 seconds and writes five upserts in a single transaction:
unique_visitors_global → lifetime deduplication per (link, visitor)
link_stats_global → lifetime totals
link_stats_monthly → monthly rollup
link_device_stats → device rollup
link_location_stats → country / city rollup
unique_visitors_log → 35-day rolling raw log for time-window queries
New-visitor attribution uses INSERT ... ON CONFLICT DO NOTHING return codes — 1 = new, 0 = returning — without a
separate SELECT.
Raw IPs are never stored. Visitor identity is murmur3_128(ip + "|" + userAgent) → UUID, used solely for deduplication
counting.
getDashboard runs two independent queries in parallel: getLinkSummary (4 sequential reads — lifetime totals, top
countries, top cities, device breakdown) and getTimeline (1 read). Both results are cached for 1 minute. Cold load: 2
simultaneous DB connections. Warm: 0.
A servlet filter enforces routing by Host header before Spring Security processes the request:
tryglg.ink— onlyGET /{key}andHEAD /{key}reach the handler; all other paths return 404api.golinkgone.com— all API paths pass through; short-key paths return 404
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
Public | Health check |
GET |
/actuator/health |
Public | Detailed health (includes GeoIP status) |
POST |
/create |
Public | Shorten a URL → { shortUrl, qrCode } |
GET / HEAD |
/{shortKey} |
Public | Redirect + async analytics |
GET |
/my-links |
JWT | Paginated list of user's links |
GET |
/{shortKey}/dashboard |
JWT (owner) | Per-link analytics |
DELETE |
/{shortKey} |
JWT (owner) | Delete link and all analytics |
DELETE |
/account |
JWT | Delete account and cascade all data |
- Java 21, Maven
- A Supabase project (database + auth)
- A MaxMind GeoLite2 license key (free at maxmind.com)
Create a .env file at the project root:
DB_URL=jdbc:postgresql://<host>:5432/postgres
DB_USERNAME=postgres
DB_PASSWORD=...
ISSUER_URI=https://<project>.supabase.co/auth/v1
JWK_SET_URI=https://<project>.supabase.co/auth/v1/.well-known/jwks.json
PROJECT_URL=https://<project>.supabase.co
SUPABASE_SERVICE_ROLE_KEY=...
MAXMIND_LICENSE_KEY=..../mvnw spring-boot:runStarts at http://localhost:8080. Point the frontend's VITE_API_BASE_URL here.
./mvnw testIntegration tests use Testcontainers and require Docker.
| Service | Platform |
|---|---|
| Backend | DigitalOcean droplet — Docker + Caddy (TLS via Cloudflare Origin Certs) |
| Frontend | Cloudflare Pages at golinkgone.com |
| Auth + DB | Supabase |
On every push to main, GitHub Actions builds the fat JAR, publishes the Docker image to GHCR, and SSHs into the
droplet to pull and restart the container.
Required GitHub secrets: GHCR_USER · GHCR_PAT · DROPLET_HOST · DROPLET_USER · DROPLET_SSH_KEY
Required env vars on the droplet (glg.env):
DB_URL # Supabase session pooler JDBC URL (IPv4)
DB_USERNAME / DB_PASSWORD
ISSUER_URI / JWK_SET_URI
PROJECT_URL
SUPABASE_SERVICE_ROLE_KEY
MAXMIND_LICENSE_KEYCloudflare SSL/TLS mode must be set to Full (strict). The droplet firewall should allow ports 80/443 only from Cloudflare's published IP ranges.
The GeoLite2 database (GeoLite2-City.mmdb) must be present at /opt/glg/GeoLite2-City.mmdb before first start. The
app refreshes it automatically every Sunday at 03:00 UTC.
MIT
