Deploying RomM, a self-hosted ROM manager, in the homelab
I had a growing pile of game backups scattered across folders with no organization and no metadata. I wanted one place to manage them: web-based, self-hosted, with cover art and platform sorting, and ideally a way to play something in the browser without setting up an emulator on every machine. To be clear up front, this is about managing a library of games I own, not acquiring anything. RomM organizes what you give it.
This is the story of picking a tool, deploying it on my Proxmox cluster, and the two problems that turned a quick deploy into an afternoon.
Picking a ROM manager
I went in with two names: RomM and something I had written down as “Emulibrary.” The research killed one of them immediately.
“Emulibrary” is actually EmuLibrary, a Playnite extension for Windows. It needs Playnite installed and runs on a desktop. That is not self-hosted, and it is not web-based, so it was out. The other tools I looked at had the same problem: Romulus, RomVault, and EmuControlCenter are all Windows desktop applications. Useful, but not a service I can run on a server and reach from a browser.
That left RomM, and the more I read the better it fit:
- Over 400 gaming platforms.
- Metadata enrichment from IGDB, ScreenScraper, and MobyGames, plus cover art from SteamGridDB.
- Browser-based play through EmulatorJS.
- Hash-based matching for accurate metadata, multi-disk handling, and filename tag parsing.
- AGPL-3.0, and deployed with Docker Compose against MariaDB, MySQL, or PostgreSQL.
It is the only one that met the actual requirement, a self-hosted web app, so the decision made itself.
Deploying it: Docker-in-LXC
My homelab runs services as Docker containers inside Proxmox LXC containers. It is a slightly unusual pattern, but it gives me Proxmox-level backups, replication, and HA failover around a normal Docker Compose stack. RomM got the same treatment.
The stack is two containers managed by Compose: RomM itself on port 7676, and a MariaDB database for its metadata. I put the whole thing behind my Traefik reverse proxy so it is reachable over HTTPS at an internal name and a public one, and I drove the deployment with Ansible so it is reproducible rather than hand-built.
The one design decision worth calling out is storage. A ROM library gets big, and I did not want it living on the container’s disk. So the library sits on my NAS and the LXC mounts it over SMB at /media/emulation, with the structure RomM expects:
/media/emulation/roms/<platform>/
/media/emulation/bios/<platform>/
RomM scans those directories, sorts by platform, and enriches each title with metadata. Putting the library on the NAS also means it survives the container entirely and gets backed up with everything else there.
Gotcha one: “Configuration file not mounted!”
The first time the web interface came up, it greeted me with Configuration file not mounted! instead of a library.
RomM expects a config.yml in its config directory and will not start properly without one. An empty deploy does not create it for you. The fix was to ship a default config.yml as part of the deployment with the sections RomM wants (exclude rules, system, filesystem) so the file is always present before the container starts. While I was in there I added a rule to exclude .txt files from scanning, because scattered readme and notes files were otherwise getting picked up as if they were games.
Gotcha two: the MariaDB user that never got created
With the config file sorted, RomM still could not talk to its database. The MariaDB container was healthy, but RomM’s connection kept failing.
This one is a classic and I walked right into it. The official MariaDB image only runs its initialization, creating the database and the application user from the environment variables, when the data directory is empty on first start. My first run had created that data directory with one password, and a later run changed the password in the environment. Because the data directory was no longer empty, MariaDB never re-ran init, so the user RomM was trying to log in as did not exist with the password it was using.
The fix was to wipe the database data directory and let MariaDB initialize cleanly with the password it would actually be given, then persist that password so re-runs stayed idempotent. After that, RomM connected on the first try and the library finally loaded.
Metadata that actually shows up
Out of the box RomM scans filenames, but the good part is the metadata. I wired up ScreenScraper as the primary source for covers, screenshots, and details, and SteamGridDB for high-quality cover art, with the credentials pulled from my Ansible vault rather than typed into the UI. ScreenScraper alone covers well over a hundred systems, and between the two the library went from a folder of cryptic filenames to a wall of box art.
The NAS mount mistake that bit me later
One footnote that turned into a real lesson. That SMB mount I was happy about caused an outage months later, and it was my own fault.
A backup script mounted the NAS share with nofail in the fstab entry and trusted the mount command’s exit code. With nofail, CIFS can return exit code 0 even when the mount actually failed to connect. The script saw “success,” ran rsync, and wrote tens of gigabytes of backup data into the container’s root filesystem instead of onto the NAS. That filled the LXC disk and took RomM down with it.
The fix is to never trust the exit code for a network mount. Check the kernel’s mount table instead:
mount "${NAS_MOUNT}"
if ! mountpoint -q "${NAS_MOUNT}"; then
echo "ERROR: NAS mount failed" >&2
exit 1
fi
mountpoint -q is not fooled by nofail. If the share is not actually mounted, the script stops before it can write into the wrong place.
Where it landed
RomM has been running quietly since: hundreds of platforms supported, a properly organized library on the NAS, metadata and cover art filled in, browser play when I want it, and the same backups, replication, and monitoring as every other service in the homelab.
A few things I took away:
- The hard part of a self-hosted deploy is rarely the app. It was the database init order and a missing config file, both generic Docker problems wearing a RomM costume.
- MariaDB only initializes its user once, against an empty data directory. If you change the password after that first run, wipe the data and start clean.
- RomM wants its
config.ymlpresent before it starts. Ship a default with the deploy. - Putting a big library on the NAS is the right call, but a network mount needs
mountpoint -qverification, not a trusted exit code, or a backup job can quietly fill your container’s disk.
Related reading
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.
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.
Replacing Firefly III with Actual Budget
Why I swapped out a finance app I never opened for one I actually use, and how the Docker-in-LXC deployment on Proxmox went.
Ready to Transform Your Career?
Let's work together to unlock your potential and achieve your professional goals.