hackOS
The platform HackUDC runs on: applications, check-in, offline scanners, the judging queue and the venue screens on one database, replacing four tools.
Overview
hackOS is the platform HackUDC runs on. Applications, check-in, meals, projects, judging rooms, announcements and the screens around the venue all share one database. Before, they were separate tools that shared nothing.
I started it on July 3rd and it's public, under the AGPL. As I write this, on November 7th, it has 3,306 commits, 965 merged pull requests and about 330,000 lines of TypeScript. What it does on event day is in the talk I gave at CoruñaWTF. This page is about why it exists, how it's put together and what it's built to withstand.
Why it exists
At HackUDC 2026, two of the tools the event ran on were mine. I built the staff app, for check-ins, meals and attendance, on top of the registration backend that already existed. And during the event itself I locked myself away and wrote FastTrack, the judging queue, in about three nights. It held up, and at one point I was staring at its dashboard every few minutes to check it was still alive. The full story is in the post I wrote about that edition.
Around them there were three more: a spreadsheet with the run of show, Directus for the public schedule, the challenges and the sponsors, and Grafana for the numbers. Five tools and five logins, with Devpost on top of that for the projects, and none of them knew about the others, so a lot of the same data ended up duplicated between their databases.
hackOS is what came next: one database and one login, so every piece of data lives in one place.
Plan first
Before the first line of code there was a plan/ folder. HackUDC had four editions behind it, and the team had written postmortems of them. We had also asked the sponsors who used FastTrack in the last edition for their feedback. So we already knew, at least, what a system for our event needed to do.
It holds 59 user stories, numbered H1 to H60 (H6 was retired), and they're the source of truth: if another doc disagrees with a story, the story wins. Next to them is a short list of rules that aren't up for debate:
- Permissions come from capabilities, not roles. A judge who is also competing doesn't switch accounts.
- A no-show is a human decision. Nothing expels a team silently.
- A team is never called into two rooms at once if it shares a member.
- Scanners survive bad wifi: a local queue, and retries that are safe to repeat.
The plan only covered what an MVP needed, and it was blunt about its priorities: "The queue block is non-negotiable. FastTrack must improve; the rest is optional." Identity and permissions came first, then the queue and judging, and the mobile app last. That MVP came together fast. Making it easy to use, feature-rich and full of small quality-of-life details has been the long journey, and it isn't over.
Every commit names the story it implements, like feat(queue): call_next transition (H29, H30). Sixty different story numbers show up in the history.
How it works
From the outside, hackOS is two apps and a lot of screens. People use a web app and a mobile app, and the TVs on the venue walls are just the web app on a big display. Behind them there's one API that owns every rule, and a worker that does the slow jobs in the background: sending email, publishing scheduled announcements, keeping the judging queues moving and syncing Wallet passes.
Underneath, Postgres remembers everything that matters. Valkey is fast and disposable: it carries the realtime nudges, a shared cache and the clock that wakes the worker up. MinIO keeps the files. The API and the worker are the same code started in two modes, so the code that queues a job and the code that runs it can't drift apart.
Postgres and Valkey can't be reached from outside at all, and of MinIO only the folder with the sponsor logos is public. Everything people upload, like application files, CVs and profile photos, goes in and out through the API, never through a link to the storage. A link to a file works for whoever holds it, and it keeps working. When the API serves the file instead, it checks who is asking on every request, so a copied link is worthless to anyone else, and a change takes effect right away: someone stops sharing their CV and it stops being served, someone deletes their account and their files are deleted with it. The owner's id is part of every upload's key, so the API can tell whose file it is just from the key.
One deployment serves exactly one hackathon: a second event is a second, completely separate copy of the stack, with its own database and secrets. That also means there's no multi-tenant code to get wrong.
Built to hold up
An event has a few moments where everything happens at once: registration opens, doors open, judging starts. These are the ideas the rest is built around.
One source of truth. Postgres is the only thing that has to survive. Valkey runs with persistence switched off on purpose: if it dies, the background jobs tick again and the screens ask the database for fresh data, and nothing is lost. Even the queue of emails waiting to go out is just rows in Postgres, with their status, attempts and next try. Valkey only supplies the clock, and a cache that is allowed to vanish.
Exactly one winner. Two staff members press "call next" at the same moment. Only one of them gets the team. Every state change locks the rows it touches, so one transition wins and the other is told no. Some rules aren't even checked in code: the schema makes them impossible, like a project having two queue entries for the same challenge.
Retries are safe. Scanners keep working without a connection. A scan is saved on the device first, with an id of its own, and only then sent. It counts as done when the server confirms it has saved it. If the server doesn't answer, the scanner keeps the scan and tries again with the same id, and the server recognises the retry and returns the answer it already stored instead of counting it twice. If the server answers with an error (the record now conflicts with newer data, say), the operator sees why and can fix it.
That id is an idempotency key, and it's on every mutation that matters, not only on scans. The server claims the key before it does the work, and the change, its audit entry and any outgoing notifications are committed in one transaction.
A nudge, not the data. The venue screens never get data pushed at them. They get a nudge, a tiny "the queue changed" message, and then they ask for the new state themselves. When a hundred screens ask at once, only one of them actually queries Postgres. Every change throws away the cached copy of that data, which lives in Valkey and is shared by all the API instances, so the first request to arrive rebuilds it and the rest wait a moment and read the result. The nudge only goes out after the change has been committed, and it carries a counter per topic, so a screen that reconnects can tell it missed something and refetch everything.
That only applies to what everyone sees the same: the walls, the public schedule, the sponsors. The cached copy is dropped before the nudge goes out, so a screen that reacts to it can't read the old one, and the live queue's copy also expires by itself after two seconds, as a backstop. Anything personal, like your own place in the queue, is never cached.
The staff always get through. When registration opens, hundreds of participants hit the app at once while a handful of staff are trying to check people in. Requests are sorted into four lanes before they reach any code: queue operators, judges and accreditation staff first, then sponsors and judge collaboration, then the public screens, then everyone else. A few slots are reserved so participants can't use them all up, and when it's really crowded it's the lowest lane that gets a polite "try again".
One Postgres, on purpose. I kept a single Postgres primary as the deliberate bottleneck, because it keeps correctness simple, and the stateless API and worker are where the scaling happens. The connection budget fits in one line: an API pool of 24, a worker pool of 24 and 12 spare connections come to 60, under Postgres's default limit of 100.
How it's written
Tests run against a real Postgres and a real Valkey, not mocks, and there are 485 test files. The docs travel with the code, in the same commit as the change they describe.
I write it together with coding agents, Claude Code and Codex, and the repo is set up for them. AGENTS.md tells an agent what to read before it touches anything, and CLAUDE.md holds the rules above.
There's also a file of lessons, with an entry for every time I had to correct a coding agent. It has 31 so far. I started keeping it after Christian Palou and Xoel López explained the idea in their talk at Shipaton A Coruña.
How it went
The first two days were the plan, the scaffold, the shared contracts (the list of capabilities and the realtime events), the database schema, the API core and a test harness. By the end of the second day, identity, notifications, the judging queue, applications and logistics all existed in a first version. The web app started on July 5th. The mobile app followed on July 14th, with push notifications, Apple Wallet and the offline scanners.
August started slowly, with a quiet week in the middle, and ended with the biggest week of the whole project: scaling work for a 600-person event, TestFlight feedback on the schedule and, on August 26th, end-to-end tests for the event-day flows: check-in, scanners and the queue.
On September 15th I reset the database history and made the repo public. There's a clean production baseline now, and the migrations start again at 0001. The 117 old ones live in a folder that is never applied.
On October 2nd Samuel Corsan helped me set up a design system for the web app, connected to the look of HackUDC. The palette and the Rockwell and Inter fonts come from the HackUDC 2027 website, and they live in one theme file. He also standardised the icons and the filter menus. Samuel is a beast, and one of the people behind HackSpain, the 36-hour hackathon in Madrid that brought together 250 young builders this year.
October is where most of the work is, and there's a simple reason. Over the long weekend I went to an offsite with some of the GPUL volunteers, at Anceu Coliving, to plan next year and to push HackUDC 2027 forward (I wrote about it on LinkedIn). I had access to an extra Claude Max 20x subscription there, and people around to test the Android app on real phones, which I can't do on my own because I don't own an Android device. The busiest day of the whole project, October 11th with 170 commits, was in the middle of that weekend, and it's when Live Activities started.
On October 13th Devpost was retired, and the next day projects became a native part of hackOS: my own submission flow, with the editor, the gallery and the winners, on web and mobile at once. After that came the cleaning: an audit for dead code, no more N+1 queries, the Sonar cleanups, an accessibility audit and, in the last two days, untangling the API into shared helpers.
One more number: 1,023 of the commits are fixes and 635 are features. I'd rather not think about what that says.
HackUDC 2027 is on February 26th to 28th. That's the real test.