Bruin Clubs
1,863 groups you can join at UCLA, with what each one is, who can join, and how. Built from UCLA’s live registry and from the school, department and Recreation pages the registry does not cover. Independent of UCLA; live at bruinclubs.org since September 2026.
At a glance
- Server
- Go 1.27, about 13,000 lines;
net/http,html/template,embed - Dependencies
- Two: a pure-Go SQLite driver and
golang.org/x/crypto - Tests
- 112 test functions, run with the race detector; a separate security suite
- Data
- 1,863 entries merged from the registry and the pages it misses, rebuilt by Python scripts
- Hosting
- One hardened container behind a Cloudflare Tunnel, no port open
- Rendering
- About 1 ms per page on the server; 0.24 s median load for visitors
- Source
- Private
Where the data comes from
UCLA publishes a directory of registered campus organizations, and the obvious site is a search box over it. It is also not the whole list. Of the 1,863 entries, 1,614 are registered organizations: every entry in the public registry on September 23, 2026 except two of UCLA’s own test records. The other 249 live only on pages the registry does not cover: 143 clubs that professional schools and departments list on their own sites (Anderson’s 42 MBA clubs, Dentistry’s 39, Medicine’s 25, and so on), 60 club sports from UCLA Recreation, 35 campus ensembles open to students outside the music major, and 11 student government, media and spirit groups.
Whatever its source, a group becomes one entry of one shape: a type, one of 15 browse areas, categories, who can join, a description with a label saying where it came from, contact and social links, any sign-up form or group chat, a “how to join” text, and former names. Registered organizations are identified by their registry ID, so when UCLA renames one, saved lists keep working and the old address redirects. Compared with an early-2026 copy of the registry, 220 organizations are new this year, 6 are gone and 27 were renamed, and the site shows the new ones as new.
159 of the registered organizations have no description anywhere public after three research passes, and their pages show only what exists. A few entries look like one group registered twice; UCLA lists both, so both stay, and the build prints a warning rather than choosing for UCLA.
A build that refuses to write bad data
Python scripts, standard library only, do the rebuilding. One re-pulls the registry and the club sports pages. One re-checks every Discord invite against Discord and drops the expired ones. One checks all 517 websites twice and flags the dead ones. Then build.py merges the sources, validates them, and writes one JSON file the server loads, plus the same data as an offline HTML page, a spreadsheet with a shortlist column, and a CSV.
Validation is strict and it stops the build: an unknown category or member type, a duplicate name, a malformed URL or email, leftover HTML or mojibake in a description, or a registry with implausibly few organizations means nothing is written and every problem is listed. Things that deserve a look but are not wrong, such as possible duplicates and dead links, are printed as warnings and kept. A --check mode compares the committed output with a fresh build, and the deploy refuses to bundle a tree where they differ.
A Go server with two dependencies
Apart from a pure-Go SQLite driver and golang.org/x/crypto for password hashing, the server is the standard library: net/http routes written as method and path, html/template, and embed for the templates and static files, so the binary is the deployment. The dataset is read into memory at start, and search and browsing run over it there. SQLite holds what belongs to a visitor: accounts, the clubs they have saved, feedback, and the site’s own analytics.
Every page is rendered on the server. A small script adds instant search, one-tap saving and a search palette on top, and every page works without it. Search folds case and accents, and each word of a query has to appear somewhere in a group’s name, description, categories, area or type; a short hand-written table of aliases widens a few common words, so “premed” also finds “pre-health”, and a match in the name outranks one in the description. Filters cover the area, the type, who can join, whether a group is new this year, and whether it lists a sign-up form or a chat.
Accounts exist so that people can save clubs. Passwords are checked against a list of common ones, reset links expire in an hour, and the admin dashboard, which is the site’s first-party analytics, sits behind both an admin flag and Cloudflare Access in production. The security test suite covers the things a public site with accounts has to get right: session handling, CSRF, rate limits, the shapes of input that should be rejected.
Deploys and rollback
The site is one Docker Compose project on a small ARM VPS: the Go server in a distroless image, non-root, read-only root filesystem, no Linux capabilities, 1 CPU and 768 MB; and cloudflared, which connects outbound to a Cloudflare Tunnel and forwards the hostnames to the server. No host port is published and no firewall rule was added, so the server has no route to the internet and nothing reaches it except the tunnel. It shares no network, volume or container with anything else on the machine.
A deploy bundles only files git tracks, and refuses to bundle a tree whose data does not match a fresh build. On the server it builds a new image while the old container keeps serving, with the Go tests running inside the build so a failing test stops everything with nothing changed. It then starts the new image once on a scratch database with no network and waits for its health check, so an image that cannot start never replaces one that can. Only then does it recreate the container on the new image, wait for health again, and, if that fails, switch back to the previous image and bundle by itself and exit non-zero. The one cost is a few seconds of 502 during the swap, because SQLite allows one writer and there is no second copy to hand over to. Rollback is one command; backups are daily; a restore drill is part of the test suite.
Reach, and what is still missing
In its first three weeks, more than 1,400 people used the site without any paid promotion, and 62% of visits came from search engines. 1,380 clicks went through to clubs’ own sign-up forms, Instagram pages and websites; one visitor in three went on to a club’s page, and 580 different clubs received at least one visitor. Those numbers come from the site’s own analytics, with bots and my own visits filtered out.
- The school clubs, ensembles and research files are hand-curated and do not refresh on their own. Registration renews every fall, so the dataset has to be rebuilt after each round.
- Some clubs have no verified joining information beyond the typical process for their kind, which the page labels as typical rather than as that club’s own.
- The source is private, mainly because the dataset includes research notes with links to people’s public profiles that I would rather not republish wholesale.