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:
- The ESP8266 reads the UID and sends an HTTPS POST to the Railway-hosted API
- FastAPI looks up the student by UID, determines IN or OUT, and records the attendance
- A WebSocket broadcast instantly updates the dashboard in every open browser tab
- The ESP8266 flashes green (success) or red (error / unregistered card)
Hardware#
Components#
| Component | Purpose |
|---|---|
| NodeMCU ESP8266 | WiFi-enabled microcontroller |
| RC522 RFID Module | Reads 13.56 MHz MIFARE cards |
| Green LED + 220Ω resistor | Visual feedback: success |
| Red LED + 220Ω resistor | Visual feedback: error |
| Jumper wires + breadboard | Connections |
| MIFARE RFID cards / fobs | Student 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#
| Method | Path | Description |
|---|---|---|
POST | /mark-attendance | Receives RFID UID from ESP8266 |
GET | /students | List all students |
POST | /students | Register a new student |
PUT | /students/{id} | Update student (assign RFID UID) |
GET | /attendance/today | Today’s attendance records |
GET | /attendance/export | Download CSV report |
WS | /ws/attendance | Live 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-slimkeeps the image small (~150 MB)${PORT:-8000}reads Railway’s injectedPORTvariable at runtime; falls back to8000locally
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#
- Go to railway.com → New Project
- Select Deploy from GitHub repo
- Choose your
rfid-attendancerepo → 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 → Networking → Generate 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:
- Service → Volumes → Add Volume
- Mount path:
/app
ESP8266 Arduino Code#
Libraries Required#
Install these via Arduino IDE → Tools → Manage Libraries:
MFRC522by GithubCommunityArduinoJsonby 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, andyour-app.up.railway.appwith 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#
| Flashes | Colour | Meaning |
|---|---|---|
| 2× green | Green | Checked IN successfully |
| 1× green | Green | Checked OUT successfully |
| 3× red | Red | Card not registered in system |
| 1× slow red | Red | Duplicate scan (too fast) |
| 2× red | Red | Network / server error |
Registering Students#
Before cards work, each student must be registered via the dashboard:
- Open
https://your-app.up.railway.app/students-page - Click Add Student → fill in name, class, roll number
- 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#
| Symptom | Likely cause | Fix |
|---|---|---|
HTTP -5 on ESP8266 | Using WiFiClient with HTTPS | Switch to WiFiClientSecure + setInsecure() |
HTTP 404 | Card UID not in database | Register the student via dashboard |
HTTP 429 | Card scanned twice within 10 s | Wait and scan again |
| Dashboard 500 error | Starlette/Jinja2 version mismatch | Update TemplateResponse to new API: (request, name, context) |
| Railway deploy fails | PORT hardcoded in Dockerfile | Use ${PORT:-8000} in CMD |
| Data lost after redeploy | No persistent volume on Railway | Add 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
WiFiClientSecureis mandatory for ESP8266 when the server uses HTTPS- Railway’s
PORTenvironment 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.
