Practical Applications12 min readshipped

How I Found glyf.cc and Stood Up a Homelab URL Shortener in an Evening

How I Found glyf.cc and Stood Up a Homelab URL Shortener in an Evening

TLDR

The why. Bitly's free tier dropped to 5 links per month. I publish more than 5 a week. The forced choice was either pay Bitly or move. While moving anyway, I picked up the side benefit I'd been thinking about for a year: my own domain on the front of every link I send, so the short domain becomes part of the signal instead of someone else's brand. glyf.cc/<slug> instead of bit.ly/<random>.

The shape. Self-hosted Shlink (PHP, MariaDB, Docker) on a $7/mo VPS. Cloudflare in front for TLS termination and edge caching. Admin UI gated by a private network rather than a password. A small CLI wrapper so creating a short link is one shell command.

The hard part. Not the buildout (about fifteen minutes). The domain. Three-letter .cc is fully picked clean. Domain investors registered the entire short-TLD inventory years ago. The work was finding a four-character name with an etymology hook that wasn't $200+ aftermarket.

Reproduction prompt for Claude Code:

Stand up Shlink (latest stable) on a small Linux VPS behind Cloudflare. Three Docker containers under one docker-compose.yml: Shlink, the Shlink web client, and MariaDB. Public origin on port 443 with a Cloudflare Origin Cert covering both apex and wildcard. Admin UI bound only to a private-network interface IP (Tailscale, WireGuard, ZeroTier, your pick), never the public interface. Generate an admin API key, store it in a password manager. Write a small bash wrapper at (local path) that calls the Shlink REST API for create/list/get/delete, reading the API key from the password manager. Use findIfExists: trueon create so re-running with the same slug is idempotent. UseX-Forwarded-Proto https` in the nginx config to avoid redirect loops behind the TLS-terminating proxy.

Every newsletter footer, social bio, and blog teaser I send carries a short link. For three years I leaned on bit.ly. Then bit.ly dropped the free tier to 5 links per month. I publish more than that in a week. The choice was pay them or move.

I'd been thinking about moving anyway. The reason was the surface. bit.ly/3xK9pQa in the footer of a Run Data Run post tells the reader nothing about who sent it. The short domain is the brand signal, and that signal was someone else's. The free-tier cap turned a slow-burn want into a now problem.

I wanted my own. A four-character .cc that I could put behind every Justin-owned URL and keep bit.ly for the things I link to that I don't control.

This post is the buildout. Two parts. First, finding the domain, which is where Nymio did the heavy lifting and where the refinement loop produced a name I would not have written down on my own. Second, the stack: Shlink on a Hetzner VPS, Cloudflare in front, a tailnet-only admin plane on Tailscale, and a small bash wrapper so creating a short URL from the CLI is one line.

Part one: finding the name

The constraint was tight. I wanted four to six characters, pronounceable, no numbers or hyphens, available on .cc or .co or .io, and ideally with a hint of "link" or "mark" without being literal about it. The literal ones (lnk.cc, link.co, goto.cc) were either taken decades ago or registered as squat assets at four-figure prices.

I used Nymio, the domain discovery tool I've been building, because it has the one feature that breaks domain hunting for me: registry-grade availability verification via Fastly's Domainr API, which proxies to the upstream registries rather than guessing from registrar caches. Every domain you see is genuinely registerable right now. I've burned too many hours on registrar search boxes that say "available" and then surface a four-figure premium fee on the next screen.

The first (secret) Claude Sonnet 4.6 with my prompt set to "self-hosted URL shortener for my data and AI writing, builder-vibe not corporate, owner-feel, data-adjacent without being literal." TLD preferences: .co, .so, .to, .io, .me, .xyz, .ai. Five candidates came back. All five were taken.

Grok 4.3 on the same prompt gave five more. All five were taken too.

This is the territory tax for URL shorteners. The shortest TLDs (.co, .so, .io, .me) have been picked clean of any pronounceable three-to-five-character base name. Domain investors registered the entire space years ago. Even garbage strings like vox.cc or pyn.to are owned. You can't out-search the squat market with a one-shot prompt.

What worked was Nymio's refinement loop. The refinement prompt explicitly tells the model that round-one candidates were all taken, forbids reusing the roots, and pushes for longer base names in lower-traffic TLDs (.xyz, .so, .run, .codes, .cc). Round two found glyf.to at 6 characters total, available, with a real etymology hook: a glyph is a compressed symbol, and a shortened URL is exactly that.

The catch was the price. .to registrations run $50-100 a year. I'm not paying that for a personal shortener. I dropped .to and .ai (also $70+) from the TLD list and re-ran. That round gave several .so and .me hits in the $15-25 range, but nothing with the same hook.

I went hunting in .cc specifically. Three-letter .cc is effectively picked clean across the patterns worth registering. I brute-checked 89 unusual consonant clusters (q/x/z-heavy, no-vowel triples) and found zero available. Investor-registered inventory is dense enough that even the awkward strings are owned. Four-letter .cc opens up: of 79 curated candidates, 14 were available. glyf.cc was among them.

The vibe explanation Nymio generated landed it for me:

The base name "glyf" evokes glyphs, suggesting concise, symbolic representation, which aligns well with a URL shortener and the idea of distilling complex data. The .cc TLD is often associated with creative or tech-oriented projects, making it a suitable choice for personal data and AI blogs.

Registered the same day for less than $20. The signal-to-noise on that name is high: short, has actual etymology, no LLM-cliché baggage, and the brand reads cleanly in a newsletter footer as glyf.cc/<slug>.

Read on if you want to understand why I chose Shlink, what the Cloudflare config actually looks like, and where the small decisions live.

Part two: the stack

The buildout was straightforward once the domain was decided. Cert plumbing took five minutes, Docker pulled the images in another minute, the compose came up clean on the first try, and the rest was smoke-test passes against the public domain and the tailnet admin. Total post-domain time: about fifteen minutes. The summary:

LayerChoiceWhy
Domainglyf.ccSub-$20/yr, Cloudflare nameservers
CDNCloudflare ProxiedDDoS, caching, free SSL termination
OriginHetzner CX33, HelsinkiAlready running SocialEyes API on the same box
AppShlink 5.0.2Self-hosted, REST API, MariaDB backend
AdminShlink Web (8081)Behind Tailscale, never public
CertCloudflare Origin Cert, 15yrExpires 2041, one less renewal cron
CLI`(local path)Bash, ~60 lines, reads key from a password manager

