← Projects

hackOS

The platform HackUDC runs on: applications, check-in, offline scanners, the judging queue and the venue screens on one database, replacing four tools.

  • TypeScript
  • Fastify
  • Next.js
  • Expo
  • Postgres
  • React Native
  • Valkey
  • BullMQ
  • Tailwind CSS
  • Docker
Year
2026
Source
github.com/danicallero/hackOS
Live
os.hackudc.com

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

The check-in app from HackUDC 2026: a phone camera view with a yellow frame around the QR code being scanned.
The check-in app from HackUDC 2026, which hackOS replaces

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.

Diagram. On the left, the tools the 2026 edition ran on: Google Sheets, Directus, FastTrack, a staff app, Grafana and Devpost. On the right, one box with what replaced each of them: schedule and run of show, public pages screens and sponsors, queue rooms and judging, scanners that work offline, live statistics, and projects built in.
What each of the tools became

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 roles screen: a list of roles on the left and, for the selected role, every capability with Deny, Inherit and Allow buttons.
Roles in hackOS: for every capability, allow, deny or inherit

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.

Diagram of the system in three layers. On top, the web app and the mobile app. In the middle, the API and the worker. At the bottom, inside a private network, Postgres as the only source of truth, Valkey for realtime nudges and the job clock, and MinIO for files.
The whole system. The dark box is the only one that has to survive

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.

State diagram of the judging queue: a team goes from Waiting to Called to In the room to Presenting to Completed. It can be sent back from Called to Waiting, and taken out of the room back to Called. Disqualification is a person's decision.
A team's path through judging. A person decides no-shows, never a timer

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.

Flow diagram of a scan: it is made, saved on the device with its own id, then sent to the server with the id as a key. The server either acknowledges it, does not answer so the device retries with the same id, or rejects it and the operator sees the reason.
The journey of a scan

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.

Five steps from a button to a screen. Staff press call next. One database transaction locks the rows and writes the change, the audit entry and the emails. It commits. Valkey drops the cached copy and nudges every API instance. The first request reads Postgres and everyone else reads the new copy.
From a button to a screen

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

Bar chart of commits per week from July to November. Peaks of about 310 in mid July, 390 in late August and 350 and 310 in mid October, with a quiet stretch in August.
Commits per week, July 3rd to November 7th

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.