23 August 2026

Kokoro TTS on a Shared GPU: Lazy Loading and Idle Unloading

Running multiple GPU-accelerated services on a single consumer GPU is a juggling act. This post documents how I diagnosed a VRAM exhaustion problem with four concurrent Kokoro TTS instances, why the "official" fix didn't exist, and how I patched the server to load the model on demand and release VRAM after 120 seconds of inactivity.


The Setup

I'm running four variants of kokoro-fastapi in Incus LXC containers, each serving a different combination of device and streaming mode:

Hostname Container Mode
kokoro-gpu-streaming.lan kokoro-stream-gpu GPU + streaming patches
kokoro-gpu.lan kokoro-gpu GPU, unpatched
kokoro-cpu-streaming.lan kokoro-stream CPU + streaming patches
kokoro-cpu.lan kokoro-tts CPU, unpatched

Each one is reverse-proxied by Caddy, so all four are reachable by name with no port numbers.


The Problem: 8 GB Doesn't Go Far

After getting the GPU streaming instance running cleanly, I tried to start the GPU non-streaming instance. It crash-looped immediately.

Apr 17 07:55:11 kokoro-gpu uv[402]: RuntimeError: Warmup failed: Failed to load model:
Failed to load Kokoro model: CUDA out of memory. Tried to allocate 2.00 MiB.
GPU 0 has a total capacity of 7.62 GiB of which 3.75 MiB is free.
Process 2729035 has 174.00 MiB memory in use.   ← ComfyUI
Process 2750368 has 880.00 MiB memory in use.   ← kokoro-stream-gpu
Process 2763628 has 6.26 GiB memory in use.     ← Ollama (model loaded)
Process 2766828 has 296.00 MiB memory in use.

Running nvidia-smi made the picture clear:

+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 580.126.09             Driver Version: 580.126.09     CUDA Version: 13.0     |
+-----------------------------------------+------------------------+----------------------+
|   0  NVIDIA GeForce RTX 4070 ...    Off |   00000000:01:00.0  Off|                  N/A |
| N/A   56C    P4              9W /   40W |    7501MiB /   8188MiB |      0%      Default |
+-----------------------------------------+------------------------+----------------------+

|    0   N/A  N/A         2729035      C   ...ComfyUI/.venv/bin/python3        174MiB |
|    0   N/A  N/A         2750368      C   /app/.venv/bin/python3              880MiB |
|    0   N/A  N/A         2763628      C   /usr/local/bin/ollama              6414MiB |

7,501 MiB of 7,623 MiB used. Ollama had a model loaded and was sitting idle, consuming 6.4 GB with nothing to do.

Each Kokoro GPU instance needs ~880 MB. Two instances plus ComfyUI plus an idle Ollama model adds up to more than 8 GB. Something had to give.


Mitigation Options

Before writing any code, it's worth considering what levers exist:

1. Reduce the number of GPU processes. Stop ComfyUI or unload the Ollama model when not in use. Ollama exposes a keep_alive API parameter — setting it to 0 on any request tells Ollama to unload immediately after responding. This frees 6.4 GB instantly, enough for both Kokoro instances with room to spare. It just means Ollama has a cold-start penalty on its next request too.

2. Use CPU for some instances. Kokoro on CPU takes ~1–3 seconds for a short phrase instead of ~90ms on GPU. For benchmarking purposes — which is what this setup is for — that's acceptable. The non-streaming instance doesn't need GPU to produce valid benchmark data.

3. Lazy-load the model. The server starts and listens, but only loads weights into VRAM when the first request arrives. Combined with an idle timeout that unloads the model after a period of inactivity, multiple GPU services can share the same card as long as they're not all active simultaneously.

For this setup the goal was four genuinely independent instances, so lazy loading was the right answer. Before implementing it, though, there was a detour.

3a. Checking What AI Agents Tell You

This setup is managed by AI agents (Claude), and the agents suggested a quick fix before any code was read:

Set IDLE_TIMEOUT=120 and WARMUP_STEPS=0 in the systemd service unit. WARMUP_STEPS=0 prevents the model loading at startup, and IDLE_TIMEOUT unloads it after inactivity.

This sounds authoritative. It's also completely wrong — and importantly, it isn't a hallucination in the technical sense. The model didn't confabulate random tokens. It generated a plausible-sounding answer based on patterns from similar projects (many ML serving frameworks do have env vars like these), then stated it as fact without checking whether this particular codebase actually implemented them.

The distinction matters: a hallucination is making something up. This was something subtly different — confident assertion without verification. The env vars were set:

Environment=IDLE_TIMEOUT=120
Environment=WARMUP_STEPS=0

