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.

Architecture
Decisions
DECISION 01/04 · Graduation changes
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
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
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
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 by
the graduation field is absent from the athlete-update schema entirely; there is no code path that writes to it outside the approval endpointA teacher or academy manager role requires at least 1st Dan
Guaranteed by
validated on athlete creation and edit, and on teacher-academy association; a violation returns 422Deactivating an athlete's account revokes access immediately, even with a still-valid JWT
Guaranteed by
the backend reloads the athlete from the database on every authenticated request and rejects inactive accounts, rather than trusting the token's cached claimsAn athlete never has more than one pending graduation request open at the same time
Guaranteed by
a uniqueness check at request-creation time returns 409 on a duplicateAn avatar is never served as the exact file a user uploaded
Guaranteed by
every 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
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