Why Shlink

Real comparison set: YOURLS, Dub OSS, Kutt, Cloudflare Workers + KV, and Shlink.

YOURLS is venerable. PHP codebase, mid-2010s admin UI, REST API requires plugins. I've run it before. It works.

Dub OSS is the marketing-team flavor. Beautiful UI, A/B testing, link previews. But the stack is Next.js + Postgres + Redis + Tinybird. For a personal shortener, that's three extra services to keep alive.

Kutt is Node + Postgres, modern enough, but the momentum has slowed and the API ergonomics are weaker than Shlink's.

Cloudflare Workers + KV almost won. Ten lines of Worker code, global edge, zero servers, free tier covers 100K req/day. The reason I didn't pick it: no admin UI ships with it. You either build one or skip analytics entirely. For an evening project, that's a no.

Shlink won on four points. The REST API is first-class, not bolted on. It ships with a separate web admin that runs in its own container, which means the admin surface is trivially gateable on a tailnet IP without touching the redirect path. The Docker images are official and small. And the API supports findIfExists, which means my CLI can be idempotent without writing a "does this exist already" probe first.

Docker compose

The three containers sit under one docker-compose.yml:

services:
  shlink-db:
    image: mariadb:11
    container_name: glyf-shlink-db
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MARIADB_DATABASE: shlink
      MARIADB_USER: shlink
      MARIADB_PASSWORD: ${DB_PASSWORD}
    volumes:
      - shlink-db-data:/var/lib/mysql
    networks: [shlink-net]
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 10

  shlink:
    image: shlinkio/shlink:stable
    container_name: glyf-shlink
    restart: unless-stopped
    depends_on:
      shlink-db:
        condition: service_healthy
    environment:
      DEFAULT_DOMAIN: glyf.cc
      IS_HTTPS_ENABLED: "true"
      DB_DRIVER: maria
      DB_HOST: shlink-db
      DB_NAME: shlink
      DB_USER: shlink
      DB_PASSWORD: ${DB_PASSWORD}
      INITIAL_API_KEY: ${INITIAL_API_KEY}
    ports:
      - "127.0.0.1:8080:8080"
    networks: [shlink-net]

  shlink-web:
    image: shlinkio/shlink-web-client:stable
    container_name: glyf-shlink-web
    restart: unless-stopped
    depends_on:
      - shlink
    environment:
      SHLINK_SERVER_URL: https://glyf.cc
      SHLINK_SERVER_API_KEY: ${INITIAL_API_KEY}
    ports:
      - "127.0.0.1:8081:8080"
    networks: [shlink-net]

