Skip to content

Automating RomM Setup with Ansible (No GUI Required)

How to fully automate RomM deployment — including first-run admin user creation, metadata source configuration, and PocketID OIDC — without ever touching the setup wizard.

The Problem

RomM's first-run experience requires you to:

  1. Open the web UI and go through the setup wizard
  2. Create an admin account manually
  3. Configure metadata scan priorities

This is fine for a one-off setup, but if you're managing infrastructure as code (IaC), you want every deployment to be repeatable and hands-free. This guide shows how to automate all of it via the RomM API and environment variables.

Prerequisites

  • Docker and Docker Compose
  • A MariaDB/MySQL database
  • (Optional) A PocketID instance for OIDC SSO
  • (Optional) Ansible for full automation

Step 1: Skip the Setup Wizard

RomM supports disabling the setup wizard via environment variable:

environment:
  - DISABLE_SETUP_WIZARD=true

This prevents the wizard from appearing on first access, but you still need to create an admin user.

Step 2: Preseed Metadata Configuration

RomM reads metadata scan priorities from a config.yml file. Mount a config directory and write this file before the container starts:

# /path/to/config/config.yml
scan:
  priority:
    metadata:
      - igdb
      - moby
      - ss
      - ra
      - launchbox
      - gamelist
      - hasheous
      - tgdb
      - flashpoint
      - hltb
    artwork:
      - igdb
      - moby
      - ss
      - ra
      - launchbox
      - gamelist
      - hasheous
      - tgdb
      - flashpoint
      - hltb
    region:
      - us
      - wor
      - ss
      - eu
      - jp
    language:
      - en
      - fr
  media:
    - box2d
    - screenshot
    - manual

Mount it in your docker-compose:

volumes:
  - /path/to/config:/romm/config

This is the same configuration you'd set through the GUI's metadata priority screen, but written as a file so it's ready on first boot.

Step 3: Create Admin User via API

RomM has a special behavior: when no admin users exist, the POST /api/users endpoint is unauthenticated. This is the same mechanism the setup wizard uses internally.

After the container is healthy, create the admin user:

curl -s -o /dev/null -w "%{http_code}" \
  -X POST "http://localhost:8080/api/users" \
  -H "Content-Type: application/json" \
  -d '{
    "username": "admin",
    "email": "[email protected]",
    "password": "your-secure-password",
    "role": "admin"
  }'
  • Returns 201 on success (first run)
  • Returns 403 if an admin already exists (idempotent — safe to retry)

Important: RomM returns an empty body on 201 Created. Don't try to parse the response as JSON — just check the HTTP status code.

Why curl instead of Ansible's uri module?

We discovered that Ansible's uri module can mangle passwords during JSON serialization, particularly passwords containing special characters. The BCrypt hash comparison then fails with "Invalid credentials." Using shell with curl avoids this issue entirely.

Step 4: Configure PocketID OIDC

RomM has built-in OIDC support via environment variables. Here's the complete configuration for PocketID:

environment:
  - OIDC_ENABLED=true
  - OIDC_PROVIDER=pocketid
  - OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
  - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
  - OIDC_REDIRECT_URI=https://romm.example.com/api/oauth/openid
  - OIDC_SERVER_APPLICATION_URL=https://auth.example.com
  - OIDC_SERVER_METADATA_URL=https://auth.example.com/.well-known/openid-configuration
  - OIDC_ISSUER_URL=https://auth.example.com
  - DISABLE_USERPASS_LOGIN=true  # Optional: force OIDC-only login

PocketID Client Configuration

In PocketID, create an OIDC client with:

  • Callback URL: https://romm.example.com/api/oauth/openid
  • PKCE: Disabled

