Skip to main content

Smart RFID Attendance System with FastAPI, ESP8266 & Railway

Introduction
#

Manual attendance marking in classrooms is time-consuming and error-prone. This project replaces it with a contactless RFID attendance system — students tap their card on a reader, and the backend instantly logs their check-in or check-out, pushes a live update to the dashboard via WebSocket, and stores everything in a database.

The system is built with:

  • Hardware: NodeMCU ESP8266 + RC522 RFID module
  • Backend: FastAPI (Python) served by Uvicorn
  • Database: SQLite via SQLAlchemy
  • Frontend: Jinja2 templates + vanilla JS with WebSocket live updates
  • Deployment: Docker container on Railway

GitHub Repository: github.com/iam-vivekus/rfid-attendance


System Architecture
#

RFID Card
    │
    ▼
ESP8266 (NodeMCU)
  RC522 Reader
    │  HTTPS POST /mark-attendance
    ▼
FastAPI Backend ─────────────── SQLite Database
  (Railway Cloud)                  attendance.db
    │
    ├── REST API  (/students, /attendance/*)
    ├── WebSocket (/ws/attendance)
    └── Dashboard (Jinja2 templates)
         │
         ▼
    Admin Browser
   (Live Dashboard)

When a card is scanned:

  1. The ESP8266 reads the UID and sends an HTTPS POST to the Railway-hosted API
  2. FastAPI looks up the student by UID, determines IN or OUT, and records the attendance
  3. A WebSocket broadcast instantly updates the dashboard in every open browser tab
  4. The ESP8266 flashes green (success) or red (error / unregistered card)

Hardware
#

Components
#

ComponentPurpose
NodeMCU ESP8266WiFi-enabled microcontroller
RC522 RFID ModuleReads 13.56 MHz MIFARE cards
Green LED + 220Ω resistorVisual feedback: success
Red LED + 220Ω resistorVisual feedback: error
Jumper wires + breadboardConnections
MIFARE RFID cards / fobsStudent ID cards

Wiring
#

RC522 Pin  →  NodeMCU Pin
─────────────────────────
SDA        →  D8
SCK        →  D5
MOSI       →  D7
MISO       →  D6
RST        →  D3
3.3V       →  3V3
GND        →  GND

Green LED (+) → D1 → 220Ω → GND
Red   LED (+) → D2 → 220Ω → GND

Backend — FastAPI
#

Project Structure
#

rfid_attendance/
├── main.py          # FastAPI app, routes, WebSocket
├── models.py        # SQLAlchemy ORM models
├── schemas.py       # Pydantic request/response schemas
├── crud.py          # Database operations
├── database.py      # Engine & session setup
├── requirements.txt
├── Dockerfile
├── templates/
│   ├── base.html
│   ├── dashboard.html
│   ├── students.html
│   └── attendance.html
└── static/
    ├── css/custom.css
    └── js/app.js

Database Models
#

The system has two tables — students and attendance_records:

# models.py
class Student(Base):
    __tablename__ = "students"
    id          = Column(Integer, primary_key=True)
    name        = Column(String, nullable=False)
    roll_number = Column(String, unique=True, nullable=False)
    class_name  = Column(String, nullable=False)
    rfid_uid    = Column(String, unique=True, nullable=True)

class AttendanceRecord(Base):
    __tablename__ = "attendance_records"
    id              = Column(Integer, primary_key=True)
    student_id      = Column(Integer, ForeignKey("students.id"))
    attendance_type = Column(String, nullable=False)  # "IN" or "OUT"
    timestamp       = Column(DateTime, default=datetime.datetime.now)

Key API Endpoints
#

MethodPathDescription
POST/mark-attendanceReceives RFID UID from ESP8266
GET/studentsList all students
POST/studentsRegister a new student
PUT/students/{id}Update student (assign RFID UID)
GET/attendance/todayToday’s attendance records
GET/attendance/exportDownload CSV report
WS/ws/attendanceLive WebSocket stream

Mark Attendance Logic
#

The /mark-attendance endpoint has a smart duplicate-scan guard — if the same card is scanned within 10 seconds it is silently rejected, preventing accidental double-entries from a card held too close to the reader.

@app.post("/mark-attendance")
async def mark_attendance(payload: schemas.RFIDRequest, db: Session = Depends(get_db)):
    student = crud.get_student_by_rfid(db, payload.rfid_uid)
    if not student:
        return JSONResponse(status_code=404, content={"success": False, "message": "Invalid RFID"})

    # Duplicate-scan guard: reject if last scan was within 10 seconds
    last = crud.get_last_attendance(db, student.id)
    if last:
        elapsed = (datetime.datetime.now() - last.timestamp).total_seconds()
        if elapsed < 10:
            return JSONResponse(status_code=429, content={"success": False, "message": "Duplicate scan"})

    attendance_type = crud.determine_attendance_type(db, student.id)
    record = crud.create_attendance(db, student.id, attendance_type)

    await manager.broadcast({
        "student_name": student.name,
        "attendance_type": attendance_type,
        "timestamp": record.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
    })

    return {"success": True, "student_name": student.name, "attendance_type": attendance_type}

IN / OUT Logic
#

The system alternates between IN and OUT automatically — the first scan of the day is always IN, the next is OUT, and so on.

def determine_attendance_type(db, student_id):
    last = get_last_attendance(db, student_id)
    if not last or last.attendance_type == "OUT":
        return "IN"
    return "OUT"

requirements.txt
#

fastapi>=0.111.0
uvicorn[standard]>=0.29.0
sqlalchemy>=2.0.0
pydantic>=2.7.0
jinja2>=3.1.0
aiofiles>=23.0.0
python-multipart>=0.0.9

Docker
#

The app is containerised so it runs identically everywhere — locally, on a server, or on Railway.

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD sh -c "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"

Two things to note:

  • python:3.11-slim keeps the image small (~150 MB)
  • ${PORT:-8000} reads Railway’s injected PORT variable at runtime; falls back to 8000 locally

Build and run locally:

# Build
docker build -t rfid-attendance .

# Run (mounts the database so data persists)
docker run -d \
  -p 8000:8000 \
  -v $(pwd)/attendance.db:/app/attendance.db \
  --name rfid-attendance \
  rfid-attendance

Visit http://localhost:8000 — the dashboard should be live.


Deploy to Railway
#

1. Push to GitHub
#

git init
git add .
git commit -m "initial commit"
git remote add origin https://github.com/YOUR_USERNAME/rfid-attendance.git
git push -u origin main

Add a .gitignore so venv/, __pycache__/, and attendance.db are never committed.

2. Create Railway Project
#

  1. Go to railway.comNew Project
  2. Select Deploy from GitHub repo
  3. Choose your rfid-attendance repo → Deploy Now

Railway automatically detects the Dockerfile and builds the image. Within a minute the service is live.

3. Generate a Public Domain
#

In your service settings → NetworkingGenerate Domain.

You get a URL like https://your-app.up.railway.app.

4. Persist the Database
#

Railway’s filesystem resets on every deploy. Add a Volume so attendance.db survives:

  1. Service → VolumesAdd Volume
  2. Mount path: /app

ESP8266 Arduino Code
#

Libraries Required
#

Install these via Arduino IDE → Tools → Manage Libraries:

  • MFRC522 by GithubCommunity
  • ArduinoJson by Benoit Blanchon (v6.x)
  • ESP8266 board package (includes ESP8266WiFi, ESP8266HTTPClient, WiFiClientSecure)

Full Sketch
#

#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>
#include <WiFiClientSecure.h>
#include <SPI.h>
#include <MFRC522.h>
#include <ArduinoJson.h>

// ── Configuration ─────────────────────────────────────────────
const char* WIFI_SSID     = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* SERVER_URL    = "https://your-app.up.railway.app/mark-attendance";

// ── Pins ──────────────────────────────────────────────────────
#define SS_PIN    D8
#define RST_PIN   D3
#define GREEN_LED D1
#define RED_LED   D2

MFRC522 rfid(SS_PIN, RST_PIN);
WiFiClientSecure wifiClient;

void setup() {
    Serial.begin(115200);
    SPI.begin();
    rfid.PCD_Init();

    pinMode(GREEN_LED, OUTPUT);
    pinMode(RED_LED, OUTPUT);

    wifiClient.setInsecure();  // accept Railway's SSL cert
    connectWiFi();

    Serial.println("Ready — scan a card.");
    flashLED(GREEN_LED, 3, 150);
}

void loop() {
    if (WiFi.status() != WL_CONNECTED) connectWiFi();

    if (!rfid.PICC_IsNewCardPresent() || !rfid.PICC_ReadCardSerial()) {
        delay(100);
        return;
    }

    String uid = readUID();
    Serial.println("Scanned UID: " + uid);
    sendAttendance(uid);

    rfid.PICC_HaltA();
    rfid.PCD_StopCrypto1();
    delay(1500);
}

String readUID() {
    String uid = "";
    for (byte i = 0; i < rfid.uid.size; i++) {
        if (rfid.uid.uidByte[i] < 0x10) uid += "0";
        uid += String(rfid.uid.uidByte[i], HEX);
    }
    uid.toUpperCase();
    return uid;
}

void connectWiFi() {
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
    int tries = 0;
    while (WiFi.status() != WL_CONNECTED && tries < 30) {
        digitalWrite(RED_LED, !digitalRead(RED_LED));
        delay(500);
        tries++;
    }
    digitalWrite(RED_LED, LOW);
    if (WiFi.status() == WL_CONNECTED)
        Serial.println("Connected! IP: " + WiFi.localIP().toString());
}

void sendAttendance(const String& uid) {
    HTTPClient http;
    http.begin(wifiClient, SERVER_URL);
    http.addHeader("Content-Type", "application/json");
    http.setTimeout(8000);

    StaticJsonDocument<64> req;
    req["rfid_uid"] = uid;
    String body;
    serializeJson(req, body);

    int code = http.POST(body);

    if (code == 200) {
        StaticJsonDocument<256> doc;
        deserializeJson(doc, http.getString());
        String atype = doc["attendance_type"] | "IN";
        flashLED(GREEN_LED, atype == "IN" ? 2 : 1, 250);
    } else if (code == 404) {
        flashLED(RED_LED, 3, 200);   // unregistered card
    } else if (code == 429) {
        flashLED(RED_LED, 1, 600);   // duplicate scan
    } else {
        flashLED(RED_LED, 2, 300);   // connection error
    }

    http.end();
}

void flashLED(int pin, int times, int delayMs) {
    for (int i = 0; i < times; i++) {
        digitalWrite(pin, HIGH);
        delay(delayMs);
        digitalWrite(pin, LOW);
        if (i < times - 1) delay(delayMs / 2);
    }
}

Important: Replace YOUR_WIFI_SSID, YOUR_WIFI_PASSWORD, and your-app.up.railway.app with your actual values before uploading.

Why WiFiClientSecure?
#

Railway enforces HTTPS. The original WiFiClient only supports plain HTTP — trying to connect it to an HTTPS URL fails at the SSL handshake with error code -5 (HTTPC_ERROR_NOT_CONNECTED). Switching to WiFiClientSecure with setInsecure() handles the TLS layer, and the error goes away immediately.

LED Feedback Guide
#

FlashesColourMeaning
2× greenGreenChecked IN successfully
1× greenGreenChecked OUT successfully
3× redRedCard not registered in system
1× slow redRedDuplicate scan (too fast)
2× redRedNetwork / server error

Registering Students
#

Before cards work, each student must be registered via the dashboard:

  1. Open https://your-app.up.railway.app/students-page
  2. Click Add Student → fill in name, class, roll number
  3. To link an RFID card, scan it once (you’ll see the UID in the Serial Monitor) and paste it into the RFID UID field for that student

Alternatively, use the REST API directly:

# Register a student
curl -X POST https://your-app.up.railway.app/students \
  -H "Content-Type: application/json" \
  -d '{"name": "John Doe", "roll_number": "101", "class_name": "10A", "rfid_uid": "A1B2C3D4"}'

Dashboard Features
#

The web dashboard provides:

  • Live scan feed — new scans appear instantly via WebSocket, no page refresh needed
  • Student management — add, edit, delete students and assign RFID UIDs
  • Attendance history — filter by date to view past records
  • CSV export — download a formatted attendance sheet for any date
  • Today’s summary — total students, present count, and recent scans on the home page

Troubleshooting
#

SymptomLikely causeFix
HTTP -5 on ESP8266Using WiFiClient with HTTPSSwitch to WiFiClientSecure + setInsecure()
HTTP 404Card UID not in databaseRegister the student via dashboard
HTTP 429Card scanned twice within 10 sWait and scan again
Dashboard 500 errorStarlette/Jinja2 version mismatchUpdate TemplateResponse to new API: (request, name, context)
Railway deploy failsPORT hardcoded in DockerfileUse ${PORT:-8000} in CMD
Data lost after redeployNo persistent volume on RailwayAdd a Railway Volume mounted at /app

Conclusion
#

This project shows how straightforward it is to combine inexpensive hardware (ESP8266 + RC522, under ₹500 total) with a modern Python backend to solve a real daily problem in schools and colleges. The whole stack — from card scan to live dashboard update — takes less than a second end-to-end.

Key takeaways:

  • FastAPI + WebSocket makes real-time features easy without any extra infrastructure
  • Docker ensures the same container runs locally and on Railway with zero configuration drift
  • WiFiClientSecure is mandatory for ESP8266 when the server uses HTTPS
  • Railway’s PORT environment variable must be used instead of a hardcoded port

The full source code is available at github.com/iam-vivekus/rfid-attendance.


Built for VHSE — a practical IoT project demonstrating real-world full-stack development from hardware to cloud deployment.

Vivek US
Author
Vivek US
A Tech Enthusiast