networks:
  shlink-net:
    driver: bridge

volumes:
  shlink-db-data:

Secrets live in a sibling .env file (DB_ROOT_PASSWORD=…, DB_PASSWORD=…, INITIAL_API_KEY=…) with 0600 perms. The cleaner pattern is Docker Compose secrets, but for a single-user homelab service the env-file shape is fine and easier to audit. Both Shlink containers bind to 127.0.0.1 only, so nothing reaches them except the local nginx, which I'll configure next.

nginx, two vhosts

The public vhost on port 443 does TLS termination with the Cloudflare Origin Cert and proxies to the Shlink redirect container on 127.0.0.1:8080. Pretty standard. The interesting one is the admin vhost.

# nginx vhost for the admin UI, tailnet-only
server {
    listen <TAILNET_IP>:80;   # bind to your Tailscale interface IP, NOT 0.0.0.0
    server_name shortener-admin;

    location / {
        proxy_pass http://127.0.0.1:8081;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Two things matter. First, the listen directive binds to the Tailscale interface IP directly. Not 0.0.0.0. That single line is the whole admin gate. Anyone not on the tailnet can't reach the listener at all because the listener isn't listening on the public interface. Get your Tailscale IP from tailscale ip -4.

Second, add a host alias on your laptop so the URL is memorable. In /etc/hosts:

<your-tailnet-ip>  shortener-admin

Now http://shortener-admin/ in any browser, anywhere you have Tailscale connected, opens the Shlink admin UI. No public DNS record, no auth challenge to bypass, no extra surface for a leaked credential to matter. The credential boundary is "are you on my tailnet."

I keep coming back to this pattern for admin planes. The mental model is older than I am: separate the control plane from the data plane and put a network gate between them. Tailscale just makes the gate one config line instead of a VPN appliance.

Cloudflare config

Two things to get right.

DNS records: glyf.cc and www.glyf.cc both point to the Hetzner public IP, both proxied (the orange cloud). SSL/TLS mode set to Full (strict). Always Use HTTPS on.

Origin cert: generate a 15-year certificate in Cloudflare's SSL/TLS panel, covering <your-domain>.<tld>, *.<your-domain>.<tld>, RSA 2048. Cloudflare gives you the cert and key as a one-time download. Store both in your password manager and drop copies onto the VPS at /etc/ssl/<your-domain>/, owned by root, mode 0600 on the key.

A 15-year expiry is the small luxury of Cloudflare Origin Certs. Let's Encrypt is fine but the renewal cron is one more thing to monitor. I'd rather set a 2041 calendar reminder.

The header that matters

One nginx detail worth calling out before you copy the config: the X-Forwarded-Proto header. When you set IS_HTTPS_ENABLED: "true" in Shlink, it generates redirects with whatever scheme the inbound request claims. Cloudflare terminates TLS at the edge and talks to your origin in HTTPS too (with Full strict), but nginx still needs to forward the right protocol header so Shlink can build correct Location URLs.

The proxy headers I use:

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;

I hardcode https rather than $scheme because I never want this vhost serving over plain HTTP. The HTTP listener exists only to 301 to HTTPS. If you forget this header on a TLS-terminating proxy, expect redirect loops the first time someone visits a shortened URL. It's the most common gotcha when proxying a HTTPS-aware app.

The CLI wrapper

The whole point of building this myself was to not open a web UI to create a short link. The wrapper is ~60 lines of bash. Sketch (substitute your own domain and password-manager command):

#!/usr/bin/env bash
# Create / list short URLs via the Shlink REST API
set -euo pipefail

API="${SHORTENER_API:-https://YOUR-DOMAIN.TLD/rest/v3}"
KEY="${SHORTENER_API_KEY:-$(your-password-manager-command 2>/dev/null | head -1)}"

req() {
  local method="$1" path="$2" body="${3:-}"
  if -n "$body"; then
    curl -sS -X "$method" "$API$path" \
      -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
      --data "$body"
  else
    curl -sS -X "$method" "$API$path" -H "X-Api-Key: $KEY"
  fi
}

cmd="${1:-}"
case "$cmd" in
  list)
    n="${2:-10}"
    req GET "/short-urls?itemsPerPage=$n&orderBy=dateCreated-DESC" \
      | python3 -c "import sys,json; d=json.load(sys.stdin); [print(f\"{u['shortUrl']:40} {u['longUrl'][:60]}\") for u in d['shortUrls']['data']]"
    ;;
  *)
    long="$1"
    slug="${2:-}"
    if -n "$slug"; then
      body=$(printf '{"longUrl":"%s","customSlug":"%s","findIfExists":true}' "$long" "$slug")
    else
      body=$(printf '{"longUrl":"%s","findIfExists":true}' "$long")
    fi
    req POST "/short-urls" "$body" \
      | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('shortUrl') or d)"
    ;;
