Full-stack platform · designed, built, and operated solo

CO Tech
Underground

A live radio station, a real-time news dashboard, a multiplayer game platform, and nine other production services (17 in total), sharing one auth system and one deploy pipeline, running on a single self-hosted server. Designed, built, and operated end-to-end by one engineer: architecture, backend, frontend, real-time systems, and infrastructure.

1 developer 17 production services 30+ external APIs 100% self-hosted zero-downtime deploys
Philosophy

No VC funding, no growth hacking, no dark patterns: just a platform built the way I'd want to use it.

Keep it simple, make deliberate tradeoffs, and ship.

Technology

The Stack

No frameworks, no magic: just Node.js, Docker, and well-chosen tools for each job.

Runtime
Node.js 20 ESM (import/export) Express 5 dotenv
Real-time
WebSocket (ws) HTTP upgrade HLS polling Server-sent broadcast
Auth & Sessions
bcrypt express-session session-file-store SSO cookie domain Email verification Cloudflare Turnstile (CAPTCHA)
Infrastructure
Docker Docker Compose Caddy (HTTPS) MINISFORUM UM690L Let's Encrypt
Radio / Video / Media
AzuraCast Liquidsoap Icecast Owncast OBS (RTMP) Jellyfin Immich Plex EmulatorJS (WebAssembly) AMD VAAPI NFS (QNAP NAS)
Data & APIs
rss-parser YouTube Data v3 Twitch API SteamSpy Open-Meteo BLS JOLTS FMP (stocks)
Architecture

Services

Seventeen independently deployed services, unified by shared auth and a Caddy reverse proxy.

🏠
CO Tech Underground
cotechunderground.com
Central hub with full auth system: register, verify, manage accounts, admin panel. Dashboard tiles are drag-to-reorder (HTML5 native drag API) and the order persists per-user across restarts and deploys.
📻
Radio Web
radio.cotechunderground.com
Stream player, WebSocket chat, real-time light show, live video panel.
📡
Newspaper
news.cotechunderground.com
Real-time news dashboard aggregating 30+ data sources across trending searches, music charts, tech, automotive, world news, sports, gaming, finance, weather, streaming movies & TV (with platform badges), and FDA recalls by category. Auto-refreshes twice daily with admin-triggered manual refresh.
💼
Portfolio
portfolio.cotechunderground.com
Personal site and technical showcase; this page is the platform deep-dive.
🎮
Game Night
gamenight.cotechunderground.com
Jackbox-style multiplayer game platform. Host creates a room, 2–8 players join with a 5-digit code. Five games: Word Sprint (Scattergories-style), Trivia Wager (bet your points), Drag Race (reaction-time racing with gapless engine audio), Tower Defense (co-op with A* pathfinding and chain-lightning Tesla tower), and Tanks (turn-based artillery with server-side trajectory physics). Auto-reconnect on WS drop or browser close. Opt-in WebRTC voice chat, synthesized sound effects, and per-player tower colors. All-time win leaderboard; session scores live in the lobby. Plugin architecture: adding a game is one file and a registry entry.
🏘️
Your Area
space.cotechunderground.com
Personal publishing platform. Post blog entries (markdown), upload photos, audio, and video. Customize your space with a profile picture, background image, theme song (YouTube autoplay), accent color, and font. Your Town shows everyone's posts in a community feed; click any username to visit their customized space.
💡
Idea Board
ideas.cotechunderground.com
Community feature requests and voting. Submit ideas, upvote favorites, track what's done. Toggle-voting, admin done/reopen workflow, owner and admin delete controls.
💙
Support this Tech
support.cotechunderground.com
Platform support and sustainability page. Explains the real costs behind running this stack (domains, server, internet) and why access codes exist. Donation options coming soon.
🕹️
Emulators
emulators.cotechunderground.com
Browser-based retro game emulation: NES, SNES, and N64 via EmulatorJS WebAssembly cores. ROMs served from the QNAP NAS over NFS. Per-user save states stored server-side as binary blobs, auto-loaded on next play session. ROM paths validated against live directory listings; no path traversal possible.
🎥
Owncast
Self-hosted live video stream via OBS RTMP → HLS. Appears on the radio page when live.
🎬
Jellyfin
Self-hosted media server for movies, TV, and music. AMD VAAPI hardware transcoding via the Ryzen 9 6900HX iGPU. Media library stored on the NAS over NFS. Restricted to admin and family roles.
📷
Immich
Self-hosted photo and video library, replacing iCloud and Google Photos. iOS/Android auto-backup via the Immich mobile app. Photos stored on the NAS over NFS. Restricted to admin and family roles.
🎞️
Plex
Self-hosted media server for movies, TV, and music. AMD VAAPI hardware transcoding via the Ryzen 9 6900HX iGPU. Media library stored on the NAS over NFS alongside Jellyfin. Restricted to admin and family roles.
🏎️
Automotive
datalog.cotechunderground.com
Client-side Accessport CSV log analyzer for a 2015 Ford Focus ST. Drag-and-drop upload; Chart.js multi-channel time-series chart with 14 selectable sensors and WOT pull regions highlighted in red. Auto-detects wide-open-throttle pulls, flags knock corrections, lean AFR, high charge air temp, and fuel trim deviations against Stage 1 tune thresholds. No files stored server-side; all analysis runs in the browser.
🏠
Rentals
Admin-only landlord tool for managing rental properties, leases, and maintenance. Two tabs: Properties (with lease templates that pre-populate new leases) and Maintenance. Lease PDFs are scrubbed of form fields and metadata on upload via pdf-lib. Templates are copied to leases so editing a template never affects existing ones. Status state machine: draft → pending → active → terminated; expired computed on read. Every data route requires admin; no tenant-facing features.
🗂️
Job Tracker
jobtracker.cotechunderground.com
Personal job application tracker. Track company, role, salary range, location/work type, contact info, and a status pipeline (applied → interviewing → offer/rejected/withdrawn) with inline quick-editing; no need to open a full edit form to update status or notes. Each application has its own timeline of dated events, each with optional freeform notes. Per-user data; everyone only sees their own applications.
🛡️
AdGuard Home
Network-wide DNS ad and tracker blocker for every device on the network, filtering before content ever loads rather than per-browser. Runs in host-networking mode to serve DNS directly. Admin-only, LAN-only access, no public entry point.
Technical deep-dives

