In production2026-04 – nowSolo full-stack developer

FECOT

A taekwondo federation's most sensitive data (who holds which belt) had no record of who approved it, or why.

  • fastapi
  • nextjs
  • postgresql
  • typescript
  • tailwind

The problem and constraints

FECOT is the regional body that certifies who holds which taekwondo belt across four Brazilian states. That single fact (rank) is the whole point of a federation existing: it decides who can compete in which category, who is allowed to teach, and whether the federation’s own certificate means anything to the national league it answers to. Before this rewrite, that data lived in a system where the story of how a change happened wasn’t part of the design.

The client, Luciano Bezerra da Cunha, brought a working but aging Ruby on Rails monolith and one clear priority: rank could not be a field like any other. Everything else (modernizing the stack, typing both ends of the API) mattered, but the actual constraint shaping this project was that a federation’s authority rests entirely on people trusting its records, and a record anyone with admin access can quietly edit is only an opinion with a timestamp.

The second constraint was who actually uses this system and how. Athletes aren’t self-registering from a laptop; they’re teenagers and adults enrolled in bulk by their teacher, frequently right there on the training mat, with no email address handy and no moment where handing them a “click here to set your password” link makes sense. Any auth design that assumed a self-service signup flow would have been solving a problem this federation doesn’t have, while ignoring the one it does.

The third constraint was operational honesty about scale: one developer building for a regional sports federation on a single VPS, with no staging environment, no dedicated security function, and no platform team or venture funding behind any of it. Every control (password handling, authorization, upload safety) had to be something one person could reason about completely, because there’s no second reviewer and no incident-response team behind it.

The fourth constraint came from the domain’s own hierarchy: teachers, academy managers, and administrators, and every one of them is also, unavoidably, an athlete with their own belt and their own training history. A teacher without at least a first-degree black belt fails the sport’s own rules for the role, before the software ever gets involved. Whatever model got built had to represent that a manager or teacher is a specialization of “athlete,” the same underlying entity carrying an extra role.

The last constraint was continuity: the Rails system already encoded years of real business rules: which grade requires which rank to teach, how graduation levels are named and ordered, who is allowed to register whom. A rewrite that “modernized” the stack while quietly losing one of those rules would have been a worse outcome than not rewriting at all. Every rule visible in the old code needed a deliberate decision about whether it carried over.

Architecture

A teacher, an academy manager, or an admin opens the Next.js frontend and does everything through one typed API client. There is no other path from the browser to the backend, and no component reaches across that boundary with an untyped fetch call. That single chokepoint is what makes it possible to say, with confidence, that a contract change on one side is caught on the other: if the backend’s response shape moves, the frontend’s types stop compiling instead of quietly rendering undefined.

The FastAPI backend is the only process with write access to PostgreSQL. Two layers of authorization sit in front of every write: role guards at the endpoint level (an endpoint simply refuses a role that shouldn’t be there), and contextual rules evaluated in the domain model itself: a teacher can edit their own students, an academy manager can edit anyone at their academy, an admin can edit anyone. Both layers run in the backend, never in the frontend: enforcement has to live where a user can’t route around it by editing what’s rendered in the browser; the constitution of this project says so explicitly, and the test suite is what actually holds it to that.

The graduation flow is where this architecture is most visible in practice. Nothing in the system (no endpoint, no admin shortcut, no internal script) writes to an athlete’s graduation field directly. The only way that value changes is through a GraduationRequest: a teacher, manager, or admin creates one, which snapshots the athlete’s current grade automatically; only an admin can approve or reject it; and the decision, the reviewer, and the timestamp are all recorded permanently, whether the request was approved or turned down. An athlete can have at most one pending request at a time, which closes the one loophole that would otherwise let someone route around the single-pending-request audit trail by just filing another request before the first is resolved.

Authentication sits underneath all of this as a deliberately simple layer: a stateless JWT, cached in the browser, revalidated against the database on every request rather than trusted at face value. That revalidation step is what makes deactivating an account actually mean something. The token itself doesn’t expire for up to 24 hours, but the backend checks whether the athlete behind it is still active before honoring anything the token claims. Avatar uploads follow the same instinct for verifying rather than trusting: every image is decoded, flattened onto a white background, resized, and re-encoded to WebP before it’s ever written to disk, so nothing a user uploads is served back byte-for-byte. A renamed executable or an image with an embedded payload never survives that round-trip intact.

The FECOT athlete list, showing the same table holding an administrator, athletes, academy managers and a teacher side by side, each with their belt rank, academy and status. All names and IDs shown are fictional seed data.
The 'one Athlete table with roles' decision, visible: a federation administrator, an academy manager and an ordinary athlete are the same kind of row, distinguished only by role and rank.

Architecture

FECOT: architecture A Next.js frontend and a FastAPI backend deploy as two separate processes behind nginx on one VPS. The frontend calls the backend only through a single typed API client. The backend is the only writer to PostgreSQL. A teacher or manager submits a graduation request; only the backend's approval endpoint can change an athlete's graduation field, and every request is either approved or rejected by an admin, with the reviewer and timestamp recorded. Next.js frontend one typed API client Teacher / Manager requests a graduation change FastAPI backend only writer to the database Admin approves or rejects, never edits directly PostgreSQL athletes, academies, graduation_requests graduation field: writable only through the approval endpoint