esac

The findIfExists: true flag is the small detail that makes the script idempotent. If I run glyf https://example.com/long-url rdr-2026-05 twice, the second call returns the existing short URL instead of erroring on the duplicate slug. That means I can drop glyf invocations into other scripts without first probing whether the slug already exists.

Two ways I use it:

# Ad-hoc, auto-generated slug
$ glyf https://rundatarun.io/p/some-long-post-name
https://glyf.cc/Kx9pQ2

# Branded, deliberate slug
$ glyf https://rundatarun.io/p/claude-code-magnum-opus rdr-2026-05-claude-magnum
https://glyf.cc/rdr-2026-05-claude-magnum

The branded form is what goes in the social companion. The auto-generated form is for one-offs.

Slug grammar

I settled on <surface>-<yyyy-mm>-<keyword> for blog post links. So rdr-2026-05-claude-magnum for a Run Data Run post about Claude Code, aix-2026-05-shortener for this AIXplore article, sdd-2026-05-19 for a Sunday Deep Dive dated the day this post shipped. The slug carries the metadata. Future me can read a footer and know exactly which surface and which month.

What's still on the list

A few things I deferred at deploy and intend to come back to.

A nightly MariaDB backup. The Docker volume is on the same VPS as the data, so one bad docker volume rm and the short-link history is gone. A one-line cron is enough (docker exec <db-container> mariadb-dump | gzip > /backup/shortener-$(date +%F).sql.gz) and the gzipped dumps are tiny. This should have been at deploy time; it isn't yet.

A second API key with read-only role. Shlink supports multiple keys with different scopes. Right now the CLI uses a full-admin key for everything, which is fine in a single-user context but wouldn't (secret) code review. A read-only key would also let me drop a public dashboard onto glyf.cc/stats without giving the dashboard write access.

Visit analytics export. Shlink's UI shows time-series, geo, device, and referrer. The data is in the database. I want a small nightly script that pulls the aggregate counts into an Obsidian note so I can see which posts are getting clicked without opening the admin UI.

All three are small follow-up tickets, not blockers.

Why this was worth building

The whole project was about $20 of marginal cost (the domain), a few hours of attention, and produced a piece of infrastructure I'll use every time I publish anything for the next several years. Most of those hours went to domain hunting. The actual Shlink deploy was ten minutes. The math is comfortable.

The deeper reason is the surface. Every blog post I publish, every newsletter, every social companion, now carries glyf.cc/<slug> instead of bit.ly/<random>. The reader sees the domain. The domain is the brand signal. The signal compounds.

bit.ly still has a role. For third-party URLs I want to shorten (vendor docs, news articles, paper PDFs, anything I don't own), bit.ly is the right tool because there's no brand mismatch. The rule I encoded into my writing skills is: glyf for Justin-owned URLs, bit.ly for everything else.

If you've been thinking about a personal URL shortener, the work is in two places: picking a domain that doesn't embarrass you, and gating the admin plane behind a network you trust. Both are evening projects. The rest is docker compose up.

Related reading

  • Defending Your Homelab and Agent Fleet From npm Supply-Chain Attacks, the nightly monitor I built after the TanStack incident
  • When LaunchAgents Attack, what happens when homelab infrastructure misbehaves while you sleep
  • My Personal AI Assistant Lives Everywhere, the broader homelab philosophy this fits into

Follow the lab

Get the next experiment

Enjoyed the breakdown on How I Found glyf.cc and Stood Up a Homelab URL Shortener in an Evening? New entries land roughly weekly. No digest, no roundup. Just the next build log, when it ships.