Skip to content

Installation

Romule is one Python process with no runtime dependencies. Whatever route you take, there is no database to provision, no message queue, no build step.

Route Good for
Docker Compose almost everyone — NAS, mini-PC, home server
Docker run a quick trial, or an existing orchestrator
From source development, or a machine without a container runtime
As a package a system-wide install with pipx

Nothing to clone. Put this in a docker-compose.yml, change the one volumes: line that points at your games, and start it:

services:
  romule:
    image: ghcr.io/romule-app/romule:latest
    container_name: romule
    restart: unless-stopped
    ports:
      - "8787:8787"
    environment:
      ROMULE_ROOT: /data
      ROMULE_BASES: /library
    volumes:
      - romule-data:/data
      - /path/to/your/games:/library      # ← the only line to change

volumes:
  romule-data:
docker compose up -d
docker compose logs romule      # prints the address to open

Open it and answer the wizard: an account, or no password. Until you do, the installation answers everybody — that is what makes this screen reachable from your phone rather than only from the machine running the container. The image ships adb, nsz, unar and 7z, so nothing else needs installing.

The same file, with every option

The one above is the minimum. This is the same file with everything you might want to set, commented — the repository ships it as docker-compose.yml, with build: . in place of image: because someone who cloned the repository wants to run what they just read.

services:
  romule:
    image: ghcr.io/romule-app/romule:latest    # or, from a clone: build: .
    container_name: romule
    restart: unless-stopped

    ports:
      - "8787:8787"

    environment:
      # Romule's own state, inside the container. Do not change.
      ROMULE_ROOT: /data
      # Where the interface may browse for games. The exact folder is chosen
      # from the interface on first run — mount the parent and pick inside.
      ROMULE_BASES: /library
      ROMULE_WEB_PORT: "8787"
      TZ: Europe/Paris

      # Nothing else is required. On first start the installation is
      # unclaimed: it answers everybody and opens on its wizard, whose access
      # step settles the question. From the terminal:
      #     docker compose exec romule python3 -m romule access status
      #     docker compose exec romule python3 -m romule user create you@ex.com
      #
      # Prefer a shared secret instead of an account?
      #
      # To pin your own instead:
      #     python3 -c "import secrets; print(secrets.token_urlsafe(32))"
      # ROMULE_TOKEN: ""
      #
      # Trusted network and nobody else on it? You can do without a token —
      # but then every device on the LAN has every right.
      # ROMULE_LAN: "1"

    volumes:
      - romule-donnees:/data      # named volume: settings, accounts, artwork
      - ./library:/library        # ← your games
      - ./keys:/keys:ro           # ← the folder holding prod.keys (optional)

    # The image already runs as uid 1000. If yours differs (`id -u`), set it
    # here so dropped files belong to you and not to root.
    # user: "1000:1000"

volumes:
  romule-donnees:

What to mount

Container path What goes there
/data Romule's own state: settings, accounts, artwork, logs. A named volume — these files are not yours the way your games are, and they have no business sitting among them.
/library Your games. Romule writes only _import/ and _corbeille/ alongside them.
/keys The folder holding prod.keys, read-only. Optional — needed only for .nsz / .xcz.

You are not required to mount the games folder exactly. Mount whatever contains it — a whole disk, a share, a parent folder — and pick the right one from the interface. That is what ROMULE_BASES: /library means: browse anywhere under the mount, and nowhere else.

    environment:
      ROMULE_BASES: /library
    volumes:
      - /mnt/nas:/library      # then choose /library/switch in the interface

To pin the folder instead and stop anyone changing it from the interface, set ROMULE_LIBRARY: /library.

Networking

The default file uses ordinary bridge networking with a published port. This works everywhere, including Docker Desktop on macOS and Windows. adb over Wi-Fi works in this mode: the container reaches your local network, and you enter the console's address once.

ports:
  - "8787:8787"

Needed only for the console to be discovered automatically over mDNS.

network_mode: host

This weakens container isolation. Enable knowingly.

devices:
  - /dev/bus/usb:/dev/bus/usb

File ownership

The image runs as uid 1000, the first account on most distributions, so files land owned by you rather than by root. If your uid differs (id -u), set user: "<uid>:<gid>".


Docker run

Same thing without a compose file:

docker run -d --name romule --restart unless-stopped \
  -p 8787:8787 \
  -e ROMULE_ROOT=/data \
  -e ROMULE_BASES=/library \
  -v romule-donnees:/data \
  -v /mnt/games:/library \
  ghcr.io/romule-app/romule:latest

