Skip to content

Configuration

Two layers. Environment variables cover what must be known before Romule starts. Everything else lives in the interface and is stored in _romule-config.json inside the service data folder.

Romule keeps two folders apart, and the distinction runs through this whole page:

  • the data folder belongs to the service — settings, accounts, cover art, logs, backups. It is fixed by your deployment and has no reason to move.
  • the library belongs to you — your games. It usually lives on another disk, and you pick it from the interface, not from a compose file.

By default the library is the data folder, so a single-folder install keeps working exactly as before.

Environment variables

Variable Default What it does
ROMULE_ROOT ~/.local/share/romule Service data folder: settings, accounts, artwork, logs
ROMULE_LIBRARY Pins the games folder and locks it — the interface can no longer change it
ROMULE_BASES Folders the interface may browse, separated like a PATH. Unset means everything the process can see.
ROMULE_WEB_PORT 8787 Port to listen on
ROMULE_BIND see below Interface to bind to
ROMULE_PUBLIC_HOST The address other machines reach Romule at, with a port when the published one differs (192.168.1.20, nas.local:9000). A container cannot discover its host's address — it only sees its own on Docker's network, which nothing routes — so without this it offers no network address rather than an unreachable one. See In a container.
ROMULE_TOKEN Access token; overrides the generated one
ROMULE_LAN 1 opens network access without a password
ROMULE_KEYS ~/.romule/prod.keys Path to the decryption keys
ROMULE_TRUSTED_PROXIES Addresses whose forwarded headers are honoured, comma-separated. CIDR is accepted (172.16.0.0/12), which is what you want under Docker where the proxy's address is assigned dynamically.
ROMULE_UPLOAD_MAX 64 GiB Largest accepted upload, bytes
ROMULE_DISK_MARGIN 2 GiB Free space kept in reserve, bytes
ROMULE_NO_BROWSER 1 stops Romule opening a browser at startup
ROMULE_TIMEOUT 300 Socket timeout, seconds
ROMULE_MAX_CONN 64 Simultaneous connections
ROMULE_RATE 600 Requests per minute per client
ROMULE_CHROME Chrome binary for the browser test suite
ROMULE_SCRYPT_PARALLELE 2 How many password hashes may run at once. scrypt deliberately costs ~128 MiB each; without a cap, a handful of parallel sign-in attempts would exhaust the server's memory and turn a protection into a lever.
ROMULE_LOG normal How much Romule writes to the terminalquiet, normal, verbose, debug, json. Unrelated to the Log panel in the interface: this is what docker logs and a systemd journal show. See Reading the logs.
ROMULE_LANG en The language of the terminal: the startup banner, the log, and the romule commands. English whatever the interface speaks — ROMULE_LANG=fr switches the terminal to French. See Reading the logs.
NO_COLOR Any value turns off colour, per the no-color.org convention. Colour is also off automatically when the output is not a terminal.
ROMULE_ADB adb on the PATH Path to the adb binary. A path that does not exist means “no console”, which is how the test suite stays independent of what is plugged in.

ROMULE_BIND defaults to 127.0.0.1, except in a container or once network access is enabled — otherwise a published port would reach nothing.

ROMULE_BASES is not a sandbox and is not set by default. In a container the real boundary is the volumes: list, applied by the kernel; on a bare install it is the Unix account the service runs as. Jellyfin, Sonarr and qBittorrent all work this way. Set ROMULE_BASES when you run natively under a broad account and want to narrow the browser anyway. When it is set, it bounds both what you can browse and what you can select — typing a path is not a way around it.

Old names still work

SWITCH_* variables are still read, and Romule prints their replacement at startup. They will be dropped in a later release.

In a container

A container cannot discover the address its host is reached at. The socket it opens answers with its own address on Docker's network — 172.18.0.2 — which is correct for the container and routed from nowhere else: not from the machine running it under Docker Desktop or Colima, and not from your console.

Romule used to print that address as the one to open at first start. The first thing a container install showed was therefore an address that does not answer.

It no longer guesses. Without ROMULE_PUBLIC_HOST it says the published port and stops there, and the button that opens the interface on the console's screen stays off with the reason given. From the machine hosting the container, http://localhost:8787 works — it is the published port.

To have Romule know the address, state it:

