Self-hosting Backlogia, and fixing it before running it
I have games spread across Steam, GOG, Epic, itch.io, and a handful of Humble bundles I have forgotten about. I wanted a single place to see them all. I had researched this once before and walked away convinced I would have to build the tracker myself, because nothing self-hosted and web-based seemed to exist. Then Backlogia showed up: a self-hosted FastAPI app that pulls from all those sources into one library. Setup should have been quick. It wasn’t, but the detour was worth it.
The security audit
Before deploying anything to my homelab, I read the code. Backlogia had four issues I wasn’t willing to ship.
Running as root. The Dockerfile had no USER instruction. Any RCE in the app or a dependency would have full container access.
/setup open after account creation. The initial account setup page stayed accessible indefinitely. The route handler blocked duplicate creation, but the endpoint itself never closed.
No CSRF tokens. The login and setup forms accepted cross-origin POST submissions without any validation.
Plaintext credentials at rest. API keys for Steam, IGDB, EA, and the rest were stored in SQLite as-is.
I checked for existing fixes upstream. None of these were addressed. So I forked it.
The fork
I created vdaluz/backlogia on a security-fixes branch and fixed all four.
Non-root container. I changed the Dockerfile to add a dedicated user with an explicit UID/GID of 1000. The explicit UID matters for host volume mounts: you need to chown the data directory on the host before the container starts, and a system-assigned UID (often 999 on Debian) is unpredictable.
RUN groupadd -g 1000 appuser \
&& useradd -u 1000 -g appuser -m -s /bin/sh appuser \
&& chown -R appuser:appuser /app /data
USER appuser
The -m flag is easy to forget. Without it, useradd skips creating the home directory, and I found that out the hard way when legendary-gl crashed trying to write to /home/appuser/.config/.
Locked /setup. One middleware change. Once any user account exists, both /setup and /auth/setup redirect to /login.
CSRF. itsdangerous was already a dependency, used for session signing. I added a second signer with a separate salt for time-limited CSRF tokens, embedded as hidden fields in the login and setup forms. Tokens expire after an hour.
Fernet encryption. For the credential fields, I derived a Fernet key from the app’s secret key using PBKDF2HMAC, then wrapped the set_setting and get_setting calls for sensitive keys. Existing plaintext values decrypt transparently on first read: they fail the token check and fall through to plaintext, so migrating an existing install doesn’t break anything.
Starlette 1.0.0 broke everything
After the security work, I deployed and immediately hit a 500 on /setup. The error was TypeError: unhashable type: 'dict'.
Starlette 1.0.0 (March 2026) changed TemplateResponse. The old call:
# broken with Starlette 1.0.0+
TemplateResponse("setup.html", {"request": request, "error": ""})
The new one:
TemplateResponse(request, "setup.html", {"error": ""})
requirements.txt doesn’t pin Starlette, so a fresh install pulls 1.0.0+ and every HTML route throws. There were 14 calls across 5 files. I updated them all. This is the single most urgent fix for anyone spinning up a new install.
The bookmarklet CORS bug
Backlogia has a neat feature: bookmarklets you install in your browser that scrape your game libraries from GOG, Ubisoft, and others, then POST them to your local instance. With auth enabled, the GOG bookmarklet just showed “Failed to connect to Backlogia.” There were two bugs stacked on top of each other.
Bug 1: middleware order. In Starlette, add_middleware is last-in, outermost. The original code added CORS first and auth second, which made auth the outer layer. Auth rejected the cross-origin request with a 401 before CORS could add its Access-Control-Allow-Origin header, so the browser saw a response with no CORS header and threw a network error.
# Wrong: auth becomes outermost
app.add_middleware(CORSMiddleware, ...)
app.add_middleware(AuthMiddleware, ...)
# Correct: CORS becomes outermost and wraps auth's 401s
app.add_middleware(AuthMiddleware, ...)
app.add_middleware(CORSMiddleware, ...)
Bug 2: cross-origin cookies. Even with the order fixed, the bookmarklet’s fetch() call didn’t include credentials: 'include', so no session cookie was sent. And even with that flag, the default SameSite=Lax blocks cookies on cross-origin fetch() calls anyway.
The practical fix: exempt /api/import/* from auth entirely. Those endpoints only accept game data the user is deliberately sending from their own browser. For a personal homelab app, that’s a reasonable trade.
Deployment
The same Docker-in-LXC pattern I use for most services. The Ansible role clones the fork, builds the image locally with docker build, and deploys via Compose. pull_policy: never tells Compose not to go looking for the image in a registry.
backlogia:
image: vdaluz/backlogia:security-fixes
pull_policy: never
ports:
- '5050:5050'
volumes:
- /data/backlogia:/data
environment:
- ENABLE_AUTH=true
- SECRET_KEY={{ backlogia_secret_key }}
Traefik handles HTTPS termination at backlogia.example.net.
Library setup
Steam and itch.io were straightforward, just API keys from each platform’s developer settings. Epic and Amazon Games both have CLI tools (legendary and nile) that ship inside the image; you auth them once inside the running container and the credentials persist on the mounted data volume.
GOG uses the bookmarklet. You install it from the Backlogia settings page, open your GOG library, click the bookmark, and it scrolls through your games and POSTs them. Once the CORS bugs were fixed, it worked cleanly.
The fork is permanent now
I submitted the work upstream: three of the four security fixes (non-root, locked /setup, CSRF), plus the Starlette and bookmarklet fixes, as five PRs (#84 to #88). The fourth security fix, the Fernet credential encryption, I kept fork-only on purpose, because it is wired to my own secret-key deployment and wouldn’t generalize.
Then nothing happened. The maintainer keeps shipping his own commits but hasn’t merged an outside contributor’s PR in months, and mine have sat open and untouched since I filed them. I am treating them as indefinitely stalled, and that turns the fork from a temporary patch into permanent infrastructure. A few things follow from that:
- My usual container update-watcher is out. It compares the running image tag against a registry, and my image is a local build tag that resolves nowhere, so updates are manual.
- “Just go back to the official image” is a trap. Switching back would silently drop all four protections, a security regression dressed up as a cleanup. The official image is exactly the thing I forked away from.
- Staying current means rebasing my hardening commits onto each new upstream release by hand and rebuilding. I watch the releases feed and rebase when a new tag lands.
Would I do it again? Yes, with clear eyes. Reading the code before running it is the whole point, and finding four real issues justified the detour. Forking to fix them was right. What I underestimated was the tail: a fork you carry indefinitely because upstream went quiet. Submit your fixes upstream, hope they land, and be honest that they might not, because then the maintenance is yours.
And if you want the research that sent me looking in the first place, back when I was sure I’d have to build this myself, it’s in Researching self-hosted game library consolidation.
Related reading
Deploying RomM, a self-hosted ROM manager, in the homelab
I wanted a web-based, self-hosted way to organize a pile of game backups with real metadata. RomM was the only option that fit. Here is the Docker-in-LXC deployment, the two gotchas that cost me time, and the NAS mount mistake that bit me later.
Deploying Homebox, a self-hosted home inventory, in the homelab
I wanted one place to track what I own, what it cost, and where the warranty paperwork lives. Homebox fit, and a SQLite-backed deploy meant no extra database to babysit. Here is the Docker-in-LXC setup and the admin-account trick that is easy to miss.
Diagnosing slow RomM scans on a large ROM library
RomM was taking 60-80 seconds per ROM during its first scan on my homelab. Here is what I found, what I changed, and why the real answer turned out to be a much bigger library than I thought.
Ready to Transform Your Career?
Let's work together to unlock your potential and achieve your professional goals.