The service still OOM'd on startup. The next step was what should have happened first:

grep -r "IDLE_TIMEOUT\|WARMUP" /app/api/
# (no output)

Not a single match. These variables are read nowhere in the codebase. The model always loads at startup, unconditionally, regardless of any environment variable you set. Version 0.3.0 simply doesn't have this feature.

The lesson isn't that AI agents are unreliable — they're genuinely useful for this kind of infrastructure work. The lesson is that suggestions about third-party library behaviour need to be verified against the actual source before being acted on, especially when they're offered without a citation. "Check the docs / grep the source" takes thirty seconds and would have saved the detour entirely. A good agent should do this itself before making the suggestion; when it doesn't, the human needs to catch it.


Diagnosing the Startup Flow

The relevant code lives in two files. First, main.py starts everything in a FastAPI lifespan context:

# api/src/main.py — original startup (simplified)
@asynccontextmanager
async def lifespan(app: FastAPI):
    model_manager = await get_manager()
    voice_manager = await get_voice_manager()

    # This loads the model weights immediately at startup
    device, model, voicepack_count = await model_manager.initialize_with_warmup(
        voice_manager
    )
    yield

initialize_with_warmup in model_manager.py calls initialize()load_model() → runs a warmup inference pass. The model is in VRAM before the first request ever arrives.

The generate() method had no concept of loading on demand:

# original generate() — raises if backend not initialized
async def generate(self, *args, **kwargs):
    if not self._backend:
        raise RuntimeError("Backend not initialized")
    async for chunk in self._backend.generate(*args, **kwargs):
        yield chunk

The Fix: Lazy Loading + Idle Unload

The fix has two parts: skip model loading at startup, and load it on the first generate() call. A background coroutine checks every 10 seconds and unloads if the model has been idle for 120 seconds.

