17 August 2026

Building a Live QR Code Demo Platform with Flask and Incus

QR codes are everywhere — on restaurant menus, product packaging, event tickets. Most people know how to scan one, but fewer have thought about what's inside: a 2D barcode encoding plain text, URLs, or structured data that any modern smartphone camera can decode in a fraction of a second.

I built a small web platform to explore QR code generation live in the browser, with a focus on understanding the technical parameters that affect how codes look and how resilient they are to scanning. It's hosted at qr.mattsouthgate.co.uk and runs on a lightweight Linux container on my home server.


What's on the Site

The landing page gives a brief explanation of QR codes and links to two demos:

QR-Time (/time)

A live clock encoded as a QR code. The page refreshes every five seconds, generating a new code containing the current timestamp (2026-04-17 18:38:05). Scan it with your phone to capture the exact time — useful for demonstrating timestamp capture workflows, or just as a curiosity.

Make Your Own (/make)

Type any text into the box and watch the QR code update in real time (with a 300ms debounce). The page loads with "Test" pre-filled so there's a code visible immediately. The code encodes whatever you type, up to 500 characters. Scan the result with any QR reader to verify it.

Both demos include a dropdown to change the error correction level:

Level Recovery Effect
L ~7% Smallest, densest code
M ~15% Default — good balance
Q ~25% More robust to damage
H ~30% Largest, most resilient

Higher error correction means the code can still be scanned even if part of it is obscured or damaged — useful for printed codes that might get dirty. For a clean screen display, L or M is fine. Switching levels on the Make Your Own page lets you see the density change in real time.


The Stack

The server is about as minimal as it gets:

  • Python 3.12 + Flask — handles HTTP routing and serves HTML pages
  • qrcode + Pillow — generates QR PNGs on demand, entirely in memory
  • Caddy — TLS termination and reverse proxy on the host
  • Incus LXC container — isolated, lightweight runtime

The entire application is a single app.py file. Each request to /qr generates a PNG in memory, returns it, and throws it away. There's no database, no file storage, no JavaScript framework.

CPU usage is effectively zero at idle. Even under active use — someone scanning the clock page every five seconds — the server generates one QR PNG per refresh in a few milliseconds and returns to sleep.


Architecture Decisions

Path-based routing, single container

Rather than creating a separate container or virtual host per demo, all demos live under one container at one IP, served from one Flask app. Adding a new demo is:

  1. Write the HTML and Flask route
  2. Add a card to the index page
  3. Push the file and restart the service

No DNS changes, no Caddy changes, no new containers. The container is named qr to reflect that it's a multi-demo host rather than a single-purpose one.

Stateless QR generation

QR codes are generated fresh on every request. For /time this is intentional — the timestamp must be current. For /make, the same /qr endpoint accepts a ?data= parameter. There's no caching because there's no need: generation is fast and the data changes constantly.

If traffic ever warranted it, a short-TTL in-memory cache keyed on (data, error_correction_level) would be trivial to add.

Input sanitisation

The /make page sends user text to the server as a URL query parameter. On the server side:

  • Hard limit of 500 characters (returns HTTP 400 if exceeded)
  • Unicode normalised to NFC
  • Control characters stripped (except tab and newline, which are valid in QR data)

The data is passed directly to qrcode.add_data() — it's never rendered as HTML on the server, so XSS isn't a concern server-side. encodeURIComponent on the client prevents URL injection.

Layout

The Make Your Own page uses position: fixed controls at the bottom of the screen. To stop the QR code being obscured by the text box, the body uses padding-bottom: 9rem with box-sizing: border-box — this makes the flexbox centering work relative to the usable area above the controls, rather than the full viewport height. A max-height on the image prevents it overflowing on very tall screens.


The QR Generation Code

The app is built around Python's qrcode library. The core of QR generation:

qr = qrcode.QRCode(
    error_correction=qrcode.constants.ERROR_CORRECT_M,
    border=2,
)
qr.add_data("2026-04-17 18:38:05")
qr.make(fit=True)
img = qr.make_image()

fit=True lets the library choose the smallest QR version (1–40) that fits the data at the chosen error correction level. border=2 sets the quiet zone to 2 modules — the spec recommends 4, but scanners handle 2 fine on a clean screen.


Scaling Path

The current setup handles any realistic personal or demo traffic comfortably. If usage grew:

  1. More demos — add routes and cards. No infrastructure changes needed.
  2. Higher traffic — add an in-memory cache for /qr responses; swap Flask's dev server for Gunicorn with a couple of workers.
  3. Multiple distinct apps — spin up additional containers on the same bridge and add vhosts in Caddy. The path-based URL structure (/time, /make) scales naturally to new subpaths.

Try It

Visit qr.mattsouthgate.co.uk and scan the live clock, or type something into the Make Your Own page. Switch error correction levels to see the code density change.

No comments:

Post a Comment