9 min read
Building a service to manage college events - (mostly) self-hosted

TL;DR

I got tired of tech festivals using dreaded BigForms™ for registrations, so I built a custom system. What started as “how hard could it be?” evolved into 3AM Docker sessions soundtracked by ABBA’s greatest hits. The app handles teams, payments, and emails without breaking a sweat—though my sleep schedule wasn’t so lucky. Self-hosting saved me exactly one KFC bucket’s worth of money, and somehow I went from hating database constraints to writing custom PostgreSQL triggers in my sleep. Next mission: becoming the very Platform™ I swore to destroy, just done right this time.

So, some backstory, as you know every year all colleges have this techfest season, where all clubs, department organizations host events, could be individually, could be together, but most colleges across the country have a culture of hosting one.

But then, what’s my problem? All these events seem to definitely have creative webpages, kudos to the designers and web devs, but that redirect link to some BigFormsTM or the usual un-platformsTM always gave me an ick.

Like a tech event at least deserves a custom form, and how difficult could it be to build a basic CRUD application? While it makes sense for BigCorporationsTM to host their events on these PlatformsTM, college students could definitely do better!

So I went ahead and did it, tried to build one!

Oh Boy

Oh boy, was I so wrong

Nerdy part starts ahead, go slow!

In the next few minutes, I will walk you through how a simple CRUD app turned into a full-fledged service, and to inflict more pain on myself, how it was self-hosted and the reasons for it.

So generally registering for an event would look like this:

  1. You visit the event page
  2. You are redirected to a login page (passwordless login, everyone hates passwords, I know you do too 🫵)
  3. You click on register (if solo)
  4. You click on create team, add members, wait for members to approve it, then register (sigh)
  5. You pay the fees, if you’re not from the same campus
  6. You are redirected to confirmation page

Yeah, that’s quite a bit. Now for actually building it, let’s start with the tech stack:

  • NextJS
  • Supabase
  • ZeptoMail (for emails)
  • Cashfree (for payments)
  • A VPS and S3 bucket from DigitalOcean
  • Docker, Grafana, Github Actions, Nginx, and a few other tools

This stack is what I feel like is the “quick build stack.” What I mean by it is that I can quickly build an MVP with this stack, then iterate on it, and it’s also quite easy to maintain (or so I thought).

Manually setting up Postgres is quite a pain, and I am not a fan of it, so I went with Supabase, which is a managed PostgreSQL service, and also has authentication, so that’s a plus!

NextJS, because of the API routes, hands down. Hosting another service for the API is the last thing I would like to do while working on a deadline.

Though, what still baffles me is NextJS allowing direct database queries from client components. Why would they even enable such an anti-pattern? NextJS, explain yourself! Did I almost end up leaking the keys? That’s a story for another day.

Let’s look at the database schema (the fun part, I promise):

The SQL Schema, this will help us understand more about the app, pretty instantaneously.
-- User Profiles Table: The who's who of our system
create table profiles (
  id uuid not null,
  created_at timestamp with time zone null default timezone('utc'::text, now()),
  updated_at timestamp with time zone null default timezone('utc'::text, now()),
  full_name text null,
  email text null,
  phone text null,
  college_name text null,
  prn text null,                  -- Student ID number
  branch text null,               -- Engineering discipline
  class text null,                -- Year of study
  gender text null,
  is_pccoe_student boolean null,  -- For distinguishing home vs. guest participants
  constraint profiles_pkey primary key (id),
  constraint profiles_id_fkey foreign key (id) references auth.users (id) on delete cascade
) tablespace pg_default;

This data makes it easy for event organizers to filter participants - “Show me all the Computer Science students” or “Who’s coming from other colleges?” - super useful for planning.

Next up, the events table - where all the action happens:

-- Events Table: The main attraction
create table public.events (
  id uuid not null default gen_random_uuid(),
  created_at timestamp with time zone null default timezone('utc'::text, now()),
  updated_at timestamp with time zone null default timezone('utc'::text, now()),
  name text not null,
  description text null,
  event_type public.event_type not null,  -- Solo or team-based variations
  min_team_size integer not null default 1,
  max_team_size integer not null default 1,
  registration_start timestamp with time zone null,
  registration_end timestamp with time zone null,  -- Often extended last-minute
  event_start timestamp with time zone null,
  event_end timestamp with time zone null,
  max_registrations integer null,         -- Capacity limits
  is_active boolean null default true,
  img_url text null,                      -- Event poster/banner
  whatsapp_url text null,                 -- Where most communication happens anyway
  constraint events_pkey primary key (id),
  constraint valid_event_period check ((event_start < event_end)),
  constraint valid_event_team_size check (
    (
      (
        (event_type = 'solo'::event_type)
        and (min_team_size = 1)
        and (max_team_size = 1)
      )
      or (
        (
          event_type = any (
            array[
              'fixed_team'::event_type,
              'variable_team'::event_type
            ]
          )
        )
        and (min_team_size > 0)
        and (max_team_size >= min_team_size)
      )
    )
  ),
  constraint valid_registration_period check ((registration_start < registration_end)),
  constraint valid_team_size check ((min_team_size <= max_team_size))
) tablespace pg_default;

