Stock Kutt (bare-metal LXC, Postgres on void-db), blackflame via custom CSS (no fork), private-first via CF Access with a public-later toggle. Hybrid Void integration: embedded themed Kutt + a native card (update-tracker + quick-add). Repos: Hynes/URLShortener-void-kutt + void-v2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.3 KiB
Design: Kutt URL shortener as a Void app
Date: 2026-06-08
Status: Approved (brainstorm), pending implementation plan
Repos: new Hynes/URLShortener-void-kutt (theme + deploy) + Hynes/Void-Homelab (void-v2 integration)
Summary
Self-host Kutt (a modern Node URL shortener) unmodified in its own bare-metal
LXC behind link.hynesy.com, blackflame-themed via Kutt's custom-CSS support (no
fork → stays 100% upstream-clean). Surface it in the Void as a Hybrid Apps item:
an embedded themed Kutt UI for management, plus a small Void-native card for an
update-tracker and a quick-add box. Start fully private (CF Access over the
whole host) with a clean, no-rebuild path to public-later (and per-link
public/private via Kutt's multi-domain support).
Background / constraints
- Why Kutt (vs Shlink): liked the UI, MIT, actively released (v3.2.5, 2026-05),
Node/Postgres bare-metal install, and themeable via custom CSS without forking —
so we ride upstream
npmupdates with zero merge conflicts. - User preferences honoured: bare-metal-in-LXC (no Docker for long-term personal apps); blackflame styling; static IP + router MAC reservation per guest; HA-tag + Z→Z3 replication; backup before changes; document everything to the wiki + git.
- The redirect-vs-auth split: a shortener's redirect endpoint normally must be public, but the admin must be protected. We resolve this with a CF Access toggle (private now) rather than baking a split in.
Decisions
| Decision | Choice | Rationale |
|---|---|---|
| App | Kutt, stock (pinned release) | Liked UI; themeable via CSS so no fork; upstream-clean. |
| Deploy form | Bare-metal LXC (Node + systemd) | No-Docker-for-keepers preference. |
| Database | Shared void-db (CT 310, PG 16) — dedicated kutt DB + least-priv role |
One Postgres to back up / HA; no new DB service. |
| Redis | Skipped | Optional cache; unnecessary single-user. |
| Domain | link.hynesy.com → Traefik → Kutt |
Clear, readable. |
| Access (now) | Private — CF Access over the whole host | Locked down first. |
| Access (later) | Relax CF Access on redirects; add a public 2nd domain for per-link public/private | No rebuild — policy flip + Kutt multi-domain. |
| UI | Hybrid — embed themed Kutt + a Void-native card | Keep the UI you liked + Void extras. |
| Feature parity w/ Shlink | Stock + QR Void-side; richer gaps via upstream MRs (separate effort) | Never fork Kutt. |
Architecture
Browser ──(CF Access, Phase 1)── link.hynesy.com ── Traefik(mediastack) ── CT 113 kutt (Node)
│ DATABASE_URL
void-db CT 310 (PG 16, db=kutt)
Void (#/links) ── iframe ── themed Kutt UI
└── Void-native card ── /api/links proxy (void-app, holds Kutt API key) ── CT 113 Kutt REST API
└── update-tracker ── GitHub releases API + running version
Components
1. Kutt LXC (CT 113 kutt)
- New unprivileged LXC on Z, static IP + router MAC reservation, HA-tagged,
replicated Z→Z3, AppArmor
unconfined(host requirement). 2 GB / 2 cores / small disk. - Node 20+; Kutt checked out at a pinned release tag (e.g.
v3.2.5) under/opt/kutt, userkutt. Install:npm ci→ (build step if the release needs one) →npm run migrate→npm start, managed bykutt.service(systemd). - Config via
/opt/kutt/.env(mode 600):DEFAULT_DOMAIN=link.hynesy.com,DB_*/DATABASE_URL→ void-db,DISABLE_REGISTRATION=true, trust-proxy / forwarded settings so Kutt knows its public origin, no SMTP (registration off, admin pre-seeded), one admin account + an API key.
2. Database (shared void-db)
- On CT 310 (
192.168.1.215:5432, PG 16): create databasekuttowned by a newkuttrole (NOSUPERUSER, owns its DB +publicschema so Kutt migrations run). Kutt manages its own schema vianpm run migrate. Rides void-db's existing HA + backups; no impact on thevoiddatabase.
3. Domain + CF Access (private → public)
- Traefik (mediastack
/docker/proxy/dynamic.yml): routerlink.hynesy.com→ CT 113 Kutt port. Wildcard*.hynesy.comDNS already targets the tunnel. - Phase 1 (private): CF Access app over all of
link.hynesy.com(Google IdP, email allowlist) — admin and redirects private. (Embed then works viavoid.hynesy.com, not the raw LAN IP — same CF-cookie rule as Timelapse/AI-Usage.) - Phase 2 (public, later, separate effort): remove CF Access from redirects; for
per-link public/private add a public 2nd domain (e.g.
go.hynesy.com, no CF Access) and use Kutt's multi-domain to choose a link's domain.
4. Theming (blackflame, no fork)
- Kutt's custom CSS hook: a blackflame stylesheet (palette
--accent #ff4f2eetc., Cinzel/Cormorant/JetBrains fonts, surfaces) applied to stock Kutt. Lives inHynes/URLShortener-void-kuttand is dropped into Kutt's custom/override dir at deploy. No Kutt source changes →npm/release updates never conflict.
5. Void integration (the Hybrid)
- Apps rail "Links" (
#/links,public/views/links.js): a Void header bar + a Void-native card (below) + an<iframe src="https://link.hynesy.com">(themed Kutt). Mirrors the Timelapse/AI-Usage embed views; added to the Apps sidebar section. - Void server proxy (
lib/api/routes/links.js, mount/api/links, owner-gated): forwards to Kutt's REST API over the LAN (CT 113 IP:port) with the Kutt API key held in void-app's.env(key never reaches the browser; LAN call works regardless of CF Access). Endpoints needed: create link (quick-add) + recent links + version. - Void-native card:
- Update-tracker — the proxy fetches
api.github.com/repos/thedevs-network/kutt/ releases/latest(cached ~6 h) and the running Kutt version; the card shows the version, an "update available" badge when they differ, and a changelog link. (Bare-metal = manual updates, so this is the "what's new / time to update" signal.) - Quick-add — paste a URL →
POST /api/links→ Kutt creates the short link → show- copy. Optional QR rendered Void-side (client lib) for any link.
- Update-tracker — the proxy fetches
6. Feature parity with Shlink
Kutt stays stock. Two non-forking lanes: (a) now, presentation-only wins like QR in the Void card; (b) later, richer gaps (geo analytics, tags) as merge requests to Kutt upstream — tracked as a separate project, out of scope here.
Data flow
Create: Void quick-add → /api/links proxy (+API key) → Kutt → row in kutt DB → short
URL returned. Resolve: link.hynesy.com/<slug> → Kutt → 302 (CF-gated in Phase 1).
Update check: card → proxy → GitHub releases + running version → badge.
Error handling
- Kutt down / proxy error → the card shows "Kutt unreachable" + the
↗ Openfallback; the embed shows Kutt's own error. Void itself unaffected. - GitHub API rate-limited/unreachable → update-tracker shows "version check unavailable" (cached last-known if any); never blocks the card.
- Missing/invalid Kutt API key → proxy returns a clear 502; quick-add disabled with a hint.
Testing
- vitest: the
/api/linksproxy (mock Kutt API — create/list) and the update-tracker comparison logic (mock GitHubreleases/latest+ running version → badge true/false); thelinks.jsview renders the card + iframe (jsdom). - Deploy smoke: create a link via Kutt's API →
curlthe slug → 302 to target; confirmlink.hynesy.comis CF-gated (302 to cloudflareaccess) in Phase 1.
Out of scope (separate efforts)
- Phase-2 public access + per-link public/private second domain.
- Upstream MRs for geo/tags parity.
- Redis cache; SMTP/registration; multi-user.
Repos & docs (standing rule)
Hynes/URLShortener-void-kutt(created,gitea@192.168.1.223:Hynes/URLShortener-void-kutt.git): blackflame theme CSS, the LXC create/bootstrap +kutt.service,.env.example, deploy notes.Hynes/Void-Homelab(void-v2): thelinks.jsview +/api/linksproxy + Apps rail entry + CHANGELOG.- Wiki: new "Kutt / Link Shortener LXC (113)" page under Hosts & Services.