Skip to main content

Command Palette

Search for a command to run...

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.

Updated
7 min read
Building a Production Gym Management System with Ruby on Rails
L
Ruby on Rails engineer with 2 years of experience building production systems — including government management platforms (Ministry of Tourism, India), HR and manufacturing workflow systems, and Spine Fitness, a gym management SaaS I independently built and deployed. Reached the global final round in Empire Flippers' engineering assessment process. Writing about backend architecture, production debugging, integrations, and real-world Rails engineering.

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:

  1. Connects to the ZK biometric device on the local network (192.168.1.201:4370) using the pyzk library

  2. Polls attendance logs every 20 seconds

  3. Deduplicates locally using an in-memory set (last_sent)

  4. Sends each new punch as an HTTP POST to https://spine-fitness.com/api/biometric_attendances

  5. Runs as a background process via a .bat file 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 profiles

  • mst_membership_plans — Plan types (monthly, quarterly, annual)

  • mst_staff_lists / mst_trainer_lists — Staff records

  • mst_stock_lists — Equipment & inventory

Transaction Tables:

  • trn_member_subscriptions — Active subscriptions with dates

  • trn_member_attendances — Biometric punch records

  • trn_payments — Payment records linked to subscriptions

  • trn_whatsapp_logs — Message delivery tracking

  • trn_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

  1. Real business requirements are messy. Partial payments, expired members punching in, duplicate scans — you don't think about these in tutorials.

  2. Background jobs are essential. WhatsApp notifications can't block the request cycle.

  3. Audit logging is non-negotiable. When real money is involved, every action needs a trail.

  4. N+1 queries become real when you have 200+ members on a dashboard.

  5. 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!