environment:
  ROMULE_PUBLIC_HOST: "192.168.1.20"     # your machine, not the container
  # ROMULE_PUBLIC_HOST: "nas.local:9000" # with a port when it differs

Reading the logs

Two logs answer two different questions, and they are not the same log.

The Log panel in the interface tells whoever is looking at their library what it is currently doing. The terminal is where you find out why a service will not start, on a machine where nobody can open a browser — a container, a NAS, an ssh session. ROMULE_LOG controls the second one only.

The terminal speaks English, whatever the interface speaks. Following the interface's language setting sounded right and produced worse: the banner printed in English at startup, then the first person to pick French in the wizard flipped every later line — one log, two languages, unreadable to both readers. A log is a technical document with a stable audience; the interface is not. ROMULE_LANG=fr switches the terminal to French, deliberately and for good.

Value What you get
quiet Errors only
normal (default) The startup banner, the facts it lists, warnings and errors
verbose Plus every task event, timestamped
debug Plus each HTTP request with its status and duration, the module, the thread, and seconds since startup
json One JSON object per line, for a log collector
docker compose logs -f romule                  # whatever the style is set to
ROMULE_LOG=debug python3 -m romule serve       # when something is wrong

verbose deliberately hides debug

The interface polls /api/job continuously while a task runs, and those requests are logged at debug. A verbose that showed them would bury the task events under dozens of lines a second — that is, make unreadable exactly what you opened it to read.

The startup banner is not decoration. It answers, before you go looking: which version is actually running, where it keeps your settings as opposed to your games, who may connect, which external tools it found, and where the log file is. Each of those lines is a question that otherwise costs half an hour.

Settings

All of these are edited from the interface. The names are the keys stored in _romule-config.json; you should not normally need to edit that file by hand.

Access

Key Default Meaning
auth_mode aucun aucun, interne (accounts), or oidc (beta)
lan_access false Allow the network in without a password
maj_check true Ask GitHub once a day whether a newer version exists. This is the only time Romule reaches the internet without being asked; turn it off and it never does.
maj_vue The version whose release note has been acknowledged from the “Update” pill. Server-side rather than in the browser: a reminder dismissed on the laptop that comes back on the phone is the same reminder. The pill returns on its own at the next version.
notif_destinations [] Outgoing notification destinations. Set from Settings → Access, not here: the address is checked and the count is capped.
auth_secret generated Signing key for session cookies. Never leaves the server.
jeton_auto A token set by romule token reset, kept so it survives restarts. Nothing generates one any more: an unclaimed installation opens on its assistant instead — see A token, if you want one.
oidc_issuer Provider URL
oidc_client_id / oidc_client_secret Client credentials
oidc_redirect Redirect URI registered with the provider
oidc_scopes openid profile email Scopes requested
oidc_emails / oidc_groupes Restrict who may log in
oidc_admin_groupes Groups whose members may administer. Empty: nobody does.

Two different questions

oidc_groupes decides who gets in. oidc_admin_groupes decides who may open Settings and manage the tool. Confusing them would hand administration to everyone who can log in.

The role is read from the token at sign-in, so removing someone from a group demotes them at their next session, not in the middle of the current one. See Roles and access.

About

The last settings tab: the installed version and whether a newer one exists, its release notes, the source repository and its releases — the AGPL wants a reachable instance's code accessible — and the tools the server found (adb, nsz, prod.keys).

Your console

Key Default Meaning
emulateur eden Profile deciding all paths on the console
emulateur_paquet Android package, detected from the console
device_dir /storage/emulated/0/Switch Switch games folder on the console
roms_root Parent folder of the other platforms. Empty: derived from device_dir.
wifi_addr Console address, remembered after pairing
push_layout type type sorts into GAMES/UPDATE/DLC; plat keeps it flat
saves_dir Where game saves are backed up
auto_nand false Install into the emulator's NAND automatically

Backups

Set from Settings → Backups, through the dialog. See Backups.

Key Default Meaning
backup_sources ["sauvegardes"] What gets copied: sauvegardes, jeux, maj, dlc, jaquettes, config
backup_dest Where the batches land. Empty means nothing is backed up anywhere. Must be ABSOLUTE, and is refused if it falls outside ROMULE_BASES.
backup_keep 5 How many batches are kept. Past that, the oldest goes — an interrupted one before a complete one.

