Rework the apprenticeship-specific profile into a general electrical apprenticeship pitch, folding in the useful framing from Why Electrical, and tighten experience and Additional copy. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
My Web
Welcome to the source code for my website! Please contact me if you would like to collaborate and thank you for visiting.
This website is self-hosted on my Raspberry Pi. Any interference or hax and the killswitch will activate and stop the UK national grid power system so please don't tamper with my domain :).
The use of AI
This has been created with a heavy amount of AI. Initially, I began with articles from medium informing me of how to use Nginx to host static sites and reverse proxy to other services. They were incredible helpful and I wish I were able to give credit them, but sadly they were read too long ago.
After hearing all the hype on LinkedIn and feeling the pressure to keep up a high output without AI, I eventually caved in. There is an immense advantage not having to scan though documentation to find relevant functions, asking how other developers implement their infrastructure and finding what command will achieve your outcome quickly. Sure, it would be good to have that already cached in your own human memory. But my reasoning is that you will have cached it after being reminded by AI enough times, similar to how flashcards work. I may not be an expert in the specific tool, though I've been able to get good enough at it for my own purposes.
Architecture
All services run in Docker containers orchestrated by Docker Compose behind Nginx as a reverse proxy on a single bridge network.
vue ── Frontend build (outputs dist to shared volume)
nginx (80, 443) ── Frontend SPA + Reverse Proxy
backend (8080) ── Go API (GraphQL + REST)
db (5432) ── PostgreSQL 16
icecast2 (8000) ── Audio Streaming (Icecast2 + Liquidsoap)
gitea (3000) ── Self-Hosted Git
quartz (8080) ── Obsidian Notes Publisher (Quartz v4.4.0)
searxng (8080) ── Meta Search Engine
hasura (8080) ── Hasura GraphQL Engine (Docker profile: hasura)
autoheal ── Auto-restart unhealthy containers
certbot ── SSL Certificate Management (disabled in dev)
Tech Stack
Frontend - Vue 3, Vite, Tailwind CSS v4, Pinia, Vue Router, markdown-it (wikilinks + KaTeX), Rust/WASM
Backend - Go (Gin), gqlgen (GraphQL), GORM, PostgreSQL, JWT auth, WebSockets
Integrations - Spotify API, Steam API, Anthropic Claude API, Icecast2
Infrastructure - Docker Compose, Nginx, Let's Encrypt (Certbot), Gitea + Act Runner
Features
- Spotify integration (currently playing, recently played)
- Steam integration (online status, recent games)
- Obsidian note viewer via Quartz
- Live radio streaming via Icecast2 + Liquidsoap
- Real-time chat over WebSockets with image/video uploads
- Blog with admin panel (CRUD)
- Activity and rowing session tracking
- AI image processing integration to extract rowing data from images (will be superseeded by openCV scanning)
- Fan shrines (GTO, Evangelion, Demoman, Skip Skip Benben)
- Self-hosted Git (Gitea) with CI/CD and commit feed on homepage
- Printable CV with role-specific sections
- Job application tracker with status workflow and CSV export (admin-only,
/cv/jobs) - Database-backed bookmarks grouped by category, managed via GraphQL (admin-only)
- SearXNG meta search engine (admin-only)
- Hasura GraphQL console (admin-only)
- Admin-gated routes:
/searxng,/notes,/hasurarequire admin JWT via Nginxauth_request - Landing page with animated stamps section
- Route transitions (slide/fade) and performance optimizations (gzip, WOFF2 fonts, lazy loading)
- Backend healthcheck with autoheal container for automatic recovery
Pages
| Route | Description |
|---|---|
/ |
Landing page |
/stp |
Home dashboard with grid layout |
/admin |
Admin login + panel (authenticated) |
/cv |
Curriculum Vitae (printable) |
/cv/jobs |
Job application tracker (admin-only, hidden print) |
/bookmarks |
Bookmarks (database-backed, grouped by category) |
/notes/:path |
Obsidian note viewer (via Quartz, admin-only) |
/shrines |
Fan shrine index + individual shrines |
API
GraphQL
Endpoint: POST /api/graphql
Playground: GET /api/graphql (when GQL_PLAYGROUND=true)
| Operation | Type | Description |
|---|---|---|
users |
Query | Get all users |
user(id) |
Query | Get user by ID |
me |
Query | Get authenticated user |
posts |
Query | Get all posts |
post(id) |
Query | Get post by ID |
activities |
Query | Get all activities |
favorites |
Query | Get all favorites |
rowingSessions |
Query | Get all rowing sessions |
post(id) |
Query | Get post by ID |
activities |
Query | Get all activities |
favorites |
Query | Get all favorites |
rowingSessions |
Query | Get all rowing sessions |
messages |
Query | Get all messages |
spotifyListening |
Query | Currently playing track |
spotifyRecent |
Query | Recently played tracks |
giteaFeed |
Query | Latest Gitea activity |
steamStatus |
Query | Steam online status |
login |
Mutation | Authenticate user |
logout |
Mutation | Logout |
refreshToken |
Mutation | Refresh auth token |
createPost / updatePost / deletePost |
Mutation | Post CRUD (admin) |
createUser / deleteUser |
Mutation | User management (admin) |
setUserAdmin |
Mutation | Toggle admin status |
createFavorite |
Mutation | Add favorite (admin) |
createActivity |
Mutation | Add activity (admin) |
bookmarks |
Query | Get all bookmarks |
createBookmark / deleteBookmark |
Mutation | Bookmark CRUD (admin) |
jobApplications |
Query | Get all job applications (admin) |
createJobApplication / updateJobApplication / deleteJobApplication |
Mutation | Job application CRUD (admin) |
REST Endpoints
Auth
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/login |
Login (rate limited: 5/min) |
| POST | /api/auth/refresh |
Refresh token |
| GET | /api/auth/check |
Check token validity |
| POST | /api/auth/logout |
Logout |
| GET | /api/auth/validate-admin |
Validate admin JWT (used by Nginx auth_request) |
Access tokens are valid for 7 days; refresh tokens for 365 days. ValidateAdmin also refreshes the access token if it is within 24 hours of expiry.
Spotify
| Method | Path | Description |
|---|---|---|
| GET | /api/spotify/callback |
OAuth callback |
| GET | /api/spotify/listening |
Currently playing |
| GET | /api/spotify/recent |
Recently played |
Public
| Method | Path | Description |
|---|---|---|
| GET | /api/favorites |
Get all favorites |
| GET | /api/rowing |
Get all rowing sessions |
| GET | /api/activity |
Get all activities |
| GET | /api/posts |
Get all posts |
| GET | /api/posts/:id |
Get post by ID |
| GET | /api/user |
Get all users |
| GET | /api/user/:id |
Get user by ID |
Protected (auth required)
| Method | Path | Description |
|---|---|---|
| POST | /api/messages/upload |
Upload message file (rate limited: 5/min) |
Admin (auth + admin required)
| Method | Path | Description |
|---|---|---|
| POST | /api/favorites |
Create favorite |
| POST | /api/rowing |
Create rowing session |
| POST | /api/activity |
Create activity |
| POST | /api/posts |
Create post |
| PUT | /api/posts/:id |
Update post |
| DELETE | /api/posts/:id |
Delete post |
| POST | /api/user |
Create user |
| PUT | /api/user/:id |
Update user |
| DELETE | /api/user/:id |
Delete user |
| PATCH | /api/user/:id/admin |
Set/unset admin |
| POST | /api/radio/upload |
Upload radio song |
| GET | /api/radio/songs |
List radio songs |
| DELETE | /api/radio/songs/:filename |
Delete radio song |
| PATCH | /api/radio/songs/:filename/disable |
Disable radio song |
| PATCH | /api/radio/songs/:filename/enable |
Enable radio song |
WebSocket
| Method | Path | Description |
|---|---|---|
| GET | /api/ws |
WebSocket chat (10s ping keepalive) |
Nginx Proxy Routes
| Route | Target | Notes |
|---|---|---|
/api |
backend:8080 | API (rate limited: 30r/s) |
/radio |
icecast:8000 | Audio streaming |
/gitea |
gitea:3000 | Git service |
/hasura |
hasura:8080 | GraphQL console + WebSocket (admin-only) |
/notes |
quartz:8080 | Obsidian notes (admin-only) |
/searxng |
searxng:8080 | Search engine (admin-only) |
/uploads |
local alias | User-uploaded files |
/hasura, /notes, and /searxng are protected by auth_request to GET /api/auth/validate-admin. Requests without a valid admin JWT are rejected with 401.
Deprecated Endpoints
Backend note API (GET /api/notes/*path) - The backend has a REST endpoint that serves note files directly from the mounted Obsidian vault. This is superseded by the Quartz service which now handles note rendering at /notes/. The backend endpoint still exists in code but is no longer the primary note serving path.
Setup
.env
Create a .env file in the project root. All services read from this file.
| Variable | Description |
|---|---|
POSTGRES_USER |
PostgreSQL username |
POSTGRES_PASSWORD |
PostgreSQL password |
POSTGRES_DB |
Main app database name |
POSTGRES_PORT |
PostgreSQL port (typically 5432) |
POSTGRES_HOST |
PostgreSQL hostname (use db for Docker) |
GITEA_HOST |
Gitea hostname (use gitea for Docker) |
GITEA_PORT |
Gitea HTTP port (typically 3000) |
GITEA_INTERNAL_TOKEN |
Gitea internal API token (generate with gitea generate secret INTERNAL_TOKEN) |
GITEA_LFS_JWT_SECRET |
Gitea LFS JWT secret (generate with gitea generate secret LFS_JWT_SECRET) |
GITEA_OAUTH2_JWT_SECRET |
Gitea OAuth2 JWT secret (generate with gitea generate secret JWT_SECRET) |
POSTGRES_GITEA_DB |
Gitea database name |
UPTIMEKUMA_HOST |
Uptime Kuma hostname (planned) |
UPTIMEKUMA_PORT |
Uptime Kuma port (planned) |
SEARXNG_HOST |
SearXNG hostname (use searxng for Docker) |
SEARXNG_PORT |
SearXNG port (typically 8080) |
SEARXNG_SECRET_KEY |
SearXNG secret key (random hex string) |
WALLABAG_HOST |
Wallabag hostname (planned) |
WALLABAG_PORT |
Wallabag port (planned) |
QUARTZ_HOST |
Quartz hostname (use quartz for Docker) |
QUARTZ_PORT |
Quartz port (typically 8080) |
GITEA_RUNNER_HOST |
Gitea runner hostname |
GITEA_RUNNER_NAME |
Gitea runner display name |
GITEA_RUNNER_REGISTRATION_TOKEN |
Token to register Gitea Actions runner |
BACKEND_PORT |
Backend port (typically 8080) |
BACKEND_HOST |
Backend hostname (use backend for Docker) |
BACKEND_SECRET |
JWT signing secret |
BACKEND_ENDPOINT |
API path prefix (typically /api) |
OBSIDIAN_DIR |
Absolute path to Obsidian vault on host machine |
SPOTIFY_CLIENT_ID |
Spotify app client ID |
SPOTIFY_CLIENT_SECRET |
Spotify app client secret |
SPOTIFY_REDIRECT_URI |
Spotify OAuth redirect (e.g. https://www.<DOMAIN>/api/spotify/callback) |
SPOTIFY_AUTH_STATE |
Arbitrary state string for Spotify OAuth |
ICECAST_SOURCE_PASSWORD |
Icecast source connection password |
ICECAST_RELAY_PASSWORD |
Icecast relay password |
ICECAST_ADMIN_USER |
Icecast admin username |
ICECAST_ADMIN_PASSWORD |
Icecast admin password |
ICECAST_HOST |
Icecast hostname (use icecast for Docker) |
ICECAST_PORT |
Icecast port (typically 8000) |
ICECAST_MOUNT |
Icecast mount point (e.g. /stream) |
LIQUIDSOAP_HARBOR_MOUNT |
Liquidsoap live input mount (e.g. /live) |
LIQUIDSOAP_HARBOR_PORT |
Liquidsoap harbor port (e.g. 8005) |
DOMAIN |
Production domain name |
EMAIL |
Email for Let's Encrypt registration |
CLAUDE_API_KEY |
Anthropic Claude API key |
STEAM_API_KEY |
Steam Web API key |
STEAM_ID |
Steam user ID |
HASURA_GRAPHQL_ADMIN_SECRET |
Hasura admin secret |
HASURA_HOST |
Hasura hostname (use hasura for Docker) |
HASURA_PORT |
Hasura port (typically 8080) |
SEED_DB |
Set to true to seed test data on startup |
Gitea Config
Copy from the template and fill in secrets:
cp gitea/config/app.ini.template gitea/config/app.ini
Populate LFS_JWT_SECRET, SECRET_KEY, INTERNAL_TOKEN, JWT_SECRET, and the database PASSWD. Alternatively, the Gitea entrypoint generates app.ini from the template using environment variables.
SearXNG Config
Copy from the template:
cp searxng/settings.yml.template searxng/settings.yml
The Docker entrypoint handles environment variable substitution (${BASE_URL}, ${SEARXNG_SECRET_KEY}) automatically, so manual setup is only needed when running outside Docker.
Spotify Token Setup
- After authorization, Spotify redirects to the callback endpoint which stores tokens at
/backend/token/spotify_token.json - Tokens are refreshed automatically; the file persists across container restarts via volume mount
Obsidian Notes Setup
- Set
OBSIDIAN_DIRin.envto the absolute path of your Obsidian vault on the host machine - The vault is mounted read-only into the Quartz container at
/quartz/content - Quartz builds a static site from the vault on startup and serves it at
/notes/ - The backend also mounts the vault at
/backend/notes(legacy, see deprecated endpoints above)
SSL Certificates (Certbot)
Initial setup (production):
- Set
DOMAINandEMAILin.env - On first run, Nginx starts with
nginx_setup.conf.templatewhich only serves the ACME challenge route at/.well-known/acme-challenge/ - Certbot requests a certificate via
certbot certonly --webroot - Once the certificate is issued to
certbot/conf/live/<DOMAIN>/, restart Nginx — it will detect the certs and switch to the fullnginx.conf.template - Certbot checks for renewal every 12 hours automatically
Dev mode: Nginx generates a self-signed certificate for localhost automatically.
Icecast Radio
Place at least one .mp3 file in icecast2/fallback_music/. Liquidsoap plays these as fallback when no live source is connected. Connect a live source to port 8001 on the LIQUIDSOAP_HARBOR_MOUNT.
Gitea Runner
- Download the
act_runnerbinary from Gitea releases and place ingitea-runner/ - Set
GITEA_RUNNER_REGISTRATION_TOKENin.env - The runner registers automatically on first startup
Dev Mode
Run the full stack over plain HTTP without SSL certificates:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
This:
- Uses an HTTP-only Nginx config with all routing (SPA, backend proxy, radio, gitea, etc.)
- Generates a self-signed certificate for localhost
- Disables certbot
- Seeds the database with test data (
SEED_DB=true) - Enables GraphQL playground and introspection
- Enables Hasura console and dev mode
Visit http://localhost to test.
Frontend only (hot reload)
cd vue && npm run dev
Vite dev server proxies /api to localhost:8080, /gitea to localhost:3000, /radio to localhost:8000.
Untracked Files
These files are git-ignored and must be created manually:
| File | Notes |
|---|---|
.env |
See setup section above |
gitea/config/app.ini |
Copy from app.ini.template or let entrypoint generate it |
searxng/settings.yml |
Copy from settings.yml.template or let entrypoint generate it |
certbot/conf/, certbot/www/ |
Created automatically by certbot; use dev mode to skip |
backend/token/ |
Created automatically by Docker volume mount |
icecast2/fallback_music/*.mp3 |
Place at least one MP3 file |
gitea-runner/act_runner |
Download from Gitea releases |
gitea-runner/.runner |
Generated on first runner startup |
Future Ideas
- More Rust to WASM
- ML for chatboards
- Cache requests
- Design more webpages
- Calendar to show radio times
- Nice smooth function background and transitions
- Design shrines
- Redis (not really but practical experience)
