This is the runbook. You already decided to leave XenForo for Discourse. The stay-or-migrate framework is the article that should have talked you out of it if add-on lock-in, staff skills, or /threads/title.12345/ equity said stay. The 2026 matrix is what you are swapping. The first-week Discourse guide is what you do after Latest is empty and clean. This article is the messy middle: a XenForo database in, a Discourse backup out, and a weekend of 301s.
It is also not a XenForo host move. Moving data/ and internal_data/ to a new PHP box is a different job. It is not the phpBB → XenForo cookbook. The official importer in that direction lives in XenForo’s customer-area add-on. The official importer in this direction does not live in XenForo at all.
Direction, said once so nobody files a ticket with XF support: XenForo does not ship an export-to-Discourse tool. Discourse ships a Ruby script, script/import_scripts/xenforo.rb, in the Discourse application. You run it against a local copy of the XenForo MySQL database from inside a Discourse container (or a staging Discourse that can see that database). XenForo’s own Importers add-on lists Discourse 3.x as a source into XenForo. That is the return ticket. It is not this trip.
Sources for what follows: the stock script on GitHub, Meta topic 45232 and the long-running “migrate to Discourse” threads, and community practice around import droplets. Where a number or a feature is not in those places, this article does not invent it.
What you are actually running
Stock xenforo.rb is a one-way, five-step importer. The execute method, in order:
import_usersimport_categoriesimport_postsimport_private_messagesimport_likes
That is the map. Everything else — resources, galleries, page nodes, custom thread fields, most add-on tables, prefix vocabularies, password hashes — is either flattened, skipped, or left for you to rebuild. Believe the script, not a sales page.
The run, once gems and paths are in place:
RAILS_ENV=production bundle exec ruby script/import_scripts/xenforo.rb
You will have installed mysql2 and php-serialize (the gem name is php-serialize; the script needs it for conversation payloads). The XenForo MySQL instance must be local to the import host. Remote MySQL over the WAN is how a million-post board becomes a multi-day timeout. Copy the dump. Restore it next to Discourse. Point the script at 127.0.0.1.
Avatars and attachments are files, not rows. The script expects:
| Asset | Stock path shape |
|---|---|
| Avatars | AVATAR_DIR/l/{id/1000}/{id}.jpg |
| Attachments | ATTACHMENT_DIR/{data_id/1000}/{data_id}-{file_hash}.data |
Those are XenForo’s on-disk conventions (data/ for public avatars and attachments, hashed filenames). If you customized internalDataPath or you never generated the l (large) avatar size, the script will import users with empty faces. Dry-run that before you announce a date.
Architecture of a safe import (never first-run on prod)
Community practice on Meta, repeated often enough to treat as operational law: do not run the first import against the Discourse your members will see on Monday.
The shape that finishes without a public incident:
- Freeze a XenForo snapshot. Database dump plus the
data/tree (avatars, attachments). Leave production XenForo running until the final cut so you are not racing new posts into a moving target. This is the same snapshot discipline the XF backup article already taught; you are just pointing the copy at a different product. - Stand up an import droplet that is not production. Community recommendations for a million-post board often land around 4 vCPU / 8 GB RAM. That is a community sizing note, not an official Discourse SLA. Smaller boards can use less. The constraint is MySQL next to Ruby, not a pretty hostname.
- Install Discourse on that box the official Docker way (or restore a clean hosted/self-hosted backup onto it). Create the categories-and-tags policy you will live with — not the 80-node tree. The importer will create categories from XF nodes; you will merge and retag afterwards. Do not pre-create a parallel museum and then wonder why you have two “General” rooms.
- Restore the XenForo MySQL dump locally. Mount or copy
AVATAR_DIRandATTACHMENT_DIRwhere the script can read them. Edit the path constants in the script (or the env the community wrappers use) so they match this box. - Run the stock script. Measure. Read the log. Fix mapping. If the category flatten is wrong, you restore Discourse from the pre-import backup and run again. That is what the backup is for.
- When the staging Discourse looks like a community you can staff, take a Discourse backup and restore it onto a clean production Discourse. Never “just keep using the import droplet” as prod unless you planned that hostname and those TLS names from day one.
Hosted Discourse is allowed as the destination. Official pricing in 2026 lists migration services on the Business plan (publicly around $500/month — re-check discourse.org/pricing; grids change). Hosted can also ingest a backup you built on a self-hosted import box. Data belongs to the customer; the backup is the portable object.
If someone proposes “SSH into production, point xenforo.rb at live XF MySQL, go home,” the answer is no. A half-imported user table plus a public signup form is how you get two Admins and a week of merge mail.
Inventory before you touch Ruby
Write this down. The importer will not.
1. What the stock script will take
| XenForo object | Stock Discourse result | Notes you must tell staff |
|---|---|---|
Valid, unbanned users (user_state = 'valid' AND is_banned = 0) |
Users | Banned accounts are skipped. If you need a ban archive, export it yourself. |
is_admin / is_moderator / is_staff |
Admin / moderator flags | Confirm the mapping on a sample of staff accounts. Do not assume every XF “staff” group bit survived. |
Avatars in the l/ tree |
Uploaded avatars | Missing l/{id/1000}/{id}.jpg → blank. |
| Nodes | Categories | Depth greater than 2 is flattened to tags on the nearest subcategory. Deep trees die as trees. |
| Visible threads and posts | Topics and posts | Hidden / deleted / moderated-unapproved content is not a first-class stock import. Visible only. |
| BBCode | Markdown | Quotes, [url], [img], lists, YouTube/media, USER. [color] is stripped. |
| Attachments on disk | Uploads | Path must match {data_id}-{file_hash}.data. |
| Conversations | Discourse personal messages | Requires the php-serialize gem. |
Reactions on posts (xf_reaction_content where content_type = 'post') |
Likes | Custom reactions are not first-class. A “Love” and a “Sad” both become a like if they landed in that table as post reactions. |
| Permalinks the script writes | board/{node_name}, threads/{thread_id} |
These are the hooks for 301s. They are not /forums/title.12/ and not /threads/title.12345/. |
Read the last row twice. XenForo’s pretty URLs are /threads/title.id/ and /forums/…. Discourse’s public topics are /t/slug/id. The importer does not preserve the XF string. It creates Discourse permalinks for two simpler patterns: board/{node_name} and threads/{thread_id}. Your 301 layer has to translate the old pretty URL into one of those, or into the new /t/… path after you look up the map.
2. What dies (say this in the staff email)
Stock xenforo.rb does not bring across:
- Page nodes. Those HTML rooms become nothing. If they were a knowledge base, you rebuild them as topics, a Doc Categories plugin later, or an external docs site.
- XenForo Resource Manager (XFRM). Resources are not topics. There is no stock “resource → something” step.
- XenForo Media Gallery (XFMG). Albums and media comments are not in
execute. - Custom thread fields. Prefix-adjacent structured data dies unless you write a mapping. Do not claim the stock script grew one.
- Most add-on tables. Credits, shops, applications, live server pings, donation logs — gone as products. The gaming architecture piece is the list of jobs you are abandoning or rewriting.
- Deep node trees as categories. Depth > 2 becomes tags on the nearest subcategory. An 80-node game tree will not look like an 80-category Discourse. That is a feature of Discourse and a migration cost.
- Prefixes, unless you map them. Stock does not treat XF prefixes as a first-class Discourse tag group. If prefixes were load-bearing (region, status, spoiler), plan a post-import tag pass or a community script. Label any such script community, not core.
- Passwords, honestly. Meta has asked whether hashes come across. This article will not invent a yes. Plan a password reset (or Discourse ID / social login) as the default member story. If a future script version documents hash import, read that version. Until a source in front of you says yes, tell members they will set a new password.
Banned users are skipped by the user_state / is_banned filter. If legal or safety work depends on a public “this account was banned” record, export that list from XenForo before you drop the snapshot.
3. Category policy before the flatten surprises you
Print the XF node tree. Mark each node:
- Category — a real room you will still moderate as a room.
- Tag — a cross-cutting marker (version, region, status, spoiler).
- Do not migrate — empty rooms, leftover events, the 2019 “Introductions (archive)”.
The stock flatten will turn depth > 2 into tags whether you planned it or not. Planning means you choose the tags instead of inheriting lfg-eu-weekend-pvp-old as a folksonomy. The beginner guide already argued for a short category list. Migration is the last moment you can refuse to import a museum.
Dry-run (do this even on a 5,000-post board)
A dry-run is not “run it on prod and hope.” It is a timed rehearsal with a restore button.
Pass 0 — count, do not import
On the XenForo snapshot, count the things the script will see:
SELECT COUNT(*) FROM xf_user WHERE user_state = 'valid' AND is_banned = 0;
SELECT COUNT(*) FROM xf_user WHERE is_banned = 1;
SELECT COUNT(*) FROM xf_node;
SELECT COUNT(*) FROM xf_thread WHERE discussion_state = 'visible';
SELECT COUNT(*) FROM xf_post WHERE message_state = 'visible';
Exact column names can vary with XF version and add-ons; the point is visible + valid, because that is the stock filter’s spirit. Write the numbers on the cutover sheet. After the import, Discourse’s user / topic / post counts should be in the same order of magnitude. A 40% miss is a path or a filter, not “Discourse is lighter.”
Also count XFRM resources, XFMG media, page nodes, and conversations. Those numbers are the “what we are leaving behind” slide for staff. If XFRM is 12,000 resources, you do not “just recategorize them next week.”
Pass 1 — users only, then stop and look
If you are using the stock script, execute always runs the five steps in order. You cannot honestly claim a first-class “users-only” flag unless you are on a community wrapper that checkpoints. Two legitimate options:
- Run the whole stock script against a tiny XF copy (one category, fifty users, a handful of PMs). Confirm avatars, staff flags, and markdown.
- Use a community enhanced script (notebook:
rahul-rakesh/xenforo-migration-scripts,xenforo_import_enhanced.rb) that adds keyset pagination,local.jsoncheckpoints, and a two-pass topics-then-replies flow. Label that community, not core. Read its README. Do not paste its flags here as if they werexenforo.rb.
Either way, the first human check is: can the admin accounts log in, and do they have the staff bit? If the mapping from is_admin failed silently, you will spend the next four steps as a regular user in a room you cannot configure.
Pass 2 — categories and the flatten
Import categories (or inspect them after a full run on the tiny copy). Open the deepest XF branch you still care about. Confirm it became either a subcategory or a tag on the nearest subcategory. If a whole game-mode tree collapsed onto one category with 40 tags, that is the script doing what it says. Fix the policy (merge categories, rename tags, delete junk) on Discourse after the import — or flatten the XF tree on the snapshot before a second run if you would rather control names on the XF side.
Permalink check, immediately:
- Visit
/board/{node_name}for a known forum. Discourse should resolve the permalink. - Visit
/threads/{thread_id}for a known thread id. Same.
If those 404, your 301 plan is fiction. Fix permalinks before you write nginx.
Pass 3 — posts, attachments, markdown
Pick ten threads that represent the ugly cases:
- A long quote nest.
- A post that is mostly
[img]and[url]. - A YouTube or media BBCode.
- A
[color]rainbow (expect it to flatten). - A post whose entire point is an attachment.
- A thread with a prefix you thought was sacred.
- A thread in a depth-3 node.
- A staff announcement with an
@user mention ([USER]). - Something you hid or soft-deleted on XF (confirm it did not come across).
- A thread whose pretty URL is bookmarked in your own browser.
Read them as a member, not as an admin. If attachments are zero-byte, ATTACHMENT_DIR is wrong. If every image is a raw BBCode leftover, the markdown converter missed a tag you actually use; that is a script patch or a post-import job, not a theme issue.
Pass 4 — PMs and likes
PMs fail in one boring way: the php-serialize gem is missing and conversations explode. Install the gem, restore, rerun. Do not “skip PMs and do them later” unless you are willing to tell every member their inbox is gone.
Likes: spot-check a popular post. Custom XF reactions will not arrive as custom reactions. If your culture is the reaction bar, say so in the comms. Discourse likes are likes.
Pass 5 — time the full snapshot
On the real-sized snapshot, run the whole job and write down wall-clock time. Cutover night is that number, plus Discourse backup, plus restore to prod, plus DNS/301 flip, plus a staff smoke test. If the import is eight hours, you do not start at 22:00 on a Sunday.
Permalinks and 301s (this is the SEO project)
Discourse topics live at /t/slug/id. XenForo threads live at /threads/title.id/ (and older /index.php?threads/… shapes if you never cleaned them). Those strings will not match. Search equity survives only if you 301 the old URL to the new topic.
What the stock importer gives you:
- A Discourse permalink
threads/{thread_id}→ the imported topic. - A Discourse permalink
board/{node_name}→ the imported category.
What it does not give you: a finished map from /threads/how-to-tune-unicorns.18442/ to /t/how-to-tune-unicorns/18442. The numeric id may or may not coincide with Discourse’s topic id. Do not assume it does. The permalink is the stable join: your edge (nginx, Apache, a small redirect app, or a CDN rule) should turn the XF pretty URL into /threads/{id} and let Discourse finish the hop.
A practical 301 design:
- Thread URLs. Extract the trailing id from
/threads/anything.{id}/(and from/threads/{id}/if you ever used that). 301 tohttps://new.example/threads/{id}. Discourse’s permalink table sends the client to/t/slug/discourse-id. - Forum / node URLs. Map
/forums/slug.{node_id}/and/forums/slug/to/board/{node_name}ifnode_nameis what the script stored. If your XF node names were ugly, fix names on Discourse after you confirm the permalink key, or keep a manual map. - Index, What’s New, members, resources, media, pages. These have no stock permalink. 301
/to/,/whats-new/to/latest(or/, if Latest is home), and send XFRM / XFMG / page-node URLs to a single honest hub topic that says the library moved or died. A 404 with a search box is better than a silent 200 on an empty category. - Query-string leftovers (
?threads/,/index.php). Fold them into the same extract-id rules. Do not leave them as 200s on a parked XF.
Build the rules against the staging Discourse that already has the permalink table. Curl twenty known-good XF URLs. Then curl twenty you expect to miss (a resource, a gallery image, a page node). The misses are the comms list, not a surprise on Monday.
Sitemaps and prerender are a later Discourse-SEO article. For cutover night you need 301s that resolve, allow index in robots txt left on (it defaults on), and no leftover XF still serving 200s on the same hostname.
Passwords, login, and the first-week identity story
Tell members, in writing, before cutover:
- They should expect to set a new password (or to use Discourse ID / Google / GitHub / Apple / Facebook if you enabled those). This article will not claim the stock script imported hashes.
- Email addresses are the join key. If two XF accounts shared an email, decide now which one is real.
- Banned accounts will not appear. If someone writes in “where is my ban appeal,” you have the XF export, not a Discourse user.
If you skipped SMTP on the import box, do not skip it on production unless you are deliberately a Discourse-ID site. Digests, mailing-list mode, and reply-by-email need SMTP via Admin → Email. The beginner guide already covered skip-SMTP as a day-one option. A migrated community that lived in inbox notifications will treat missing mail as a broken forum.
SSO (DiscourseConnect) is not an import step. If the parent site must own identity, finish the import, confirm users exist, then attach SSO from the official Meta topic. Misconfigured SSO on a half-imported user table is how you lock staff out of the only copy.
Staff communications (treat this as a deliverable)
A technically perfect import with a surprised moderator team is a failed import. Budget calendar, not vibes.
T-minus 3 weeks. Send the inventory: what comes across, what dies, the password story, the URL story. Link the pros and cons so veterans can be angry at the right article. Name a “tree ambassador” whose job is to mark nodes as category / tag / drop.
T-minus 2 weeks. Give staff accounts on the staging Discourse. They use it as members first. Glossary on a pinned topic: topic not thread, category not forum, tag not child board, personal message not conversation, flag not report, TL4 not “admin.” The trust-level moderation model is the piece they need after they can log in.
T-minus 1 week. Freeze feature requests. Announce the read-only window. Publish the 301 examples (“your bookmark to thread 18442 will land here”). Say XFRM/XFMG/page nodes by name if you had them.
Cutover window. XF to registration-closed / read-only. Final snapshot. Import or restore the already-imported Discourse backup to prod. Flip DNS / vhost. Enable 301s. Staff smoke test: login, avatar, a known thread, a PM, a flag, a category they should not see.
T-plus 48 hours. Office hours on the new Latest. One staff human answering “where did my forum go” without irony. Do not vanish into plugin shopping.
Do not post the cutover announcement as root Admin if you can avoid it. The same SF-2 habit that applies on XenForo applies here: use a personal profile. The software change is enough theatre.
Hosted versus self-hosted as a destination
You can import into either. The object that moves is a Discourse backup.
- Self-hosted Docker. Official production is Docker. Recommended 2 GB+ RAM, 2+ cores, 20 GB+ disk, Ubuntu LTS. Rebuilds are
cd /var/discourse && ./launcher rebuild app(~5–10 minutes). The import droplet can be this box if you planned the hostname; more often it is a throwaway that produces a backup. - Official hosted. Public 2026 grid: Free; Pro ~$100/month; Business ~$500/month (SSO, automation, events, gamification, more staff seats, migration services); Enterprise custom. Prices are USD and change — read the live page. Hosted can restore a backup. You do not SSH.
If the organization will pay Business for the migration services line, use it. This runbook is for the team that will not.
Enhanced community scripts (use, but label)
The stock script is a single pass. Large boards hit memory, timeouts, and “what if we die at 70%.” Community work (explicitly not core) such as rahul-rakesh/xenforo-migration-scripts / xenforo_import_enhanced.rb adds:
- Keyset pagination
local.jsoncheckpoints- Two-pass topics-then-replies
If you use it, pin the commit hash you ran, keep the stock script’s field map in your head (the enhanced script still has to read the same XF tables), and do not file Meta tickets as if you were on xenforo.rb. Community scripts also do not magically import XFRM. They make the same five jobs survivable.
Reverse direction (so you do not lie to the board)
If this project fails, XenForo’s official Importers add-on does list Discourse 3.x as a source. CLI: php cmd.php xf:import. Retain IDs only on an empty XenForo. That is a real return ticket. It is not an argument for being sloppy on the way out — Discourse → XF will not resurrect XFRM you already deleted — but it is why this is not a hostage situation.
The asymmetry still matters: Discourse-bound migrations are a script + Meta + optional paid hosted help. XF-bound migrations are a supported add-on. Operations people should keep that on the decision page, which is why it already lives in the matrix.
Cutover night, in order
Print this. Tick it.
- XF: new registrations off. Optional read-only / banner.
- Final XF dump +
data/rsync to the import host (or confirm the last good snapshot is minutes old). - Discourse production: closed to the public, or not yet pointed at the hostname.
- Run the import or restore the staged Discourse backup onto clean prod.
- Confirm permalinks
/threads/{id}and/board/{node_name}resolve. - Enable 301s at the edge. Curl the twenty known URLs.
- SMTP or Discourse ID documented on the guidelines topic.
- Password-reset / social-login note in the banner.
- Staff accounts can flag, close, and reach the private staff category.
- Point the public hostname at Discourse. Watch Let’s Encrypt if this box is new (
app.yml, then rebuild). - Turn XF into a redirect-only vhost (or shut the PHP app down so it cannot 200).
- Take a new Discourse backup. Download it off the box.
Rebuild reminder, because someone will want a plugin at 03:00: cd /var/discourse && ./launcher rebuild app is a 5–10 minute outage. Do not install a plugin during the flip. Theme later.
Aftercare the importer will not do
- Retag. Depth-3 leftovers and unmapped prefixes are a week of human work. Required tag groups on Support-style categories beat a soup of imported names.
- Trust levels. Imported veterans are not automatically TL3. TL3 is a 100-day rolling window on Discourse and can demote; TL4 is staff/manual or SSO, not an activity grind. You may lock a level or grant a minimum via groups. Do not invent visit counts; read Meta’s Understanding Trust Levels and the Trust Level Permissions Reference. The moderation article owns the model.
- Sandbox. New signups land at TL0 (invited users typically TL1). The 1-image / 2-link TL0 caps will surprise people writing “I migrated and cannot post my screenshots.” Put that sentence in the welcome topic.
- Category security. Do not add Admin + Moderators + TL1 + TL2 + TL3 + TL4 as if they were XF groups that must all be ticked. Hierarchy plus Staff already covers this; over-specifying is a known way to get “not permitted to view.”
- Search. Old Google results depend on the 301s. Do not noindex the new site “until it looks pretty.”
A worked (small) example
A 40,000-post hobby board. Eight public forums, two private staff forums, a page-node FAQ, no XFRM, no XFMG, prefixes used as [Solved].
- Snapshot: 20 minutes. Import droplet: 2 vCPU / 4 GB is enough at this size; you are not the million-post case.
- Stock script wall-clock: measure on your snapshot — do not reuse someone else’s hour count.
- Flatten: one leftover “Events / 2022 / Spring” branch becomes tags on Events. You delete the 2022 tag after cutover.
- Permalinks: nginx extracts
\d+from/threads/[^.]+\.(\d+)/and 301s to/threads/$1. - Page-node FAQ: one Discourse topic, staff-wiki, 301 from the old
/pages/faq/to that topic. - Prefixes: post-import, a staff pass adds a
solvedtag to threads that had the prefix. Not automatic in stock. - Passwords: Discourse ID + optional SMTP reset. Comms went out a week early. About 30% of week-one logins needed a reset email. That is a support load, not a script bug.
- Staff: two moderators spent four evenings on staging. One never liked infinite scroll and still does not. They stayed. That is a staffing success.
Scale the same story to a board with XFRM and a credits shop and the honest ending changes: you either stay on XenForo or you budget a rebuild of those products. The importer will not meet you halfway.
Checklist
Decide (already done, or stop)
- Stay-or-migrate still says move after lock-in, staff, URLs, cost, size
- XFRM / XFMG / page nodes / add-on shops have an explicit die-or-rebuild plan
- Staff have read the ops close
Prepare
- XF snapshot: database +
data/(avatars, attachments) - Import host with local MySQL, Discourse, mysql2, php-serialize
-
AVATAR_DIRandATTACHMENT_DIRpaths confirmed against a sample id - Pre-import Discourse backup
- Node tree marked category / tag / drop
- 301 design written against
threads/{id}andboard/{node_name}
Dry-run
- Tiny-copy import or community-checkpointed pass
- Staff bits, avatars, markdown, attachments, PMs, likes spot-checked
- Permalinks resolve
- Full-size import timed
- Discourse backup of the good import kept off-box
Cutover
- XF frozen; final snapshot
- Restore to clean prod (or flip the planned hostname)
- 301s live; XF no longer 200s
- Password / Discourse ID story on the banner
- SMTP or an honest “no digests yet”
- Staff smoke test
- Fresh Discourse backup
Explicitly not this runbook
- Not a stay-or-go essay
- Not a phpBB → XF import
- Not an XF host clone
- Not week-one category taste (do that on a clean site, then import)
Takeaways
- XenForo → Discourse is a Discourse-side Ruby job (
xenforo.rb), not an XF export. Stockexecuteis users, categories, posts, PMs, likes. Banned users are skipped. Custom reactions become likes. - Run it on a staging / import droplet with local MySQL. Community sizing for huge boards is often 4 vCPU / 8 GB. Backup the good Discourse and restore to clean prod.
- Depth > 2 becomes tags. Page nodes, XFRM, XFMG, custom thread fields, and most add-on tables die. Prefixes are not a stock tag group.
- Permalinks to plan around:
threads/{thread_id}andboard/{node_name}. XF pretty URLs need an edge 301. Discourse topics are/t/slug/id. - Do not claim password import. Tell members they will reset or use Discourse ID / social. Meta asked; this article will not invent a yes.
- Community enhanced scripts exist; label them community. Hosted Business lists migration services; re-check the live price grid.
- The return ticket is real: XF official importers list Discourse 3.x inbound. Use that fact in the decision, then run this runbook only if you still mean it.
If Latest on staging shows real names, real attachments, and a thread your own browser bookmark can reach via a 301, you are ready to flip. If it shows a flattened tag soup and a resource library that vanished, you are not looking at an importer bug. You are looking at the product you chose. Either finish the rebuild plan or stay on XenForo until you have one.