Library

Key Default Meaning
library_path The folder scanned for games. Empty means the data folder. Set from Settings → Your library → Location, not by hand.
local_layout type Same idea, on the server side
systemes_perso [] Extra platforms you define yourself
system_dirs {} Folder overrides per platform
trash_days 0 Days before the trash empties itself. 0 never.
verify_mode size size compares size and date; hash fingerprints the contents
incremental true Only re-read what changed
jobs 3 Parallel conversions
versions_urls titledb Mirrors for the Switch version database

On its own

Romule does nothing unasked until you tell it to. These two keys are what change that, and only the first is yours to set.

Key Default What it does
schedule {} What runs on its own: {task: preset}. The tasks are scan, import, convert, push, meta; the presets are never, startup, hourly, 6h and nightly:HH. Set from Settings → Maintenance → On its own.
schedule_state {} When each scheduled task last ran. Written by Romule, never by hand: without it a restart makes every nightly task due again.

Several consoles

“Ajouter une console” asks for no name: it opens the connection straight away — cable or Wi-Fi — on a blank entry, and the console takes the name it announces (“AYN Thor”) on first contact. Each console keeps its own settings: selecting one under “Console pilotée” shows its own.

An Odin and a Retroid are two consoles, not one setting overwritten twice.

Key Default What it does
devices one entry The consoles you own. Each carries its own serial, wifi_addr, emulateur, emulateur_paquet, device_dir, roms_root, push_layout and auto_nand.
active_device the first Which one Romule is driving.

Those eight settings also stay at the top level of the file, mirroring the active console. That is deliberate: it is what lets a version that predates this feature read your pairing where it left it, and it is what kept the seventy-odd places in the code that read device_dir working unchanged.

The first console is built from your existing settings, once, without asking.

Only reversible tasks can be scheduled. Emptying the trash, clearing the log and revoking a key are absent from the list, and that is not an oversight: an unattended action must be one whose result you can still look at in the morning.

If a task is already running when another falls due, the due one is skipped and said so in the log — it is not queued. Romule does one thing at a time.

Covers and details

Key Default Meaning
cover_provider nlib nlib, steamgriddb, or custom
cover_url nlib template Used when cover_provider is custom. {tid} is substituted.
steamgriddb_key SteamGridDB API key
igdb_client_id / igdb_client_secret IGDB credentials. Used for summaries and, when the chosen provider has no artwork, as a second cover source.
meta_lang en Language for titles and summaries
emuready false Community compatibility ratings (beta)
emuready_device Which device to match ratings against
emuready_device_nom Its display name, remembered so the list need not be fetched again

Why covers have two sources

SteamGridDB is a community artwork database — rich on what gets played with a keyboard, thin on handheld console catalogues. IGDB is a game database, and it publishes cover art too. Romule already asked it for summaries; it now asks for artwork as well, but only after the chosen provider has failed to return an image — not merely an address, since a URL that answers 404 is still a URL.

Both sources go through the same matching rule: a candidate has to cover two thirds of the distinctive words of the title. A cover that belongs to another game is worse than no cover at all.

Interface

Key Default Meaning
ui_lang en en or fr. Adding a language is a JSON file — see Contributing.
notify true Notify when a job finishes

Who can open this page

The setting lan_access is no longer a switch in the interface. Access is settled once, on the wizard's access step — an account, or no password — and the settings screen now READS that decision back rather than offering a second place to change it. Two places deciding the same thing is how an installation ends up open by accident.

To change it afterwards: the wizard reopens from the same row, and the terminal always works — see The first access.

Notifications

Romule could already tell you a task had finished — but only the person sitting in front of it, with a desktop notification. What it does takes time: a thirty-file conversion, a multi-gigabyte transfer. Those are precisely the moments when you are not in front of the screen.

Settings → Access → Notifications. Paste a webhook address, give it a name, done. The service is worked out from the address:

Service What to paste
Discord https://discord.com/api/webhooks/… — Server settings → Integrations → Webhooks
Slack https://hooks.slack.com/services/… — an Incoming Webhook
Telegram https://api.telegram.org/bot<token>/sendMessage?chat_id=<id>
ntfy https://ntfy.sh/your-topic, or your own instance
Gotify https://gotify.example.com/message?token=…
Anything else Any URL — Romule POSTs a plain JSON object

