# How I Found glyf.cc and Stood Up a Homelab URL Shortener in an Evening > [!tip] 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 `~/bin/<name>` that calls the Shlink REST API for create/list/get/delete, reading the API key from the password manager. Use `findIfExists: true` on create so re-running with the same slug is idempotent. Use `X-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](https://nymio.app), 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 pass used 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: | Layer | Choice | Why | |---|---|---| | Domain | glyf.cc | Sub-$20/yr, Cloudflare nameservers | | CDN | Cloudflare Proxied | DDoS, caching, free SSL termination | | Origin | Hetzner CX33, Helsinki | Already running SocialEyes API on the same box | | App | Shlink 5.0.2 | Self-hosted, REST API, MariaDB backend | | Admin | Shlink Web (8081) | Behind Tailscale, never public | | Cert | Cloudflare Origin Cert, 15yr | Expires 2041, one less renewal cron | | CLI | `~/bin/glyf` | 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`: ```yaml 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 # 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: ```nginx 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): ```bash #!/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: ```bash # 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. > [!info] 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 pass a 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 - [[Practical Applications/defending-homelab-npm-supply-chain|Defending Your Homelab and Agent Fleet From npm Supply-Chain Attacks]], the nightly monitor I built after the TanStack incident - [[Practical Applications/when-launchagents-attack-100-dollar-api-crash-loop|When LaunchAgents Attack]], what happens when homelab infrastructure misbehaves while you sleep - [[Practical Applications/my-personal-ai-assistant-clawdbot-seneca|My Personal AI Assistant Lives Everywhere]], the broader homelab philosophy this fits into