Feature Highlights

Click any card to expand the implementation details.

🎵
Internet Radio
AzuraCast · Liquidsoap · Icecast · 24/7 AutoDJ with live DJ takeover

How it works

  • AzuraCast orchestrates the full radio stack in Docker containers
  • Liquidsoap handles stream logic: AutoDJ from music library, crossfading, and fallback behavior
  • Custom autodj.liq enables a live DJ harbor; DJs connect via BUTT or Mixxx, Liquidsoap gives them priority and falls back to AutoDJ on disconnect
  • Icecast serves the MP3 stream at 128 kbps to listeners
  • Caddy proxies /stream* and /status* to Icecast, everything else to the radio web server
  • Frontend polls the Icecast status endpoint every 45 seconds to update now-playing metadata

Key implementation

# autodj.liq - harbor for live input live = audio_to_stereo( harbor("live", auth=check_auth)) radio = fallback(track_sensitive=false, [mksafe(live), autodj]) output.icecast(%mp3(bitrate=128), host="icecast", password="...", mount="...", radio)
Real-time Chat & Light Show
WebSocket · 16 effects · rate limiting · admin-only controls

How it works

  • WebSocket server lives on the same HTTP server as Express; the upgrade event is intercepted to run session middleware before handing off to ws
  • Users send { type: 'light', effect: 'strobe' }; server validates the effect name against an allowlist and broadcasts to all connected clients
  • Admin-only effects (blackout) are rejected server-side if the session isn't admin, regardless of what the client sends
  • Per-socket rate limiting: chat and light events share the same counter to prevent flooding
  • 16 effects: strobe, bass-pulse, confetti (65 JS particles), 3 lazer colors, 3 scanner colors, 3 flood colors, spotlight, color-cycle, clear-lights, blackout
  • Lazer/scanner color set via --lazer-color CSS custom property; direction classes drive animation