Events come in three types: solo, fixed team size, or variable team size. The constraints make sure nobody tries to register after the deadline (though we all know someone who tries).

Moving on to registrations:

-- Registrations Table: Where participants actually sign up
create table public.registrations (
  id uuid not null default gen_random_uuid(),
  created_at timestamp with time zone null default timezone('utc'::text, now()),
  updated_at timestamp with time zone null default timezone('utc'::text, now()),
  event_id uuid not null,
  team_id uuid null,               -- For team events
  individual_id uuid null,         -- For solo events
  registration_status public.registration_status null default 'pending'::registration_status,
  payment_status text null default 'pending'::text,  -- Payment tracking
  constraint registrations_pkey primary key (id),
  constraint registrations_event_id_fkey foreign key (event_id) references events (id) on delete cascade,
  constraint registrations_individual_id_fkey foreign key (individual_id) references profiles (id),
  constraint registrations_team_id_fkey foreign key (team_id) references teams (id) on delete cascade,
  constraint check_payment_status check (
    (
      payment_status = any (
        array[
          'success'::text,
          'failed'::text,
          'pending'::text,
          'not_required'::text,
          'pccoe_coupon'::text     -- Special code for host college students
        ]
      )
    )
  ),
  constraint registration_type check (
    (
      (
        (team_id is null)
        and (individual_id is not null)
      )
      or (
        (team_id is not null)
        and (individual_id is null)
      )
    )
  )
) tablespace pg_default;

This keeps track of who’s registered for what, ensuring you can’t accidentally register for the same events twice.

For team events, we needed a way to track teams:

-- Teams Table: Managing group participation
create table public.teams (
  id uuid not null default gen_random_uuid(),
  created_at timestamp with time zone null default timezone('utc'::text, now()),
  updated_at timestamp with time zone null default timezone('utc'::text, now()),
  event_id uuid not null,
  team_name text not null,          -- Usually something creative or inside joke
  leader_id uuid not null,          -- The team creator
  is_complete boolean null default false,  -- Has all required members
  registration_locked boolean null default false,  -- Prevents further team changes
  constraint teams_pkey primary key (id),
  constraint teams_event_id_team_name_key unique (event_id, team_name),
  constraint teams_event_id_fkey foreign key (event_id) references events (id) on delete cascade,
  constraint teams_leader_id_fkey foreign key (leader_id) references profiles (id)
) tablespace pg_default;

Each team has a unique name within its event and a designated leader who creates and manages the team.

The team member invitation system was a challenge, not for me, but the users (should have figured a better way to do it):

-- Team Members Table: Handling team composition and invitations
create table public.team_members (
  id uuid not null default gen_random_uuid(),
  created_at timestamp with time zone null default timezone('utc'::text, now()),
  updated_at timestamp with time zone null default timezone('utc'::text, now()),
  team_id uuid not null,
  member_id uuid null,              -- Only filled when invitation accepted
  invitation_status public.invitation_status null default 'pending'::invitation_status,
  invited_by uuid not null,         -- Who sent the invitation
  member_email text null,           -- For inviting users who haven't registered yet
  constraint team_members_pkey primary key (id),
  constraint team_members_team_id_member_id_key unique (team_id, member_id),
  constraint team_members_invited_by_fkey foreign key (invited_by) references profiles (id),
  constraint team_members_member_id_fkey foreign key (member_id) references profiles (id),
  constraint team_members_team_id_fkey foreign key (team_id) references teams (id) on delete cascade
) tablespace pg_default;

This manages the invite-accept flow for teams.

Finally, the payments system:

-- Payments Table: Tracking transactions and fees
create table public.payments (
  id uuid not null default extensions.uuid_generate_v4(),
  order_id character varying(255) not null,
  user_id uuid null,
  team_id uuid null,
  event_id uuid null,
  amount numeric(10, 2) not null,
  currency character varying(3) null default 'INR'::character varying,
  status character varying(20) null default 'pending'::character varying,
  payment_method character varying(50) null,
  transaction_id character varying(255) null,  -- For payment reconciliation
  bank_reference character varying(255) null,
  created_at timestamp with time zone null default now(),
  updated_at timestamp with time zone null default now(),
  cf_order_id character varying(255) null,     -- Cashfree reference
  cf_payment_id character varying(255) null,   -- Cashfree payment ID
  payment_time timestamp with time zone null,
  registration_id uuid null,
  metadata jsonb null default '{}'::jsonb,     -- For additional payment details
  constraint payments_pkey primary key (id),
  constraint payments_order_id_key unique (order_id),
  constraint payments_cf_order_id_key unique (cf_order_id),
  constraint payments_event_id_fkey foreign key (event_id) references events (id),
  constraint payments_registration_id_fkey foreign key (registration_id) references registrations (id),
  constraint payments_team_id_fkey foreign key (team_id) references teams (id),
  constraint payments_user_id_fkey foreign key (user_id) references auth.users (id)
) tablespace pg_default;

