Building a Self-Hosted Event QR Code Scanner: A Complete Guide

Date

Date

Date

November 3, 2025

November 3, 2025

November 3, 2025

Author

Author

Author

Lisa Zhao

Lisa Zhao

Lisa Zhao

From Zero to Production in One Weekend

In the last 3 years I have volunteered for 10+ events from Folk Fest to Burning Man. I often do Gate + Check-in and its a pain to scan people in by hand or worse a paper list! Long lines, tired people, people spelling their names three times? Yeah, welcome to hell.

So I decided to build my own QR code ticket scanner system that runs on my own server. I'm going to be using this system for an upcoming Women's Festival that until now did all check in by hand on paper. Full disclosure I vibe-coded this with Claude but it works and its free, for a small event of less than 1000 attendees this will do the job just fine.

Security Disclaimer: This tutorial uses example configurations for educational purposes. Always use strong, unique passwords, keep your software updated, and follow your hosting providers security recommendations. The security practices shown here are industry-standard but should be adapted to your specific needs.


What We're Building

A professional event check-in system with:

  • 🎫 QR code scanning via phone camera

  • 👥 Manual search functionality (for when QR codes don't work)

  • 📊 Real-time check-in tracking

  • 🔐 Secure multi-user access (multiple staff members scanning simultaneously)

  • 💾 Self-hosted (you own your data!)

Tech Stack:

  • YunoHost (for server management)

  • NocoDB (for the database/admin panel)

  • PHP (for the scanner app)

  • MySQL (for data storage)

Time to build: ~6 hours (including learning and debugging)
Cost: Free (if you already have a server) or ~$5/month for a VPS

Why Self-Host?

Before we dive in, you might ask: "Why not just use Eventbrite or some SaaS?" Fair question!

Reasons I chose self-hosting:

  1. Data ownership - All attendee info stays on my server

  2. No per-ticket fees - SaaS tools charge per ticket (adds up fast!)

  3. Customization - I can modify anything I want

  4. Learning - Great project to understand full-stack development

  5. Control - No internet? No problem (with the right setup)

Part 1: Setting Up Your Server with YunoHost

What is YunoHost?

Think of YunoHost as "WordPress for servers" - it makes running a server as easy as using a web interface. No need to memorize Linux commands (though we'll use some!).

Step 1: Install YunoHost

If you don't have YunoHost yet:

  1. Get a VPS (I use DigitalOcean, but Linode, Hetzner, or any provider works)

  2. Follow YunoHost installation guide

  3. Complete the post-installation setup

  4. Add your domain (e.g., yourdomain.com)

Cost: $5-10/month for a basic VPS

Step 2: Install the "My Webapp" App

YunoHost has an app catalog. We need "My Webapp" - it's basically a blank canvas PHP app.

# SSH into your server
ssh admin@yourdomain.com

# Install My Webapp
sudo

During installation, it'll ask:

  • Domain: scanner.yourdomain.com (or whatever subdomain you want)

  • Path: / (just press enter)

  • Admin user: Choose who can access it

What you get:

  • A working web server at https://scanner.yourdomain.com

  • PHP 8.4 pre-configured

  • MySQL database ready to use

  • SSL certificate (automatic HTTPS)

Pretty cool, right?

Part 2: Setting Up NocoDB (Your Database)

Why NocoDB?

NocoDB is like "Airtable but open-source and self-hosted." It gives you:

  • A spreadsheet-like interface for your data

  • Built-in QR code generation

  • API access (so your scanner can read/write data)

  • No coding required for data management

Step 1: Install NocoDB on YunoHost

sudo

Choose:

  • Domain: nocodb.yourdomain.com

  • Access: Private (protect it with login)

Step 2: Create Your Ticket Database

  1. Go to https://nocodb.yourdomain.com

  2. Create a new project called "Event Tickets"

  3. Create a table called "Attendees"

  4. Add these columns:

    • Ticket_ID (Single Line Text) - e.g., "TKT-2025-0001"

    • Attendee_Name (Single Line Text)

    • Attendee_Email (Email)

    • Attendee_Phone (Phone)

    • Ticket_Type (Single Select: VIP, Regular, Vendor)

    • Scanned (Checkbox) - Track if they checked in

    • ScanDate (Date)

    • ScanTime (Time)

  5. Add a QR Code column that uses Ticket_ID as the value

Pro tip: NocoDB auto-generates QR codes! You can download them and send to attendees.

Step 3: Get Your API Token

  1. Click your profile (top right)

  2. Go to "API Tokens"

  3. Create a new token

  4. Copy it somewhere safe (you'll need it soon)

Part 3: Building the Scanner App

Now for the fun part! We're building a PHP web app that:

  1. Scans QR codes using your phone's camera

  2. Checks tickets against the NocoDB database

  3. Marks people as checked in

  4. Shows success/error messages

The Architecture

Key features we'll implement:

  • Continuous scanning - Scan, show result, ready for next person (no stopping!)

  • Visual feedback - Green border for success, red for errors

  • Manual search - Search by name when QR codes don't work

  • Multi-user support - 4 scanners can work simultaneously

  • Race condition prevention - Two people can't check in the same ticket at once

  • Security - Database credentials hidden from web

The Code Structure

I won't paste the entire code here (it's 1000+ lines!), but here's the architecture:

index.php has four main sections:

1. User Authentication


Uses PHP sessions to keep users logged in. Passwords are hashed with password_hash() for security.

2. Database Connection


3. API Endpoints (AJAX handlers)


4. Frontend (HTML + JavaScript)

// Start QR scanner
Html5Qrcode.start(
    { facingMode: "environment" },
    { fps: 10, qrbox: 250 },
    onScanSuccess
);

// When QR code is detected
function onScanSuccess(ticketId) {
    // Show green border
    // Verify ticket via AJAX
    // Display result
    // Ready for next scan (no stopping!)
}

Key Technical Challenges Solved

Challenge 1: Continuous Scanning

Problem: Most QR scanners stop after each scan. Not great for events!

Solution: Instead of stopping the camera, we just:

  1. Flash a green/red border for visual feedback

  2. Show the result message

  3. Let the scanner keep running

  4. Next scan replaces the old message

function onScanSuccess(decodedText) {
    if (isProcessing) return; // Prevent duplicate scans
    isProcessing = true;
    
    // Flash green border
    reader.classList.add('scanning-success');
    
    // Verify ticket
    verifyTicket(decodedText);
    
    // After showing result, remove border and ready for next scan
    reader.classList.remove('scanning-success');
    isProcessing = false;
}

Challenge 2: Race Conditions

Problem: What if Scanner 1 and Scanner 2 scan the same ticket at the exact same time?

Solution: Database row locking with transactions!


The FOR UPDATE clause locks the row. If Scanner 2 tries to access it, they wait until Scanner 1 finishes.

Challenge 3: Mobile Responsiveness

Problem: Staff use phones to scan. Desktop-first designs don't work.

Solution: Mobile-first CSS with proper viewport and touch handling:

* {
    -webkit-tap-highlight-color: transparent; /* Remove blue tap flash */
}

input[type="checkbox"] {
    width: 28px;  /* Big enough for fingers */
    height: 28px;
    flex-shrink: 0; /* Don't compress on small screens */
}

.result-card {
    /* Stack vertically on mobile */
    flex-direction: column;
    gap: 0.8rem;
}

@media (max-width: 600px) {
    .header h1 { font-size: 1.1rem; }
    button { font-size: 1rem; }
}

Part 4: Security - Protecting Your Database Credentials

Here's where it got interesting. Initially, I had database credentials directly in index.php:


The problem: If PHP fails or someone views the source, they see your password!

The Secure Solution

Step 1: Create a config directory OUTSIDE the web root

sudo mkdir -p

Directory structure:


Step 2: Create database.php with credentials

<?php
return [
    'host' => '127.0.0.1',
    'database' => 'nocodb',
    'username' => 'nocodb',
    'password' => 'super_secure_password',
    'table' => 'your_table_name'
];

Step 3: Lock down permissions

This is crucial! We need to make the file readable ONLY by the PHP process.

# Find out what user PHP runs as
sudo grep "^user" /etc/php/8.4/fpm/pool.d/YOUR_APP_NAME.conf
# Output: user = YOUR_APP_NAME

# Set ownership to that user
sudo chown YOUR_APP_NAME:YOUR_APP_NAME /var/www/YOUR_APP_NAME/config/database.php

# Make it readable ONLY by the owner
sudo chmod 400

What chmod 400 means:

  • 4 = read for owner

  • 0 = no permissions for group

  • 0 = no permissions for others

Result: Only the YOUR_APP_NAME user can read it!

Step 4: Fix directory permissions

# The directory needs to be accessible
sudo chown YOUR_APP_NAME:YOUR_APP_NAME /var/www/YOUR_APP_NAME/config
sudo chmod 755

Step 5: Add .htaccess protection as backup

# .htaccess in config directory
<Files "*">
    Require all denied
</Files>

Step 6: Load config in your app

<?php
// In index.php
$configPath = '/var/www/YOUR_APP_NAME/config/database.php';

if (!file_exists($configPath)) {
    die('Configuration error.');
}

$dbConfig = require $configPath;

Testing Security

Try accessing:

  • https://scanner.yourdomain.com/config/database.php → Should fail (403/404)

  • https://scanner.yourdomain.com/../config/database.php → Should fail

Try reading the file:

# As regular user (should fail)
cat /var/www/YOUR_APP_NAME/config/database.php
# Permission denied ✓

# As the PHP user (should work)
sudo -u YOUR_APP_NAME cat /var/www/YOUR_APP_NAME/config/database.php
# Shows config ✓

Security layers:

  1. ✅ File outside web root (not accessible via URL)

  2. ✅ Restrictive permissions (chmod 400)

  3. ✅ .htaccess protection (backup layer)

  4. ✅ Database on localhost only (not accessible remotely)

  5. ✅ HTTPS encryption (all traffic encrypted)

Part 5: Deploying and Testing

Deployment Checklist

# 1. Upload your index.php
scp index.php user@yourdomain.com:/tmp/
ssh user@yourdomain.com
sudo mv /tmp/index.php /var/www/YOUR_APP_NAME/www/

# 2. Set permissions
sudo chown YOUR_APP_NAME:YOUR_APP_NAME /var/www/YOUR_APP_NAME/www/index.php
sudo chmod 644 /var/www/YOUR_APP_NAME/www/index.php

# 3. Create config
sudo mkdir -p /var/www/YOUR_APP_NAME/config
sudo nano /var/www/YOUR_APP_NAME/config/database.php
# Paste your config

# 4. Secure it
sudo chown YOUR_APP_NAME:YOUR_APP_NAME /var/www/YOUR_APP_NAME/config/database.php
sudo chmod 400 /var/www/YOUR_APP_NAME/config/database.php

# 5. Test PHP syntax
php -l

Testing Your Scanner

Test 1: Basic Login

  • Go to https://scanner.yourdomain.com

  • Login with your credentials

  • Should see the scanner interface ✓

Test 2: QR Scanning

  1. Click "Start Scanner"

  2. Allow camera access

  3. Point at a QR code (create test tickets in NocoDB)

  4. Should see green border flash

  5. Success message appears

  6. Scanner stays active for next person ✓

Test 3: Manual Search

  1. Click "🔍 Search" tab

  2. Type part of an attendee's name

  3. Should see matching results

  4. Click checkbox to check them in ✓

Test 4: Multi-User (Race Conditions)

  1. Open scanner on two phones

  2. Both scan the SAME ticket simultaneously

  3. First one: ✅ "Check-in successful!"

  4. Second one: ⚠️ "Already checked in by scanner1" ✓

Test 5: Security

  • Try https://scanner.yourdomain.com/config/database.php → Should fail ✓

  • View page source → Search for your password → Should NOT find it ✓

Lessons Learned & Tips

What Went Wrong (So You Don't Have To)

1. CORS Issues Initially tried to have the scanner call NocoDB's API directly. Hit CORS (Cross-Origin Resource Sharing) issues because they're on different domains.

Solution: Connect directly to MySQL instead. Simpler, faster, no CORS!

2. YunoHost SSO Blocking API Calls YunoHost has a Single Sign-On system that was blocking my API proxy attempts.

Solution: Again, direct MySQL connection solved it.

3. File Permission Confusion Spent an hour debugging why PHP couldn't read the config file. Turned out PHP was running as YOUR_APP_NAME user, not www-data!

Solution: Always check what user your PHP-FPM pool runs as.

4. Scanner Stopping After Each Scan Original implementation stopped the camera after each scan. Super annoying at busy check-in.

Solution: Just reset the isProcessing flag instead of stopping the camera.

Pro Tips

1. Test with fake data first Don't test with real attendee data. Create 10 fake tickets in NocoDB first.

2. Have a backup plan Print a paper list. Technology fails. Have a manual backup.

3. Charge your devices Bring power banks. Scanners drain battery fast.

4. Test in the actual venue WiFi might be spotty. Internet might be slow. Test beforehand!

5. Train your staff 15 minutes of training prevents hours of confusion.

6. Monitor in real-time Keep NocoDB open on a laptop to watch check-ins happen live.

Going Further

Features I'd Add Next

1. Offline Mode

  • Cache tickets in localStorage

  • Queue check-ins

  • Sync when connection returns

2. Analytics Dashboard

  • Real-time check-in counter

  • Chart showing check-ins over time

  • VIP vs Regular breakdown

3. QR Code Generation

  • Auto-generate tickets in bulk

  • Email QR codes to attendees

  • PDF ticket generation

4. Check-Out Tracking

  • Track when people leave

  • Calculate average stay time

5. Export Functionality

  • Export attendance to CSV

  • Generate attendance report

Scaling Up

This setup easily handles:

  • ✅ 500+ attendees

  • ✅ 4-5 simultaneous scanners

  • ✅ Multiple events (just create new NocoDB tables)

For larger events (1000+ attendees):

  • Use Redis for caching

  • Add a load balancer

  • Move MySQL to a dedicated server

Cost Breakdown

Initial Setup:

  • VPS (2GB RAM): $5-10/month

  • Domain: $10/year

  • SSL Certificate: Free (Let's Encrypt)

  • Total: ~$5-10/month

Compare to SaaS alternatives:

  • Eventbrite: 3.5% + $1.79 per ticket

  • For 500 tickets at $20 each: $1,245 in fees!

Your system pays for itself after ONE event.

Conclusion

Building your own event check-in system is:

  • Cheaper than SaaS (no per-ticket fees)

  • More secure (you own the data)

  • More flexible (customize anything)

  • Educational (learn PHP, databases, security)

  • Reliable (no vendor lock-in)

The whole project took me about 6 hours including:

  • Learning YunoHost

  • Setting up NocoDB

  • Writing the scanner code

  • Debugging security issues

  • Testing everything

Was it worth it? Absolutely! I now have:

  • A reusable system for future events

  • Complete control over my data

  • No ongoing per-ticket fees

  • A cool project for my portfolio

Resources

Code & Deployment Scripts:

  • Full source code: [GitHub link would go here]

  • Deployment automation script

  • Security hardening guide

Official Documentation:

Helpful Communities:

  • YunoHost Forum

  • r/selfhosted on Reddit

  • NocoDB Discord

Your Turn!

Have questions? Hit me up in the comments!

Thinking of building this? Here's your weekend project checklist:

  • [ ] Set up YunoHost server

  • [ ] Install My Webapp

  • [ ] Install NocoDB

  • [ ] Create ticket database

  • [ ] Build scanner interface

  • [ ] Secure your config

  • [ ] Test with fake data

  • [ ] Deploy and use!

Time investment: One weekend
Skill level: Intermediate (basic PHP and command line knowledge helpful)
Satisfaction: 🎉🎉🎉

Happy scanning! 🎫

Built something cool with this guide? I'd love to see it! Tag me or drop a link in the comments.

Related posts

November 11, 2025

How to Automatically Share Your Ghost Blog Posts on LinkedIn, Twitter, and Facebook with N8N

Description

November 11, 2025

How to Automatically Share Your Ghost Blog Posts on LinkedIn, Twitter, and Facebook with N8N

Description

November 11, 2025

How to Automatically Share Your Ghost Blog Posts on LinkedIn, Twitter, and Facebook with N8N

Description

November 10, 2025

Compassionate Systems: Clear is Kind , Unclear is Unkind

Description

November 10, 2025

Compassionate Systems: Clear is Kind , Unclear is Unkind

Description

November 10, 2025

Compassionate Systems: Clear is Kind , Unclear is Unkind

Description

Got questions?

I’m always excited to collaborate on innovative and exciting projects!

Got questions?

I’m always excited to collaborate on innovative and exciting projects!

Got questions?

I’m always excited to collaborate on innovative and exciting projects!

Lisa Zhao, 2025

XX

Lisa Zhao, 2025

XX

Lisa Zhao, 2025

XX