Key implementation

// WS upgrade - apply session before auth check httpServer.on('upgrade', (req, socket, head) => { sessionMiddleware(req, fakeRes, () => { if (!req.session?.userId) { socket.write('HTTP/1.1 401\r\n\r\n'); socket.destroy(); return; } wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req)); }); });
🎥
Live Video Streaming
Owncast · OBS RTMP → HLS · dynamic show/hide panel

How it works

  • OBS streams via RTMP to Owncast; Owncast transcodes to HLS and serves a web player
  • Radio web server exposes GET /video-status, which proxies the Owncast status API and returns { online: true/false }
  • Frontend polls /video-status every 30 seconds; video panel slides in when live, hides when offline
  • Owncast and radio-web run on separate Docker networks; radio-web is joined to both so it can reach Owncast by container name
  • Owncast admin UI is LAN-only; Caddy proxies video.cotechunderground.com to the container internally

Key implementation

// Only attach the HLS pipeline while actually live, // tears it down on offline so nothing buffers in the background async function checkVideoStatus() { const { online } = await (await fetch('/video-status')).json(); if (online === videoOnline) return; // no-op unless state flips videoOnline = online; if (online) { videoPanel.hidden = false; initVideo(); } else { videoPanel.hidden = true; destroyVideo(); } } function initVideo() { const src = `${videoBaseUrl}/hls/stream.m3u8`; if (Hls.isSupported()) { hlsInstance = new Hls(); hlsInstance.loadSource(src); hlsInstance.attachMedia(videoEl); } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) videoEl.src = src; // Safari native HLS }
📊
Newspaper Dashboard
30+ external APIs · Google Trends RPC · DST-aware scheduled caching · 13-tab UI

How it works

  • All 30+ data sources are fetched in parallel with Promise.allSettled; any individual failure returns an empty array without crashing the batch
  • Google Trends uses a 2-step batchexecute RPC (no npm package; the existing libraries are broken). First request gets a token, second uses it to fetch the actual 48-hour trending searches
  • RSS parsing covers 9 categories: tech, automotive, world, gaming, stocks, crypto, AI, cybersecurity, sports
  • Movies & TV from TMDB filtered to with_watch_monetization_types=flatrate; streaming-only titles with per-item provider fetch (Netflix, Hulu, Disney+, etc.) displayed as inline badges. Bearer token kept server-side only
  • Recalls split into three independent FDA enforcement feeds: Food, Drug, and Device; each sorted newest-first and rendered in its own panel. CPSC was dropped (returns 26 MB unconditionally, ignores all query params)
  • Steam trending uses SteamSpy top100in2weeks; one call returns all names and concurrent users, replacing a serial N+1 per-game loop
  • YouTube genre charts are fetched on-demand when a genre tab is opened (client-side cache prevents re-fetching), saving ~1,000 quota units/day of YouTube Data API v3
  • Data is cached to disk after every successful fetch; server restart resumes from cache immediately; connected browser tabs detect updates via 5-minute polling on fetchedAt
  • Auto-refresh scheduled at 4 AM and 4 PM America/Denver via DST-aware Intl.DateTimeFormat; computes the next UTC fire time from the Denver wall clock so the schedule tracks MDT/MST automatically. Each reschedule logs the next fire time
  • 13-tab UI built with vanilla JS panel builders; each tab renders from the same API response object

Key implementation

// No cron, no fixed UTC offset - derives the next 4am/4pm fire time // from Denver's actual wall clock so MST/MDT flips never need a redeploy function msUntilNextRefresh() { const now = Date.now(); const candidates = [4, 16].map(hour => { const parts = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Denver', year: 'numeric', month: '2-digit', day: '2-digit' } ).formatToParts(new Date(now)); const { year, month, day } = Object.fromEntries(parts.map(p => [p.type, p.value])); const candidate = new Date(`${year}-${month}-${day}T${String(hour).padStart(2,'0')}:00:00`); const denverOffset = new Date(candidate.toLocaleString('en-US', { timeZone: 'America/Denver' })) - candidate; const utcMs = candidate.getTime() - denverOffset; return utcMs <= now ? utcMs + 86400000 : utcMs; // already passed today → tomorrow }); return Math.min(...candidates) - now; }
🔐
SSO Auth System
Shared cookie domain · email verification · access codes · account locking