docker logs romule              # the address to open

Available tags

latest follows the newest release. 0.4.0 pins an exact version and 0.3 follows its patches — pin one of those if you would rather choose when you upgrade. The image is multi-arch (amd64 and arm64) and needs no authentication.

docker pull ghcr.io/romule-app/romule:0.4.0

You can also build it yourself — docker compose up -d --build produces the same image from the sources, and is the honest answer if you would rather not run a binary you did not build.


From source

Python 3.10 or newer. No install step, no virtualenv, no build.

git clone https://github.com/romule-app/romule
cd romule
ROMULE_ROOT=/path/to/romule-data python3 -m romule

Romule refuses to start on a library root that is clearly wrong — the disk root, your home folder, a code repository — because it moves files and creates folders there.

Running it as a service

/etc/systemd/system/romule.service:

[Unit]
Description=Romule
After=network-online.target

[Service]
Type=simple
User=romule
Environment=ROMULE_ROOT=/var/lib/romule
Environment=ROMULE_LIBRARY=/srv/games
Environment=ROMULE_BIND=127.0.0.1
ExecStart=/usr/bin/python3 -m romule serve --no-browser
WorkingDirectory=/opt/romule
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now romule
journalctl -u romule -f

~/Library/LaunchAgents/fr.romule.plist, then launchctl load ~/Library/LaunchAgents/fr.romule.plist:

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
  <key>Label</key><string>fr.romule</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/bin/python3</string><string>-m</string>
    <string>romule</string><string>serve</string><string>--no-browser</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict><key>ROMULE_ROOT</key><string>/Users/me/Library/romule</string></dict>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
</dict></plist>

External tools

All optional. A missing one disables a feature; none prevent startup. Romule tells you what is missing and how to install it on your platform.

Tool Needed for Debian/Ubuntu macOS
adb Talking to the console apt install android-tools-adb brew install android-platform-tools
nsz Converting .nsz / .xcz pipx install nsz brew install pipx && pipx install nsz
unar Unpacking .rar apt install unar brew install unar
7z Unpacking .7z apt install p7zip-full brew install p7zip

As a Python package

Romule is not on PyPI yet, so install it from the repository:

pipx install git+https://github.com/romule-app/romule
ROMULE_ROOT=/path/to/romule-data romule serve

It pulls no dependencies — the standard library is all it uses.


Behind a reverse proxy

Romule speaks plain HTTP. Anything reachable from the internet needs a proxy terminating HTTPS in front of it.

Name your proxy, or forwarded headers grant nothing

A proxy on the same host makes every request look like it comes from 127.0.0.1, and Romule grants local requests full access. It therefore ignores X-Forwarded-For unless you name the proxy yourself:

ROMULE_TRUSTED_PROXIES=127.0.0.1,::1

Without it, every user shares one apparent address and rate limiting degrades. See Security and exposure.

romule.example.com {
    reverse_proxy 127.0.0.1:8787
}

A complete stack — Caddy in front, Romule not published on the host — is in exemples/caddy/. CI stands it up on every change and checks that a forged X-Forwarded-For still gets you nowhere.

server {
    server_name romule.example.com;
    client_max_body_size 0;          # uploads are whole games
    location / {
        proxy_pass http://127.0.0.1:8787;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_request_buffering off;
        proxy_read_timeout 3600s;
    }
}
labels:
  - traefik.enable=true
  - traefik.http.routers.romule.rule=Host(`romule.example.com`)
  - traefik.http.routers.romule.tls.certresolver=le
  - traefik.http.services.romule.loadbalancer.server.port=8787

Whichever you use, raise the body size limit and the read timeout: a single upload can be several gigabytes and take minutes.


Updating

docker compose pull       # or: docker compose build --pull
docker compose up -d
git pull
# restart the service

Your state lives in the /data volume and survives. Romule tells you when a newer version exists — once a day, in the header, with the release notes. Turn it off in Settings → Access if you would rather it never asked.

Backing up

Everything Romule owns is in one folder — ROMULE_ROOT, or the /data volume:

docker run --rm -v romule-donnees:/data -v "$PWD:/out" \
  alpine tar czf /out/romule-data.tgz -C /data .

Romule also writes its own settings snapshots under Settings → Upkeep, which is enough to undo a bad configuration but is not a backup of your games.

Uninstalling

docker compose down -v          # -v also removes the state volume

Your games are untouched: they were only ever mounted. Nothing was written inside them except _import/ and _corbeille/, which you can delete.