This handles all the payment processing, with comprehensive tracking for troubleshooting those inevitable “but I already paid!” moments.

Beyond these core tables, I added a few extras:

  • Access controls for event organizers
  • Indexes, Triggers, Materialized View Tables, Cron Jobs, and more (yes, I skipped them for brevity)

Not perfect, but it works reliably and fast, does what we need it to. Sometimes that’s the best kind of system - one that just works and works fast!

Oh Boy

We talking 100 ms fast on API response times

The Backend & Infrastructure

Now, let’s talk about the backend and infrastructure. I chose to (partially) self-host the service for a few reasons:

  1. We almost ran out of Vercel’s Free Tier in two days, their pro plan cost $25, and imma be honest, I got the VPS, S3 bucket and a KFC bucket, all within the same price range!
  2. It’s a learning experience, and I wanted to see how far I could push myself, and yes, it ends at 5am trying to run Supabase on a VPS, after finishing up all of ABBA’s albums.
  3. I wanted to have full control over the data, and I didn’t want to be at the mercy of a third-party service. Truly hate that feeling.

So, the backend was the NextJS API (it doesn’t sound enticing, but trust me on this one), I cannot emphasize enough how much I enjoy writing these, and how easy they make the development cycle. For smaller web apps like these and small/medium sized SaaS apps, this is the way to go!

let query = supabase.from("events").select("*");
    
// Apply filters based on status parameter
if (status === 'active') {
  // Active events: is_active is true AND end_date is in the future
  const now = new Date().toISOString();
  query = query
    .eq('is_active', true)
    .gt('event_end', now);
} 

else if (status === 'closed') {
  // Closed events: either is_active is false OR end_date is in the past
  const now = new Date().toISOString();
  query = query.or(`is_active.eq.false,event_end.lt.${now}`);
}
// Execute the query
const { data: events, error } = await query;

It was a breeze to write, alright, enough riding.

Dockerizing and Deploying NextJS was easy, setting up the reverse proxy with Nginx was a breeze, the only thing that took a bit of time was setting up the CD pipeline with Github Actions, but once it was done, it was a breeze.

Grafana dashboard view

The CPU Basics

Grafana dashboard details

Client Connections

Grafana dashboard analytics

Network Traffic

Grafana statistics

Memory Stack

Grafana monitoring

Query Stats

Let’s talk about the memory leak issue with NextJS, it’s annoying, though the applicaiton has been stable after I migrated it from node:18-alpine to node:23-alpine

Grafana monitoring

Memory issues in Next.js

The UI

Screenshot 1

Intuitive dashboard interface providing an overview

Screenshot 2

Event Listing with filtering and sorting

Screenshot 3

Collaborative team management interface with role-based permissions

Screenshot 4

Streamlined team invitation system

Screenshot 5

Registration and participation details

Screenshot 6

The footer (looked cool)

The UI personally looks clean and minimal, works well on mobile, and is accessible. Those random binge watching of UI/UX videos on YouTube finally paid off.

I did have to make a few compromises, such as no dark mode and a limited onboarding experience, but hey, it’s a college event management system, not an overvalued, over-engineered Todo app. This thing works.

I faced a constant error that still persists. I dug through the Next.js docs, StackOverflow, GitHub issues for Next.js, and even the Next.js Discord, but had no luck. The error was:

Oh Boy

React Fragment Issue

The organizers’ experience

The organizers’ experience was quite smooth. As much as I want to show the dashboard and all its cool features, I can’t—it contains sensitive data, and I doubt anyone would be comfortable with me sharing it.

Oh Boy

it worked out well

Conclusion

So there you have it—my over-engineered solution to the problem of “why do tech events use boring dreaded BigForms™?” What started as a simple CRUD app somehow morphed into me setting up Docker containers at 3 AM while listening to ABBA’s “The Singles (The First Fifty Years)” on repeat.

Was it worth it? If you measure success in hours of sleep lost, then absolutely! But seriously, despite the Next.js fragmentation errors that still haunt my dreams, we managed to process thousands of registrations without a hitch. Nobody had to fill out those dreaded BigForms™, and I got to learn way more about PostgreSQL than I ever wanted to know.

The best part? When other clubs saw this system, they wanted in too. Suddenly I went from “that guy who complains about dreaded BigForms™” to “that guy who built that registration thingy.” That’s career growth, I tell you!

If there’s one lesson here, it’s that self-hosting is like making your own pizza—messier than ordering delivery, takes way longer than expected, but strangely satisfying when you’re done. Plus, now I get to casually drop phrases like “my Grafana dashboard” into conversations at parties.

Next year’s goal: open-source this whole thing as a “batteries included” solution that anyone can self-host. Or maybe—hear me out—become the very Platform™ I complained about, just done right this time! “KewonForms™: Because Your Events Deserve Better Than A Spreadsheet”

Wish me luck!