How it works

  • All web apps share a single cookie domain; one login works everywhere
  • User data lives on a shared persistent volume; all apps read/write the same record with serialized writes to prevent corruption
  • Passwords hashed with bcrypt. Sessions backed by a persistent file store; survive container restarts
  • Login always runs the bcrypt comparison, even against a dummy hash when the account doesn't exist; failed logins take the same time either way, so response timing can't be used to enumerate valid usernames
  • Email verification uses a time-limited signed token. The verification flow redirects to a fresh login with a success banner
  • New users require a valid access code to register when one is configured; admin can toggle the requirement on/off without deleting existing codes
  • Registration also runs a Cloudflare Turnstile check before an account is created, to cut down on bot signups
  • Admin can lock accounts; locked users see a clear error on login and cannot access any app
  • Login attempts are rate limited per IP

Key implementation

// requireVerified - async, DB is source of truth // session cache is never trusted for auth state, so a revoked or // unverified account loses access immediately, not at next login export async function requireVerified(req, res, next) { if (!req.session?.userId) return denyAccess(res); const users = await readUsers(); // re-read on every request, not cached in session const user = users.find(u => u.id === req.session.userId); if (!user || !user.emailVerified) return denyAccess(res); next(); }
🛡️
Security-First Development Process
OWASP-style code review · per-service dependency audits · access-control hardening on every change

How it works

  • Every change (not just ones that look security-related) goes through a checklist covering injection/XSS, broken access control, path traversal, SSRF, CSRF, session handling, prototype pollution, misconfiguration, and sensitive-data exposure
  • Each of the thirteen independently versioned Node.js services gets its own dependency audit; major-version bumps are verified by actually booting the service and exercising real routes, not just trusting a clean install
  • User-controlled input is escaped for the exact context it lands in: HTML text/attribute escaping, JS-string context, and URL-scheme validation are treated as three different problems, not one
  • Findings are grounded in the actual code path (input traced to output) rather than reported as theoretical risk

Worked example

Adding a new admin-only route, the first pass reused the same permission gate built for services that are meant to be role-configurable (grantable to a "family" tier, for example). Review caught that this made the new route one settings-panel checkbox away from being grantable to a non-admin role, not a bug yet, but a soft boundary on something that should be a hard one. It was reworked to check the admin flag directly, with no permission list in the path at all.

// Before: routed through the same configurable // permission list used by role-grantable services gateRoute(homeService, '/api/access-check?app=adminTool'); // After: hardcoded to the session's admin flag, // no permission list, so nothing can widen it later gateRoute(homeService, '/api/is-admin');
🚦
Drag Race · Multiplayer Reaction Game
WebSocket rooms · server-driven tree · two-bump staging · live radio on setup · gapless engine audio · persistent leaderboard

