# 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 `npm` updates 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`, user `kutt`. Install: `npm ci` → (build step if the release needs one) → `npm run migrate` → `npm start`, managed by **`kutt.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 database **`kutt`** owned by a new **`kutt`** role (NOSUPERUSER, owns its DB + `public` schema so Kutt migrations run). Kutt manages its own schema via `npm run migrate`. Rides void-db's existing HA + backups; no impact on the `void` database. ### 3. Domain + CF Access (private → public) - Traefik (mediastack `/docker/proxy/dynamic.yml`): router `link.hynesy.com` → CT 113 Kutt port. Wildcard `*.hynesy.com` DNS 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 via `void.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 #ff4f2e` etc., Cinzel/Cormorant/JetBrains fonts, surfaces) applied to stock Kutt. Lives in `Hynes/URLShortener-void-kutt` and 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 `