Discourse: Pitfalls When Bulk-Creating VM Accounts and API Keys

Background

We need to provision one independent forum-cli account and one Single User API key for each of 8 VMs (devops, kali, elementary, studio, ailab, translate, debian, modiqi) on wpcommunity.com.

This sounded straightforward—but we hit several pitfalls along the way. Here’s a record of them.

Pitfall 1: MCP Discourse Tool Returns 404 for Some Users

Using Discourse MCP’s get_user command to query the 8 usernames, only modiqi returned successfully; all others yielded 404.

Yet when we checked via Rails runner with User.where(username: ...), we confirmed that 7 of the 8 users actually exist (only kali was missing).

Root Cause: These bot/system users likely have non-public profiles—or the MCP tool uses a public API endpoint instead of an admin one.

Lesson Learned: Don’t blindly trust MCP’s 404 responses. For bot or system users, always double-check using Rails runner or the admin API.

Pitfall 2: Single User API Keys Lack Admin Privileges

Wenpai’s API key is scoped to Single User, with admin: false. It cannot be used to create users or generate new API keys via the API.

Even attempting to use the username system with the same key fails outright with invalid_access.

Conclusion: Admin-level operations—such as creating users or managing API keys—must be performed via SSH → Docker → Rails runner. Given their infrequent nature, distributing a global admin API key isn’t justified.

Pitfall 3: The Escaping Nightmare of SSH + Docker + Rails Runner

Running Ruby code via ssh prod-b "docker exec app su discourse -c '...'" involves three layers of nested quoting—and escaping mistakes are easy to make.

Especially when mixing Ruby’s save!, string interpolation (#{}), and heredoc syntax, Bash may interpret ! as history expansion and escape it to \!, causing Ruby syntax errors.

Solution: Avoid inline Ruby entirely. Instead:

# Write the script locally
scp script.rb prod-b:/tmp/
ssh prod-b "docker cp /tmp/script.rb app:/tmp/ && \
  docker exec app chown discourse:discourse /tmp/script.rb && \
  docker exec app su discourse -c \
    'cd /var/www/discourse && RAILS_DB=default bundle exec rails runner /tmp/script.rb'"

This is currently the most robust approach—fully sidestepping quoting and escaping issues.

Pitfall 4: User#approve No Longer Exists in Newer Discourse Versions

Calling user.approve(Discourse.system_user) after user creation raises undefined method 'approve'.

In newer Discourse versions (Rails 8 + Discourse 3.x), the approval logic has changed. Simply set the relevant attributes during creation:

user = User.create!(
  username: "kali",
  email: "[email protected]",
  password: SecureRandom.hex(16),
  active: true,
  approved: true,
  trust_level: 0
)
user.activate

Pitfall 5: ApiKey#key Is Only Readable Immediately After Creation

This is the sneakiest one. Discourse’s ApiKey model enforces strict access control on the key field:

API key is only accessible immediately after creation (ApiKey::KeyAccessError)

That means you cannot retrieve the plaintext key later—even via Rails—because only its hash is stored in the database.

Workaround: To obtain the plaintext key, first destroy all existing keys for that user, then create a new one and read key immediately:

ApiKey.where(user_id: user.id).destroy_all
api_key = ApiKey.new(user_id: user.id, description: "...", created_by_id: -1)
api_key.save!
raw_key = api_key.key  # This is your *only* chance to read the plaintext key

Pitfall 6: Rails Runner Must Run as the discourse User

Running docker exec app rails runner ... as root triggers a PostgreSQL role error. You must switch to the discourse user:

docker exec app su discourse -c 'cd /var/www/discourse && bundle exec rails runner ...'

In multi-site environments, don’t forget to set RAILS_DB=default.

Final Outcome

VM User ID Status
devops 34 Existing
kali 44 Newly created
elementary 38 Existing
studio 39 Existing
ailab 40 Existing
translate 36 Existing
debian 43 Existing
modiqi 2 Existing (admin)

All 8 Single User API keys have been generated and saved to /mnt/shared-context/secrets/wpcommunity-vm-api-keys.json.

Pitfall 7 (Most Critical): Incorrect RAILS_DB for Multi-Site Setup

After falling into all six previous pitfalls, the API keys were distributed to each VM—but then weixiaoduo returned HTTP 403—even GET /categories.json failed.

Troubleshooting steps:

  • User status was normal (active, approved, tl=2, not suspended)
  • The key had not been revoked; scopes were empty (i.e., full permissions)
  • Regenerating a Global API key still resulted in 403
  • curl -v revealed Discourse’s response: Invalid API username or key

Root cause identified: wpcommunity.com uses a dedicated database in Discourse’s multi-site configuration.

# config/multisite.yml
wpcommunity:
  adapter: postgresql
  database: wpcommunity_discourse
  host_names:
    - wpcommunity.com
    - www.wpcommunity.com
RailsMultisite::ConnectionManagement.all_dbs
# => ["default", "wpcommunity"]

All prior operations used RAILS_DB=default, so users and keys were created exclusively in the default database (meta.cyberforums.com). However, requests to wpcommunity.com route to the wpcommunity database—which contained none of those users or keys—hence the 403.

Fix: Re-created all 7 missing VM users and 9 Global API keys using RAILS_DB=wpcommunity. Verification succeeded.

The user IDs listed in the “Final Result” table in the original post (e.g., devops=34, kali=44) belong to the default database. IDs in the wpcommunity database are entirely different.

Does forum-cli need modification?

No code changes required. forum-cli merely uses an API key and URL to call endpoints—it has no awareness of or dependency on databases. Both sites’ .conf files are correctly configured:

Configuration file Site Database
.forum-cli.conf meta.cyberforums.com default
.forum-cli-wpcommunity.conf wpcommunity.com wpcommunity

The issue lies solely in the API key creation process, not in forum-cli.

Prevention Checklist

Before running any Rails runner command on this Discourse instance, first execute:

puts RailsMultisite::ConnectionManagement.all_dbs

Then select the correct RAILS_DB based on the target domain:

  • meta.cyberforums.comRAILS_DB=default
  • wpcommunity.comRAILS_DB=wpcommunity

If uncertain, consult the host_names mapping in config/multisite.yml.

Standard procedure when provisioning new VMs:

  1. Confirm the RAILS_DB corresponding to the target site
  2. Create the user and API key using the correct RAILS_DB
  3. Immediately verify via curl (GET /categories.json)—don’t wait for errors from the VM side.