How it works

  • Players choose 1-player (solo) or 2-player mode and normal or pro tree before starting. In 2-player, the host gets a 5-digit room code; the guest enters it to join via WebSocket
  • All tree timing runs on the server with setTimeout; clients only render light states. This ensures both players see lights fire at the same instant regardless of network latency
  • Staging uses a two-click bump: first click animates the prestage light locally; second click animates the stage light and sends bump to the server. The server only broadcasts staged to the other player (own lane is already animated)
  • The GO button is always visible; pressing during the amber sequence triggers a red light. greenWillBeAt is computed the moment the tree starts (before the random delay runs), so the "how early" time shown on red light is accurate even if the player fires before the first amber
  • Normal tree: 3 ambers sequentially, 500ms each (1.5s total). Pro tree: all 3 ambers simultaneously for 400ms. Both followed by a 1–4 second random pre-start delay
  • Leaderboard persists to dragrace_leaderboard.json on the shared Docker volume. Raw entries stored; four leaderboard sections (1P Normal · 1P Pro · 2P Normal · 2P Pro) computed on read as best time per user per mode+tree combination, top 10 each. Fouls and timeouts never recorded
  • Motorcycle engine sounds track staging state: idle at game start, partial throttle on first bump, two-step rev limiter on second bump (loops through all amber lights), cuts at green. In 2-player mode both engines play simultaneously; the opponent's engine runs at 0.6× volume so you hear both riders on the line
  • Setup page streams Basement Rhythm Radio live while you wait; automatically pauses the moment a game starts and resumes when you return to the setup screen (respects a manual pause)
  • Server-side double-fire guard: checkResults checks room.state === 'done' before broadcasting; prevents the 5-second post-green timeout from sending a duplicate result message after a player has already reacted

Key implementation

// Gapless looping via AudioBufferSourceNode // HTMLAudioElement.loop has a browser gap; // AudioBufferSourceNode loops at sample level async function play(key) { stop(); const buf = await _getBuf(key); const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true; src.connect(gain); src.start(0); current = { src, gain }; }
// greenWillBeAt computed before the amber sequence runs, so an // early press mid-amber can still be scored against the true green time function runTree(room) { const delay = 1000 + Math.floor( Math.random() * 3000); const amber = room.treeType === 'pro' ? 400 : 1500; room.greenWillBeAt = Date.now() + delay + amber; scheduleAmberSequence(room, () => { broadcast(room, { type: 'tree_green' }); // 5s fallback only starts once green is actually live, // arming it at tree-start would fire mid-sequence room.treeTimer = setTimeout( () => checkResults(room), 5000); }); }
// double-fire guard - treeTimer fires // checkResults again 5s after green even // if GO was already pressed and result sent function checkResults(room) { if (room.state === 'done') return; // … determine winner, broadcast result room.state = 'done'; broadcast(room, { type: 'result', … }); }
🎮
Game Night · Multiplayer Game Platform
Plugin architecture · 5 games · dynamic map · auto-reconnect · WebRTC voice chat · synthesized sound effects · WebSocket rooms

How it works

  • Host creates a room (5-digit code); up to 8 players join via WebSocket. Host picks the game, everyone plays together in real time
  • Each game lives in its own module (games/word-sprint.js, trivia-wager.js, drag-race.js, tower-defense.js, tanks.js) exporting a standard interface. server.js is a thin orchestration layer; it never branches on game type
  • Adding a new game: create the module file, add one line to games/index.js, add a panel and a client-side GameModules entry; core server code untouched
  • Word Sprint: Scattergories-style: 6 categories, 1 letter, 60 seconds. Unique answers score 1 pt; duplicates score 0. 40-category pool with no repeats within a game
  • Trivia Wager: players start with 1,000 pts and bet any amount on each question. Correct = +wager, wrong = −wager, floor at 0. 5 questions per game from a curated pool
  • Drag Race: reaction-time drag racing adapted for 2–8 players. n pts for 1st place, n−1 for 2nd, etc. 30s stage fallback timer fires the tree if not all players are ready
  • Tower Defense: co-op on a dynamic grid (26 cols × 10–43 rows) that scales with player count: 1 lane for solo, up to 4 lanes for 4+ players; lane count locks at game start so the map never shrinks if someone disconnects mid-game. 8 tower types including a chain-lightning Tesla (hits primary + up to 3 chained enemies). A* pathfinding runs on every placement to guarantee all active lanes stay unblocked. Per-player color rings distinguish tower ownership at a glance
  • Tanks: turn-based 2D artillery: players fire shells across procedurally-generated terrain, adjusting angle and power. The server computes the full trajectory instantly on each shot (gravity, tank collision, blast radius damage) and broadcasts the trajectory array to clients for animation. 3 rounds of last-tank-standing; scoring rewards both kills and damage dealt
  • Auto-reconnect: WebSocket drops trigger up to 10 retry attempts with linear backoff (1–8s). Closing the browser saves the room code to localStorage; reopening the page automatically rejoins the active game
  • Host disconnect grace period: 30-second timer before the room tears down, giving the host time to refresh without killing the game for everyone
  • Opt-in WebRTC full-mesh voice chat: click Enable to request mic and join; Disable leaves without disconnecting from the room. Google STUN; no TURN required for typical home networks
  • Synthesized sound effects via Web Audio API oscillators (no external files) for every game event; toggle persists to localStorage
  • All-time wins persist to gamenight_wins.json; top 10 leaderboard shown on the setup page. Session scores shown live in the lobby for every player (even 0 pts)
  • Player disconnect during a game: the active game module's onPlayerLeave hook fires; rounds advance automatically if all remaining players have already submitted