Part 1 — main.py: Skip startup model loading

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Lifespan context manager — model loads on first request, not here."""
    import asyncio as _asyncio
    from .inference.model_manager import get_manager
    from .inference.voice_manager import get_manager as get_voice_manager
    from .services.temp_manager import cleanup_temp_files

    await cleanup_temp_files()

    # Set up the manager objects but do NOT load model weights.
    # The model will load on the first call to generate().
    model_manager = await get_manager()
    voice_manager = await get_voice_manager()

    # Start the background coroutine that watches for idle time
    # and unloads the model from VRAM when it hasn't been used.
    idle_task = _asyncio.create_task(model_manager.idle_monitor())

    logger.info("Kokoro TTS ready — model will load on first request")

    yield  # Server runs here

    idle_task.cancel()  # Clean up on shutdown

The key change: initialize_with_warmup() is never called. The server starts, listens for requests, and uses zero VRAM.

Part 2 — model_manager.py: Lazy load + idle monitor

class ModelManager:
    def __init__(self, config=None):
        self._config = config or model_config
        self._backend: Optional[KokoroV1] = None
        self._device: Optional[str] = None

        # Track when the model was last used (monotonic clock, not wall time)
        self._last_used: Optional[float] = None

        # Prevent two concurrent requests from both trying to load the model
        self._loading: bool = False

        # Unload after this many seconds of inactivity
        self._idle_timeout: int = 120

    async def _load_if_needed(self) -> None:
        """Load model weights into VRAM on demand.

        Thread-safety note: if two requests arrive simultaneously before the
        model is loaded, the _loading flag serialises them so only one
        actually loads the model. The second waits, then sees the model is
        already there and returns immediately.
        """
        # Fast path: model already loaded
        if (self._backend is not None
                and hasattr(self._backend, '_model')
                and self._backend._model is not None):
            return

        # Wait if another coroutine is already loading
        while self._loading:
            await asyncio.sleep(0.1)

        # Re-check after waiting — the other coroutine may have loaded it
        if (self._backend is not None
                and hasattr(self._backend, '_model')
                and self._backend._model is not None):
            return

        # This coroutine wins the race — load the model
        self._loading = True
        try:
            logger.info("Loading model on demand...")
            await self.initialize()                          # create KokoroV1 object
            model_path = self._config.pytorch_kokoro_v1_file
            await self.load_model(model_path)               # load weights into VRAM
            logger.info("Model loaded on demand")
        finally:
            self._loading = False  # always release the lock

    async def idle_monitor(self) -> None:
        """Background task: unload model after idle_timeout seconds of inactivity.

        Checks every 10 seconds. If the model is loaded and hasn't been used
        for 120 seconds, calls unload_all() to free VRAM. The model will
        reload transparently on the next request.
        """
        while True:
            await asyncio.sleep(10)  # poll interval
            if self._backend is not None and self._last_used is not None:
                idle = time.monotonic() - self._last_used
                if idle >= self._idle_timeout:
                    logger.info(f"Idle for {idle:.0f}s — unloading model from VRAM")
                    self.unload_all()       # calls backend.unload() and sets _backend = None
                    self._last_used = None  # reset so we don't trigger again immediately

    async def generate(self, *args, **kwargs):
        """Generate audio, loading the model first if needed."""
        # Ensure model is in VRAM before we try to run inference
        await self._load_if_needed()

        # Update last-used timestamp so the idle monitor knows the model is active
        self._last_used = time.monotonic()

        try:
            async for chunk in self._backend.generate(*args, **kwargs):
                if settings.default_volume_multiplier != 1.0:
                    chunk.audio *= settings.default_volume_multiplier
                yield chunk
                # Keep updating during long generations (multi-sentence text)
                self._last_used = time.monotonic()
        except Exception as e:
            raise RuntimeError(f"Generation failed: {e}")

Verification

After patching, the service starts with zero VRAM usage:

Apr 17 08:05:05 kokoro-stream-gpu uv[1767]: Kokoro TTS ready — model will load on first request
Apr 17 08:05:05 kokoro-stream-gpu uv[1767]: INFO: Application startup complete.

Confirming via nvidia-smi that the new process (host PID 2774231) doesn't appear in the GPU process list at all — zero VRAM allocated. The previous 812 MiB entry was a lingering process from the crash-loop cycle, cleaning itself up.

On the first request, the model loads in ~5 seconds and synthesis proceeds normally. After 120 seconds of inactivity, the idle monitor fires:

Idle for 121s — unloading model from VRAM

And the VRAM is returned to the system.


What This Enables

Both GPU instances now coexist. When neither has been called recently, they hold zero VRAM — Ollama and ComfyUI can use the full 8 GB uncontested. When a request comes in, whichever instance is needed loads its model (~5s cold start), handles the request, then unloads 120 seconds later.

If both GPU instances happen to be active simultaneously — unlikely in practice — they would compete for the ~1.1 GB of VRAM not occupied by ComfyUI, and one would OOM. That's an acceptable trade-off for a benchmarking setup. For a production single-instance deployment, the same patch gives you a TTS server that is invisible to the GPU scheduler when not in use.


Summary

  • kokoro-fastapi 0.3.0 has no built-in lazy loading or idle unload — both are custom patches
  • The startup model load is in lifespan() in main.py; removing it is one line
  • The lazy load logic belongs in generate() with a flag to serialise concurrent first-requests
  • The idle monitor is a simple asyncio background task that polls every 10 seconds
  • Both patches are forward-compatible: initialize_with_warmup() is preserved in the codebase for anyone who needs eager loading, and the new _load_if_needed() / idle_monitor() sit alongside it

Mapping 101 Devices Across a /16 with nmap in Four Minutes

I needed a current inventory of everything alive on our 10.140.0.0/16 network. The infrastructure had grown organically over a couple of years — Proxmox clusters, Incus containers, WiFi access points from three different vendors, IoT devices, a Windows machine, and a NAS — without a single maintained source of truth. Time to build one from scratch.

This is the method: a two-phase nmap scan, using MAC OUI lookups to classify devices before touching a single one of them.


The Setup

The scan ran from a Linux workstation directly attached to the 10.140.0.0/16 network via a wired interface (enx803f5df84e07, IP 10.140.0.192). The machine also hosts an Incus bridge (incusbr0, 10.140.20.1) giving it a second window into the container subnet.

Because both addresses sit on the same Layer 2 broadcast domain, nmap can use ARP to resolve MAC addresses for every host in the range — not just the directly connected subnets. That makes the OUI-based device classification accurate across the whole scan, not just for local neighbours.

Target range: 10.140.0.0 through 10.140.25.255 — the first 26 /24 blocks of the /16, covering all assigned infrastructure. That's 6,656 addresses.


Phase 1: Ping Sweep

The first pass is a host-discovery-only scan (-sn). No port probing, just ICMP echo and ARP:

sudo nmap -sn --open -T4 10.140.0-25.0-255 -oG /tmp/nmap_sweep.txt

-oG saves the output in greppable format. With ARP on a flat L2 network, this is fast — 6,656 addresses in just over two minutes. Result: 101 live hosts.

Extracting the IP list for phase two:

grep "^Host:" /tmp/nmap_sweep.txt | awk '{print $2}' | sort -t. -k1,1n -k2,2n -k3,3n -k4,4n > /tmp/live_hosts.txt

Phase 2: Port and Service Scan

With 101 hosts identified, the full port scan targets only those — no wasted probes against dead addresses:

sudo nmap -iL /tmp/live_hosts.txt -sV --version-intensity 1 -F -T4 --open -R --host-timeout 60s -oN /tmp/nmap_portscan.txt -oG /tmp/nmap_portscan_greppable.txt

Key flags:
- -F — top 100 ports. Fast, catches SSH, HTTP, RDP, PostgreSQL, SMB, and most application ports.
- --version-intensity 1 — minimal service banner probing. Enough to identify OpenSSH versions and web server names without sending dozens of probes per port.
- -R — resolve hostnames for all hosts, not just ones nmap already has names for.
- --host-timeout 60s — don't let a single unresponsive host stall the scan.
- --open — only report open ports. Keeps the output clean.

101 hosts, top 100 ports each: 153 seconds. Two and a half minutes.


Phase 3: Classifying Devices by MAC OUI

The most useful output from a network scan often isn't the port list — it's the MAC address. The first three octets (the OUI) identify the manufacturer, and on a managed network that maps almost directly to device type.

Parsing the combined output with Python:

import re, ipaddress

hosts = {}

with open('/tmp/nmap_portscan.txt') as f:
    current_ip = None
    for line in f:
        line = line.rstrip()
        m = re.search(r'Nmap scan report for (?:\S+ \()?(\d+\.\d+\.\d+\.\d+)\)?', line)
        if m:
            current_ip = m.group(1)
            hosts.setdefault(current_ip, {'hostname': '', 'mac': '', 'vendor': '', 'ports': []})
            hm = re.search(r'for (\S+) \(', line)
            if hm:
                hosts[current_ip]['hostname'] = hm.group(1)
        elif 'MAC Address:' in line and current_ip:
            m = re.match(r'MAC Address:\s+(\S+)\s+\(([^)]*)\)', line.strip())
            if m:
                hosts[current_ip]['mac'] = m.group(1)
                hosts[current_ip]['vendor'] = m.group(2)

What emerged:

OUI Vendor Device type
BC:24:11 Proxmox GmbH Proxmox VMs and LXC containers
10:66:6A Ruckus Networks APs and switches
50:C7:BF TP-Link WiFi access points
68:1D:EF Shenzhen CYX Technology Proxmox bare-metal host (PVE1)
E8:DB:84 Espressif ESP8266/ESP32 IoT device
68:37:E9 Amazon Technologies Amazon Echo or Fire device
AC:3B:77, 18:1E:78, 34:8A:AE, C8:91:F9 Sagemcom Router/AP firmware devices
00:18:0A Cisco Meraki Meraki AP

The BC:24:11 prefix was the biggest reveal. Proxmox assigns MAC addresses from its own OUI pool to every VM and container it creates. Once you know that, you can identify every virtual machine on the network at a glance — no hostname needed.


What 101 Hosts Looks Like

Summarised by subnet:

Subnet Live Character
10.140.0.x 18 Mix: TP-Link APs, Proxmox VMs, one Espressif IoT device
10.140.1.x 20 Mostly Proxmox VMs, more TP-Link APs, one Amazon device
10.140.2.x 8 Network infrastructure — Sagemcom APs, Cisco Meraki, default gateway
10.140.3.x 29 Dense Proxmox cluster — VMs, NFS servers, PVE1 bare metal
10.140.4.x 1 Single PostgreSQL VM
10.140.6.x 1 Media streaming device (Luxshare MAC, ports 8080/8443/RTSP)
10.140.10.x 1 Windows PC — RDP, SMB, WinRM all open
10.140.20.x 23 Incus container subnet — known services

The 10.140.3.x subnet was the surprise. Twenty-nine live hosts, almost all Proxmox VMs (BC:24:11 MACs), with the actual PVE1 hypervisor sitting at 10.140.3.10. Several machines expose port 3128 (Squid proxy) alongside SSH and NFS — a cluster pattern I hadn't documented before.


Interesting Finds

10.140.3.200 — the undocumented NAS. Ports 21 (ProFTPD), 22, 80/443 (nginx), 111/2049 (NFS), 139/445 (Samba), 5357 (WS-Discovery). That's a full-featured NAS behind a Proxmox VM MAC. Worth investigating what's stored there.

10.140.10.104 — Windows PC with everything open. RDP on 3389, SMB on 445, MSRPC on 135, NetBIOS on 139, WinRM on 80. Intel NIC. Not in any documentation. The Tailscale topology might explain how it's routable across subnets.

10.140.2.50 — telnet still open. SSH and HTTP/HTTPS alongside port 23. On a 10.140.2.x device that looks like network infrastructure. Needs a closer look.

10.140.0.204 — Espressif on port 8081. The MAC prefix nails it as an ESP8266 or ESP32. One of the IoT devices, running its own HTTP service. Not in the known device list.

Ruckus MACs in the container subnet. The three Ruckus R720 APs documented as being at 10.140.2.16–18 didn't respond at those IPs. Ruckus-prefix MACs (10:66:6A) appear instead across 10.140.20.x — the same range as the Incus containers. Some of those IPs match known containers (faster-whisper at .6, open-webui at .61), suggesting either the MAC assignment in Incus is pulling from the Ruckus OUI range, or some APs have shifted IP leases. Needs verification.


Access Points Identified

Fourteen confirmed APs across three vendors:

TP-Link (port 9999 = TP-Link TDDP device management):
10.140.0.22, 10.140.0.63, 10.140.0.235, 10.140.1.108, 10.140.1.228, 10.140.1.238

Sagemcom (Dropbear SSH + DNS + HTTP — standard AP firmware):
10.140.2.9, 10.140.2.11, 10.140.2.12, 10.140.2.13

Cisco Meraki:
10.140.2.15

Ruckus (port 8080 = Ruckus web management UI):
10.140.20.15, 10.140.20.16, 10.140.20.30


The Output

Raw files saved at /tmp/nmap_portscan.txt and /tmp/nmap_portscan_greppable.txt. The full structured inventory is at docs/network-scan-10.140.0-25.md — 101 rows, grouped by subnet, with MAC, vendor, open ports, and annotations for known services.

Total elapsed: under four minutes from cold start to annotated inventory.


Caveats

The -F flag only covers the top 100 ports by frequency. Several known services run on non-standard ports and won't appear: the LiteLLM proxy (4000), Kokoro TTS (8880), and Ollama (11434) are all in the container subnet but showed no open ports in this scan. For services on non-standard ports, follow up with a targeted scan against the known host list:

sudo nmap -iL /tmp/live_hosts.txt -p 4000,8880,11434,8188 --open -T4

ARP-based MAC resolution only works if the scanner is on the same L2 segment. On a routed network, MAC addresses won't be visible for remote hosts — you'd need to query the ARP caches on intermediate switches or run the scan from each segment.


Scan conducted 2026-05-07. nmap 7.94SVN. Scanner: 10.140.0.192/16 (Ubuntu 24.04, kernel 6.17).

18 August 2026

Opening .ics Files into Google Calendar on Ubuntu with Firefox

Clicked an event invite, got a .ics file, and had no idea what to do with it? Here's the setup that actually works on Ubuntu with the snap version of Firefox.

The Problem

Firefox snap is sandboxed — it can't hand files directly to desktop apps. If you try to associate .ics files with Firefox, you'll end up in a loop of tabs opening endlessly. Ask me how I know.

The fix is a three-part setup: tell Firefox to save .ics files to disk, set GNOME Calendar as the system handler, and point GNOME Calendar at your Google account by default.

Step 1: Stop Firefox Fighting Over .ics Files

Firefox's internal handler for .ics files defaults to "always ask", which causes the loop. Set it to save-to-disk instead.

Close Firefox first, then edit:

~/.mozilla/firefox/<your-profile>/handlers.json

Find the text/calendar entry and change "action":4 to "action":0:

"text/calendar":{"action":0,"extensions":["ics","ifb","ical","icalendar"]}

Also clean up the system mime association so Firefox isn't listed as a handler:

xdg-mime default org.gnome.Calendar.desktop text/calendar

And check ~/.config/mimeapps.list — remove firefox_firefox.desktop from the [Added Associations] line for text/calendar if it's there.

Step 2: Connect Google Calendar to GNOME Calendar

Open Settings → Online Accounts → Google, sign in, and make sure Calendar is toggled on.

Step 3: Set Your Google Calendar as the Default Import Target

By default GNOME Calendar imports to a local "Personal" calendar. To fix that, find your Google Calendar's source UID:

grep -rl "mattsouthgate@gmail.com" ~/.cache/evolution/sources/

You'll get a path like:

~/.cache/evolution/sources/<account-uid>/<calendar-uid>.source

Take that <calendar-uid> and set it as the default:

gsettings set org.gnome.Evolution.DefaultSources default-calendar '<calendar-uid>'

The Workflow

  1. Click an .ics link in Firefox — it saves to ~/Downloads automatically
  2. Open Files → Downloads → click the .ics file
  3. GNOME Calendar opens with an import dialog, defaulting to your Google Calendar
  4. Hit Import

Not quite one-click, but reliable — and no more tab storms.

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.