The problem

A gym chain is not one business. It is a head office, several branches, staff who belong to exactly one of them, trainers who float between two, and members who bought a plan at one location and expect it to work at another.

Model that badly and every query in the system needs a WHERE branch_id = ... that someone will eventually forget. Model it well and the isolation is structural - impossible to forget because it isn’t a filter at all.

FitsSort is a SaaS platform for that shape of business, serving a web dashboard, a React Native member app and biometric hardware from one Django API.

Isolation in two dimensions

Between tenants: separate PostgreSQL schemas. Each customer gets their own schema in a shared cluster. The request’s host - including custom domains - resolves to a tenant, which sets the search path for the life of the request. A missing filter can’t leak another customer’s data because the other customer’s tables are not on the path.

Inside a tenant: branch scoping. Within a schema, branch access is enforced at the queryset layer rather than the view layer. Views ask for “the members I can see”; the scoping layer decides what that means for the caller’s role and branch assignment.

Authorization is then a three-way resolution - the subscription plan says which features exist, the role says which actions are permitted, and the resource says which records are in reach. Any one of those denying is a deny. I wrote about the trade-offs in Schema-per-Tenant Django.

Talking to the turnstile

Attendance is the feature customers actually judge the product on, and it runs on ZKTeco biometric devices sitting on a gym’s local network - not on the internet, not reachable from a cloud API.

The bridge is a small LAN agent that speaks the device protocol locally and syncs upstream. It buffers when the connection drops, deduplicates on re-sync, and treats the device clock as untrusted. Attendance records carry both the device timestamp and the server receive time, because those disagree more often than you would like, and payroll depends on knowing which one you used.

That agent is its own project - ZKTeco LAN Agent - because the cheaper terminals customers already own cannot reach a cloud API at all, and telling them to buy new hardware was not an option.

That attendance data feeds a configurable engine covering late arrivals, leave, overtime and payroll integration - rules that differ per branch, per role, and occasionally per employee.

Holding 10k+ DAU

Scaling this platform was mostly about refusing to add hardware first:

  • PgBouncer in transaction mode. Django’s connection-per-request model plus multiple workers exhausts Postgres connections long before it exhausts CPU.
  • Redis for session state, hot reads and the dashboard aggregates that were previously recomputed per page load.
  • Query work before capacity. The usual suspects - N+1s across membership and attendance, missing composite indexes on the branch/date pairs every report filters by.
  • WebSocket over polling. Live check-in feeds and notifications push; they don’t ask every five seconds.

Deployment

Traefik terminates TLS and routes per-tenant custom domains; Nginx serves static assets; Cloudflare fronts the whole thing. Releases are rolling container swaps with health gating, so a bad build fails its health check and never receives traffic. Rollback is redeploying the previous tag - deliberately boring.

What it demonstrates

This is the project where the full-stack label is literal: schema design, API, permission model, React dashboard, React Native app, hardware integration and the deployment pipeline underneath. And the constraint that shaped every one of those layers was the same - a mistake in one tenant must never become a mistake in another.