Key implementation

// Tanks CPU opponent, brute-forces the same trajectory simulator // used for real shots across the angle/power space, then aims for the // closest impact to the target and detunes it so it isn't unbeatable function computeCpuShot(room) { const cpu = room.tanksTanks.get('CPU'), target = firstAliveOpponent(room); const turretY = cpu.y - TANK_RADIUS - 4; // shoot from the turret, not tank center let bestAngle = 45, bestPower = 60, bestDist = Infinity; for (let power = 20; power <= 100; power += 5) for (let angle = 5; angle <= 175; angle += 3) { const { impactX, impactY } = computeTrajectory(cpu.x, turretY, angle, power, room.tanksTerrain, allTanks); const dist = Math.hypot(impactX - target.x, impactY - target.y); if (dist < bestDist) { bestDist = dist; bestAngle = angle; bestPower = power; } } // ±12° / ±10% noise keeps the CPU beatable, clamped so noise // can't push a shot into an invalid angle/power range return { angle: Math.max(1, Math.min(179, bestAngle + (Math.random() - 0.5) * 24)), power: Math.max(10, Math.min(100, bestPower + (Math.random() - 0.5) * 20)), }; }
// Tower Defense: A* re-run on every placement, per active lane, so a // tower can never seal off the only route to the base function aStar(sc, sr, ec, er, blocked, gridRows) { const h = (c, r) => Math.abs(c - ec) + Math.abs(r - er); const open = new Map([[key(sc, sr), { c: sc, r: sr, g: 0, f: h(sc, sr), parent: null }]]); const closed = new Set(); while (open.size) { const cur = lowestF(open); if (cur.c === ec && cur.r === er) return tracePath(cur); open.delete(key(cur.c, cur.r)); closed.add(key(cur.c, cur.r)); for (const [nc, nr] of neighbors(cur, gridRows)) { if (closed.has(key(nc, nr)) || blocked.has(key(nc, nr))) continue; relax(open, cur, nc, nr, h); // only keep the shorter path to a cell } } return null; // no route - placement is rejected before it ever lands }
🏘️
Your Area · Personal Publishing & Profiles
Secure file uploads · magic-byte validation · per-user theming · YouTube theme songs · community feed

