Building a Production Gym Management System with Ruby on Rails
How a gym replaced paper registers with a Rails app featuring biometric attendance and WhatsApp automation.

Hey there!
I'm Lakshay, and I built Spine Fitness โ a full-stack gym management system using Ruby on Rails that's currently deployed in production and used daily by a real gym in Dwarka, New Delhi.
The gym was managing 200+ members using physical notebooks. Membership renewals were missed, payments were scattered, and attendance tracking was a mess. I built a web platform to replace all of it.
๐ Live at: spine-fitness.com ๐ป Source Code: GitHub Repository
The Problem: Death by Notebooks
Before the software, the gym staff dealt with:
Member registrations handwritten in registers
Membership renewals forgotten โ lost revenue
Attendance tracked inconsistently (or not at all)
Payment history scattered across different books
Inventory (equipment, supplements) completely untracked
Member communication required manual phone calls
Result? Human errors, missed renewals, zero visibility, and hours of admin work daily.
The Solution: Spine Fitness
A centralized web platform where gym administrators manage everything digitally โ from member onboarding to automated WhatsApp expiry reminders.
Tech Stack at a Glance
| Layer | Technology |
|---|---|
| Backend | Ruby on Rails 7.1, Ruby 3.1.4 |
| Database | MySQL (CleverCloud) |
| Frontend | HTML, CSS, JavaScript (Hotwire/Turbo + Stimulus) |
| Hosting | Render |
| Biometric Bridge | Python 3 (pyzk + requests) โ runs on gym laptop |
| WhatsApp API | Interakt |
| PDF Reports | Prawn |
| Scheduling | cron-job.com |
| Hardware | ZK Fingerprint biometric device |
โ ๏ธ Update (2026): This system originally used Interakt for WhatsApp messaging. Due to reliability issues, I later replaced it with a direct integration using Meta Cloud API.
๐ Full migration breakdown: How I Replaced Interakt with Meta Cloud API
Architecture Overview
Gym Admin (Browser)
โ
โผ
Ruby on Rails Application (Render)
โ
โโโ MySQL Database (CleverCloud)
โ
โโโ WhatsApp Messaging (Interakt API)
โ
โโโ Scheduled Jobs (cron-job.com)
โ
โโโ REST API: POST /api/biometric_attendances
โฒ
โ HTTP POST (every 20 seconds)
โ
โโโโโโโโโโโดโโโโโโโโโโโ
โ Python Bridge โ
โ (bridge.py) โ
โ Runs on gym laptop โ
โโโโโโโโโโโฌโโโโโโโโโโโ
โ pyzk SDK
โผ
โโโโโโโโโโโโโโโโโโโโโโโ
โ ZK Fingerprint โ
โ Biometric Device โ
โ (192.168.1.201) โ
โโโโโโโโโโโโโโโโโโโโโโโ
The system is multi-language โ Ruby on Rails handles the web app and business logic, while a Python script running on the gym's local laptop bridges the physical biometric device to the cloud-hosted Rails API.
Python Biometric Bridge (Runs on Gym Laptop)
The biometric fingerprint device sits on the gym's local network. It doesn't natively talk to cloud APIs. So I wrote a Python bridge script that runs continuously on the gym's Windows laptop:
# biometric_bridge/bridge.py
from zk import ZK
import requests
import time
from config import *
def send_to_rails(payload):
try:
response = requests.post(
RAILS_API_URL,
json=payload,
timeout=5
)
print(f"Sent: {payload} | Response: {response.status_code}")
except Exception as e:
print("Rails API error:", e)
def main():
zk = ZK(
DEVICE_IP,
port=DEVICE_PORT,
timeout=DEVICE_TIMEOUT,
password=0,
force_udp=False,
ommit_ping=False
)
print("Connecting to biometric device...")
try:
conn = zk.connect()
conn.disable_device()
last_sent = set()
while True:
attendances = conn.get_attendance()
for att in attendances:
key = f"{att.user_id}-{att.timestamp}"
if key in last_sent:
continue
payload = {
"compcode": COMP_CODE,
"user_id": att.user_id,
"timestamp": att.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
"device_sn": conn.serial_number
}
send_to_rails(payload)
last_sent.add(key)
time.sleep(POLL_INTERVAL_SECONDS)
except Exception as e:
print("Device connection error:", e)
finally:
try:
conn.enable_device()
conn.disconnect()
except:
pass
if __name__ == "__main__":
main()
How it works:
Connects to the ZK biometric device on the local network (
192.168.1.201:4370) using thepyzklibraryPolls attendance logs every 20 seconds
Deduplicates locally using an in-memory set (
last_sent)Sends each new punch as an HTTP POST to
https://spine-fitness.com/api/biometric_attendancesRuns as a background process via a
.batfile on Windows startup
:: biometric_bridge/start_biometric.bat
cd C:\biometric_bridge
python bridge.py
This makes the system truly full-stack โ from hardware on the gym floor, through a Python bridge on a local laptop, to a Rails API in the cloud.
๐๏ธ Database Design: Master-Transaction Pattern
I structured the database into Master tables (static data) and Transaction tables (operational data):
Master Tables:
mst_members_listsโ Member profilesmst_membership_plansโ Plan types (monthly, quarterly, annual)mst_staff_lists/mst_trainer_listsโ Staff recordsmst_stock_listsโ Equipment & inventory
Transaction Tables:
trn_member_subscriptionsโ Active subscriptions with datestrn_member_attendancesโ Biometric punch recordstrn_paymentsโ Payment records linked to subscriptionstrn_whatsapp_logsโ Message delivery trackingtrn_audit_trialsโ Full audit log of all actions
This separation keeps queries efficient and business logic clean.
Key Features
1. Member Management
Members get auto-generated codes using a custom GlobalCodeGenerator:
@Lastcode = generate_code(
table: MstMembersList,
column: "mmbr_code",
prefix: "M",
compcode: session[:loggedUserCompCode]
)
Each member links to subscriptions, payments, and biometric mappings.
2. Subscription & Payment Tracking
The system supports partial payments โ a real-world requirement:
def payment_status(subscription)
paid = total_paid(subscription)
final = subscription.ms_final_amount.to_f
return "PAID" if paid >= final
return "PARTIAL" if paid > 0
"DUE"
end
3. Biometric Attendance
A fingerprint device sends punch data to a REST API endpoint. The system maps biometric IDs to members, deduplicates punches, and validates subscriptions in real-time.
๐ I wrote a full deep-dive on this โ [Link to your biometric blog post]
4. WhatsApp Automation
Members automatically receive WhatsApp messages for expiry reminders through a 3-stage pipeline:
Cron Trigger โ Background Job โ Interakt API โ Webhook Delivery Tracking
# Daily cron hits this endpoint
def send_expiry_whatsapp
return head :unauthorized unless params[:token] == ENV['CRON_SECRET']
MembershipExpiryWhatsappJob.perform_later(:expiring)
MembershipExpiryWhatsappJob.perform_later(:expired)
end
Messages are tracked through their full lifecycle:
QUEUED โ SENT โ DELIVERED โ READ (or FAILED)
5. Admin Dashboard
Single-screen overview: Active/Expiring/Expired counts, today's collections, due payments, inventory status โ all preloaded in bulk to avoid N+1 queries:
def preload_members
member_ids = @latest_subs.map(&:ms_member_id).uniq
@members_map = MstMembersList
.where(mmbr_compcode: @compcode, id: member_ids)
.each_with_object({}) { |m, h| h[m.id.to_s] = m }
end
Challenges & Solutions
| Challenge | Solution |
|---|---|
| Biometric device sends raw punch data | Built REST API with mapping, deduplication, and subscription validation |
| WhatsApp duplicate messages | Check trn_whatsapp_logs before sending |
| Dashboard slow with 200+ members | Bulk preloading with hash maps |
| Tracking message delivery | Interakt webhook controller for real-time status |
| Multi-user access control | Module-based permission system |
Key Learnings
Real business requirements are messy. Partial payments, expired members punching in, duplicate scans โ you don't think about these in tutorials.
Background jobs are essential. WhatsApp notifications can't block the request cycle.
Audit logging is non-negotiable. When real money is involved, every action needs a trail.
N+1 queries become real when you have 200+ members on a dashboard.
External APIs need defensive coding. Always handle failures, log responses, and plan for timeouts.
What's Next
SMS fallback for members not on WhatsApp
Member-facing mobile view for self-service
Revenue analytics and trend reports
Online payment collection
Final Thoughts
Building Spine Fitness taught me that production software for a real business is fundamentally different from side projects. The requirements come from real workflows, bugs have real consequences, and watching a gym owner ditch their notebooks for your app is incredibly satisfying.
If you're looking to build something meaningful with Rails, start by finding a real problem around you. The best portfolio projects are the ones that actually get used.
๐ Live: spine-fitness.com ๐ป Source: github.com/imlakshay08/spine-fitness-gym-management-system
If you found this helpful, drop a โค๏ธ and follow me for more real-world Rails content!



