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:
- Open the web UI and go through the setup wizard
- Create an admin account manually
- 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
201on success (first run) - Returns
403if 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
-
OIDC_PROVIDERmust be lowercase — usepocketid, notPocketID. -
OIDC_SERVER_METADATA_URLis critical — without it, RomM falls back to constructing the discovery URL fromOIDC_SERVER_APPLICATION_URL, which can point to the wrong place. Always set it explicitly to your PocketID's/.well-known/openid-configurationendpoint. -
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). -
Email must be verified — RomM checks the
email_verifiedclaim when PocketID advertises it inclaims_supported. If your PocketID user hasemailVerified: 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}' -
Admin user email must match — the email on your RomM admin account must match your PocketID user's email for account linking to work.
-
OIDC users get VIEWER role by default — when a user logs in via OIDC for the first time, RomM creates them with
VIEWERpermissions 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:The Ansible playbook includes this step automatically.docker exec mariadb mariadb -u<user> -p'<pass>' <db> \ -e "UPDATE users SET role='ADMIN' WHERE username='holo' AND role != 'ADMIN';" -
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.pyinside 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:
- Fetch secrets from your secret store (Vault, etc.)
- Write
config.ymlwith metadata scan priorities - Deploy the container with OIDC env vars and
DISABLE_SETUP_WIZARD=true - Wait for health check —
GET /api/heartbeatreturns 200 - Create admin user —
POST /api/userswith 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 |