How it works

  • Two-tab SPA: Your Town (community feed, all users' posts) and Your Area (own posts + customizable profile). Clicking a username in the feed opens a third SPA state; that user's full profile view with their theme applied
  • Users post blog entries (markdown, 50k chars, rendered via marked.js + DOMPurify client-side), images, audio, or video. Each type has its own size limit (images 10MB, audio 50MB, video 200MB)
  • File uploads land in a temp dir first; file-type reads the actual magic bytes before the file is accepted; extension and Content-Type header are ignored. Only then does the file move to its permanent UUID-named path
  • Per-user 500MB quota is checked before each upload by summing all files in the user's upload directory. Profile media (pic + background) is excluded from the quota
  • Profile customization: upload a profile picture (5MB limit), set a background image that displays behind the profile header, pick an accent color (hex color picker), choose a font (system default / Caveat handwriting / VT323 retro), and paste a YouTube URL as a theme song
  • Theme application: accent color and font are set as CSS custom property overrides on the section container only, never globally. This means Your Town always renders in the default theme regardless of whose posts appear
  • YouTube theme song: the server extracts the video ID server-side with extractYouTubeId(); only the 11-char ID is stored. On the profile page, a "▶ Play" button inserts a YouTube iframe with autoplay=1; autoplay is gated behind a user gesture so browsers allow it
  • All media served from authenticated /media/, /profile-pic/, and /profile-bg/ routes. Each validates the userId + filename format with a regex AND verifies a matching record exists before serving; no filename guessing possible

Key implementation

// Magic-byte validation after multer write const detected = await fileTypeFromFile(tmpPath); if (!detected || !ALLOWED_TYPES.has(detected.mime)) { await unlink(tmpPath).catch(() => {}); return res.status(400).json({ error: 'File type not allowed.' }); } // Move to permanent UUID path only if valid const filename = `${randomUUID()}.${ext}`; await rename(tmpPath, path.join(userDir, filename));
// CSS custom properties scoped to section function applyTheme(el, profile) { const accent = profile?.accentColor || '#f472b6'; el.style.setProperty( '--accent', accent); el.classList.remove( 'font-handwriting', 'font-retro'); if (profile?.font !== 'default') { loadGoogleFont(profile.font); el.classList.add( 'font-' + profile.font); } }
📈
API Metrics & Admin Panel
30 tracked sources · CORS cross-origin fetch · persisted counters · per-app maintenance mode · dynamic roles · login-abuse tracking

How it works

  • Every external API call in Newspaper is instrumented with bump(key, success); increments call count and either success or failure counter
  • Metrics are held in memory and flushed to disk after every fetch batch; survives restarts
  • Admin panel fetches live metrics cross-origin from Newspaper and Game Night using the shared SSO session, and renders a table of calls / successes / failures / last called
  • Admin panel shows per-user visit counts broken down by domain plus aggregate totals per app, plus a 30-day rolling count of distinct anonymous visitor IPs per domain, a coarse signal, not an exact visitor count, since IPv6 privacy-extension rotation means one real visitor can show up as several addresses in a day
  • Failed login attempts are logged with the attempted email, source IP, and reason (unknown email vs. bad password vs. locked account), capped at 200 entries, so admin can tell a real user struggling to log in apart from a bot spraying credentials across many accounts. The generic error shown to the person logging in never changes; the account-exists distinction stays admin-only
  • Roles are fully dynamic: admin creates/deletes roles and assigns per-app permissions from the Access tab, with admin and member protected from deletion. Role changes and per-user overrides both apply immediately, no redeploy needed
  • Maintenance mode: admin panel has a per-app toggle: non-admin users see a 503 page; admins always bypass. The deploy script sets maintenance on/off automatically around each container rebuild

Key implementation

// bump - rolling per-day buckets, not just lifetime totals, // so the admin panel can show today / this week / all-time per source export function bump(key, ok) { const m = _metrics[key] ??= { calls: 0, successes: 0, failures: 0, lastCalledAt: null, daily: {} }; m.calls++; m.lastCalledAt = new Date().toISOString(); ok ? m.successes++ : m.failures++; const today = new Date().toISOString().slice(0, 10); const day = m.daily[today] ??= { calls: 0, successes: 0, failures: 0 }; day.calls++; ok ? day.successes++ : day.failures++; const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 35); const cutoffStr = cutoff.toISOString().slice(0, 10); for (const date of Object.keys(m.daily)) if (date < cutoffStr) delete m.daily[date]; // bounded memory, no external TSDB needed }
🔔
Cross-App Notifications
One shared widget · 12 origins · atomic per-user state · zero markup duplication

How it works

  • A single notification service backs a bell/badge/dropdown widget included by every app plus the radio site: 12 origins, one source of truth
  • Rather than duplicating markup across every app's static page, the widget ships as one script that builds its own DOM and styles at runtime and figures out which app loaded it from the script tag itself; same file works whether it's loaded locally or from another app entirely
  • Admins broadcast a message from the admin panel; each user has independent read/cleared state, so dismissing a notification on one device or app doesn't affect anyone else's view
  • A failed clear (network blip, server error) re-syncs from the server on the next poll instead of silently trusting the optimistic UI update
  • Clear requests are validated against the live notification list server-side, so a malformed or replayed request can't inject arbitrary state

Key implementation

// atomic read-modify-write: queues the read, not just the write, // so concurrent updates from different users can't clobber each other export function serializedUpdate(file, fallback, update) { const prev = queues.get(file) ?? Promise.resolve(); const next = prev.then(async () => { const current = await readJson(file, fallback); const updated = await update(current); await writeJson(file, updated); return updated; }); // Store a version that always resolves. Without this, one failed // update leaves a rejected promise in the queue, every future // .then() chained onto it never runs, so writes to this file // would silently stop forever, not just for this one call queues.set(file, next.then(() => {}).catch(() => {})); return next; }
Infrastructure

Architecture

All services run in Docker on a MINISFORUM UM690L mini PC (AMD Ryzen 9 6900HX, 16GB RAM). Caddy connects to each Docker network to route public traffic.

Internet
Router (port forward) Caddy (HTTPS + Let's Encrypt)

Radio network
Icecast (stream) Liquidsoap (AutoDJ + harbor) radio-web
App network
Newspaper Home Portfolio Game Night Your Area Ideas Support Emulators Automotive Rentals Job Tracker Owncast
Own networks
Jellyfin Immich Plex

Shared volume
user accounts sessions stats api metrics news cache leaderboards posts & profiles uploads maintenance flags save states

Caddy connects to each Docker network, letting it reverse-proxy any service by container name. All twelve web apps share one user data store and one session file store; a single login works across all subdomains via a shared cookie domain. Jellyfin, Immich, and Plex each run on their own isolated Docker network and are restricted to admin and family roles. Media and photos are stored on a NAS over NFS.

Operations

Deployment

A Mac build script packages the full suite; a server deploy script handles everything from image builds to Caddy restarts.

🚀
deploy.sh
One-command deploy from Mac over LAN via rsync + SSH. Run bash scripts/deploy.sh for an interactive menu (1–27), or pass the number directly. Uses ssh -t so server-side sudo prompts work interactively. rsyncs source excluding secrets, node_modules, and runtime data, then runs the server-side deploy script.
⚙️
nas_deploy.sh
Server-side deploy script with 27 options: individual service deploys, full stack rebuild, quick radio sync, Caddy-only restart, NAS mount setup, DDNS updater, Immich, Jellyfin, Emulators, Plex, Automotive, Home Assistant, Rentals, Job Tracker, AdGuard Home, and a NAS backup routine. Automatically enables maintenance mode before stopping a container and clears it after the new one is up.
🔒
Caddy TLS
Caddy handles ACME certificate provisioning automatically via Let's Encrypt HTTP-01 challenge. All subdomains get HTTPS with zero manual cert management. Runs as a standalone Docker container with --restart unless-stopped, connected to both Docker networks so it can reach every service by container name.
💾
Persistent data
User accounts, sessions, and cached news data all live on Docker volumes; container rebuilds never wipe runtime state.
🖥️
MINISFORUM UM690L
Migrated in 2026 from an ARM-based NAS to a MINISFORUM UM690L mini PC: AMD Ryzen 9 6900HX, 16GB LPDDR5, 1TB NVMe. The x86_64 move required updating the AzuraCast Docker base images and switching Caddy to standard ports. Music library stays on the NAS, accessed over the network.
🌐
Dynamic DNS
ISP assigns a dynamic WAN IP. A custom bash script on the server checks the current IP every 30 minutes via a systemd timer and updates all DNS A records when it changes, keeping all subdomains in sync automatically. Deployed via option 14 in the deploy menu.
🛟
Backup & recovery
The codebase itself lives in version control, so a full source recovery is never in question. On top of that, a dedicated backup routine mirrors application state and database contents to redundant NAS storage, independent of the media library's own storage. Recovery objective: reprovision the mini PC, pull the repo, and restore from that mirror rather than rebuild anything from scratch — recovery point is as fresh as the last run, triggered on demand rather than on a fixed schedule.