Gotchas

  1. OIDC_PROVIDER must be lowercase — use pocketid, not PocketID.

  2. OIDC_SERVER_METADATA_URL is critical — without it, RomM falls back to constructing the discovery URL from OIDC_SERVER_APPLICATION_URL, which can point to the wrong place. Always set it explicitly to your PocketID's /.well-known/openid-configuration endpoint.

  3. The callback endpoint is /api/oauth/openid — not /api/oauth/openid-callback. This is the actual route in RomM's source code (endpoints/auth.py).

  4. Email must be verified — RomM checks the email_verified claim when PocketID advertises it in claims_supported. If your PocketID user has emailVerified: false, you'll get "Email is not verified." Fix it via the PocketID API:

    curl -X PUT "https://auth.example.com/api/users/<user-id>" \
      -H "X-API-Key: <pocketid-api-key>" \
      -H "Content-Type: application/json" \
      -d '{"emailVerified": true}'
    

  5. Admin user email must match — the email on your RomM admin account must match your PocketID user's email for account linking to work.

  6. OIDC users get VIEWER role by default — when a user logs in via OIDC for the first time, RomM creates them with VIEWER permissions regardless of any pre-existing account. If the OIDC login creates a new user record (e.g., because the admin was created with password auth but the OIDC login doesn't link to it), the user will have no upload or scan access. Fix it by enforcing the role in the database after deployment:

    docker exec mariadb mariadb -u<user> -p'<pass>' <db> \
      -e "UPDATE users SET role='ADMIN' WHERE username='holo' AND role != 'ADMIN';"
    
    The Ansible playbook includes this step automatically.

  7. RAHasher exits with code 1 on success — RAHasher 1.8.1 (bundled in the RomM container) always exits with code 1 even when it successfully computes a hash. RomM treats non-zero exit codes as failures and discards the hash, so RetroAchievements matching silently fails for all ROMs. The playbook patches /backend/adapters/services/rahasher.py inside the container to make the exit code check non-fatal. This patch is reapplied on every deployment since it lives inside the container image. Remove once upstream fixes this.

Complete docker-compose.yml

services:
  romm:
    image: rommapp/romm:latest
    container_name: romm
    environment:
      - DB_HOST=your-db-host
      - DB_PORT=3306
      - DB_NAME=${DB_NAME}
      - DB_USER=${DB_USER}
      - DB_PASSWD=${DB_PASSWORD}
      - ROMM_AUTH_SECRET_KEY=${ROMM_AUTH_SECRET_KEY}
      - IGDB_CLIENT_ID=${IGDB_CLIENT_ID:-}
      - IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET:-}
      - OIDC_ENABLED=true
      - OIDC_PROVIDER=pocketid
      - OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
      - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
      - OIDC_REDIRECT_URI=https://romm.example.com/api/oauth/openid
      - OIDC_SERVER_APPLICATION_URL=https://auth.example.com
      - OIDC_SERVER_METADATA_URL=https://auth.example.com/.well-known/openid-configuration
      - OIDC_ISSUER_URL=https://auth.example.com
      - DISABLE_USERPASS_LOGIN=true
      - DISABLE_SETUP_WIZARD=true
      - TZ=America/New_York
    ports:
      - "8080:8080"
    volumes:
      - ./roms:/romm/library/roms
      - ./bios:/romm/library/bios
      - ./assets:/romm/assets
      - ./config:/romm/config
      - romm_resources:/romm/resources
      - romm_redis_data:/redis-data
    restart: unless-stopped

volumes:
  romm_resources:
  romm_redis_data:

Ansible Automation Summary

The full Ansible playbook follows this sequence:

  1. Fetch secrets from your secret store (Vault, etc.)
  2. Write config.yml with metadata scan priorities
  3. Deploy the container with OIDC env vars and DISABLE_SETUP_WIZARD=true
  4. Wait for health checkGET /api/heartbeat returns 200
  5. Create admin userPOST /api/users with curl (idempotent)

The entire process is idempotent — running it again on an already-configured instance skips the user creation step.

API Reference

Endpoint Method Auth Purpose
/api/heartbeat GET None Health check
/api/users POST None (first run only) Create admin user
/api/oauth/openid GET OIDC callback OIDC redirect target

References