Decisions

DECISION 01/04 · Graduation changes

ChosenGraduation is never directly editable, not even by an admin. It only changes through a request-and-approval flow that snapshots the current grade and records who asked, who reviewed, and when

Discarded insteadLet admins edit the grade field directly, relying on an audit log to record the change after the fact

Grade defines who can compete in which category, who is allowed to teach, and the federation's own credibility as a record-keeper; a passive log only documents a bad change after it happens. The federation needed something that actively stopped one, with the paper trail as a side effect of that gate

Cost acceptedFixing a plain typo in someone's rank takes a request plus a separate approval, even for the person who could otherwise just fix it directly. That's deliberate: the one shortcut that matters most is exactly the one being removed

DECISION 02/04 · User model

ChosenOne Athlete table for everyone, with a role enum (athlete, teacher, academy manager, admin) and hierarchical permission helpers

Discarded insteadSeparate User and Athlete tables, linked by a foreign key

Every user in this domain, including federation staff, is also literally an athlete with a belt and a training history; splitting the concept in two would only create two records that need to stay in sync forever

Cost acceptedRole eligibility (a teacher needs at least 1st Dan) lives in Python validators rather than a database constraint. A direct SQL insert could still create an invalid teacher; the guarantee sits at the application boundary rather than the schema

DECISION 03/04 · Session strategy

ChosenStateless JWT (24h, HS256) cached in localStorage, revalidated against the database on every request

Discarded insteadhttpOnly cookies with server-side session state

Backend and frontend deploy as two separate processes, potentially on separate domains; a stateless token avoids building session infrastructure and cross-origin cookie configuration for a platform this size

Cost acceptedNo refresh tokens and no server-side revocation list. A stolen token stays valid until it expires. What actually keeps this safe is that deactivating an athlete's account cuts access immediately, because the backend reloads the athlete from the database on every request instead of trusting what the token claims

DECISION 04/04 · First password

ChosenAn athlete registered without a chosen password gets their own CPF (a Brazilian tax ID) as the initial password

Discarded insteadEmail an invite link so the athlete sets their own password

Athletes are registered in bulk by their teacher, often on the training mat, without being present to choose anything, and the platform has no transactional email infrastructure to send an invite through

Cost acceptedThe initial password is guessable by definition: a person's own CPF is often known to people around them. This is documented openly as an accepted weakness: the athlete can change it after first login, but the system does not technically force that change

Invariants

  • An athlete's graduation can only change through an approved GraduationRequest, never by direct update, not even by an admin

    Guaranteed bythe graduation field is absent from the athlete-update schema entirely; there is no code path that writes to it outside the approval endpoint

  • A teacher or academy manager role requires at least 1st Dan

    Guaranteed byvalidated on athlete creation and edit, and on teacher-academy association; a violation returns 422

  • Deactivating an athlete's account revokes access immediately, even with a still-valid JWT

    Guaranteed bythe backend reloads the athlete from the database on every authenticated request and rejects inactive accounts, rather than trusting the token's cached claims

  • An athlete never has more than one pending graduation request open at the same time

    Guaranteed bya uniqueness check at request-creation time returns 409 on a duplicate

  • An avatar is never served as the exact file a user uploaded

    Guaranteed byevery upload is decoded, flattened, resized and re-encoded to WebP through Pillow before being written to disk, discarding the original bytes

What broke

Symptom
Right after a visual rebrand, the frontend service on the hosting platform entered a restart loop: the log showed the process starting and reaching ready state repeatedly, in under a second each time, with no error printed anywhere
Root cause
Ruled out one candidate at a time: missing wget inside the image (present via busybox), starved host resources (memory and CPU both had headroom), and a port mismatch on the domain (correctly pointed at 3000). What was left was the Docker HEALTHCHECK instruction interacting badly with the hosting platform's own health probing. The two were very likely racing, with the platform killing the container as unhealthy before the HEALTHCHECK's own start-period had even elapsed
Fix
Removed the HEALTHCHECK instruction from the frontend Dockerfile entirely. The platform's own HTTP-based routing already confirms the service is reachable; a second, redundant health signal was doing more harm than good
Prevention
The commit message itself is written as a diagnostic log: what was ruled out, in what order, and why. So the next container-orchestration surprise on this platform starts from that list instead of from zero

Results

105backend tests passingpytest run against the real suite, 2026-08-29: 105 passed in 69.51s
158source files (backend + frontend)count of .py/.ts/.tsx files outside node_modules/venv, 2026-08-29
20graduation levels modeled, 10th Gub to 10th DanGRADUATIONS constant, backend/app/core/graduations.py
4athlete roles in one unified modelAthlete.role enum, backend/app/models/athlete.py
10commitsgit log --oneline count on main, 2026-08-29
since April 2026in continuous productiondate of the first commit in the rewrite

Full stack

Backend

  • FastAPI (Python 3.12)
  • SQLAlchemy 2 + Alembic
  • JWT (HS256)
  • bcrypt

Frontend

  • Next.js 16 (App Router)
  • React 19
  • TypeScript
  • Tailwind + shadcn/ui

Data

  • PostgreSQL 16

Infra

  • systemd + nginx, single VPS

Interested in a project like this? Get in touch.

Get in touch