Each destination gets its own block: the service Romule recognised, a switch to silence it without removing it, its events, a Test the connection button whose answer stays in that block, and a link to that service's own instructions. A verdict shown as a toast could not say which of four channels had answered — and it faded before you had read it.

Each destination can be tested before or after saving, and the result says which side refused: a wrong address and a service that is down do not look the same.

Which events, per destination

Every destination has its own boxes. A family channel can be told only when a transfer is over; an ops channel can be told about everything.

Event Fires when
A task finished any task at all ended well
A task failed any task at all failed or was interrupted
A transfer to the console finished a push, a deploy or a pull ended — either way
A conversion finished an NSZ conversion ended, either way
The drop folder was filed files from _import were sorted into place
The game entries were refreshed titles, summaries and covers were fetched
An integrity check finished a fingerprint pass ended
The console connected or disconnected the link changed, and only when it changes
A newer version exists once per released version, not once per page load

The first two are catch-alls: tick a task finished and you hear about every kind of task, without ticking six boxes. The specific ones fire whatever the outcome, because someone watching for the end of a 12 GB transfer wants to know either way — the message's colour says which.

A destination that has ticked both a specific event and its catch-all is told once, not twice. A destination with nothing ticked receives everything, which is what pasting an address without touching the boxes should mean.

A webhook address is a bearer secret

Whoever holds it can post in your channel. Romule therefore never sends it back — the interface shows only the host, which is enough to tell two destinations apart. The address is not in the API responses, not in the log, and not in romule doctor output. Managing notifications is administrator-only, and so is testing one: an endpoint that fetches an arbitrary URL on demand is a port scanner by proxy.

Nothing is sent when nothing is configured. A self-hosted service that reaches outward on its own is a problem, not a feature.

Debugging from the terminal

These commands exist for the moment when the interface is not the answer: no password left, no second factor, a service that will not start, or a container with no browser. Until they existed, the only way out was editing _romule-comptes.json by hand — pasting an scrypt hash computed elsewhere, which nobody gets right the first time.

They grant nothing new. Whoever can run romule already has the service's rights, and therefore its files. They only make doable, without mistakes, what the filesystem already allowed.

romule doctor                              # everything a bug report should contain
romule user list
romule user passwd you@example.com         # asks twice, no echo
romule user admin you@example.com          # grant administration
romule user admin you@example.com --retirer
romule user totp-off you@example.com       # lost phone
romule user rm someone@example.com --oui
romule config list                         # secrets shown as "(n characters, masked)"
romule config get auth_mode
romule config set trash_days 7

Under Docker, prefix with docker compose exec romule python3 -m romule.

romule user passwd resets a password without knowing the old one. That is why it exists only here and never as an HTTP route: a reset without proof of identity is exactly what an attacker wants. It also invalidates every open session for that account, and clears the failure counter — a lockout from repeated wrong attempts would otherwise survive the reset and make the new password look broken.

romule doctor is what to paste into an issue

Version, paths and their permissions, which port is taken, which external tools are on PATH, which remote services are configured, how many accounts and administrators, and the library breakdown per platform. It contains no password, no key, and no webhook address — that is checked by a test, not by intention.

Every one of these commands exits non-zero when it refuses. That sounds obvious; it was not true when they were written, and a test caught six perfectly worded refusals all reported as success.

Where the files live

Everything Romule writes is prefixed with _, and lands in one of the two folders.

In the data folder (ROMULE_ROOT):

File What it holds
_romule-config.json The settings above. chmod 600.
_romule-comptes.json Accounts: scrypt hashes and TOTP secrets
_romule-lib.log Activity log, rotated at 2 MiB, 3 files kept
_romule-acces.log Access log
_covers/ Cached cover art
_sauvegardes/ Automatic config and account backups

Next to your games (library_path):

File What it holds
_import/ Drop files here to import them
_corbeille/ Trash

These two follow the games rather than the service on purpose. Setting a game aside has to stay a rename: across two filesystems shutil.move copies instead, which turns discarding one title into several gigabytes of I/O.

Back these up

_romule-config.json and _romule-comptes.json are your settings and your accounts. They are not recoverable from anywhere else.