Migrating phpBB to Discourse — The Real Walkthrough (2026)
Migrating to Discourse is not a one-click affair. The import scripts live inside the Discourse Docker container, the source MySQL has to be reachable from that container, attachments need a path-mapping dance, usernames break on characters Discourse refuses, and once the import finishes you stare at a Sidekiq queue with hundreds of thousands of jobs that have to drain before users can log in. This guide is the end-to-end phpBB → Discourse run, with vBulletin notes at the bottom.
containers/app.yml, the import log, and the Sidekiq stats, then prepares a fix with the right ATTACHMENTS_BASE_DIR, volume mount, and UNICORN_SIDEKIQS bump — faster than re-running a 12-hour import.
Key Takeaways (TL;DR)
- The script lives in the Discourse repo at
script/import_scripts/phpbb3.rb— and it must run inside the Discourse Docker container, not on the host. - Prerequisites: phpBB 3.x (upgrade 2.x first), MySQL reachable from container, attachments dir mounted in, fresh Discourse install (or
IMPORT_DESTRUCTIVE=1). - Always dry-run first — fork the script with
BATCH_SIZE=100and a hard-coded LIMIT to validate connectivity, BBCode conversion, and username remap before committing 12 hours of CPU. - Attachments are the #1 footgun: phpBB's
files/directory has to be mounted into the container at the pathATTACHMENTS_BASE_DIRpoints to. - Sidekiq drain is the second half of the migration. Watch
Sidekiq::Stats.new.enqueuedin a Rails console; bumpUNICORN_SIDEKIQS=4inapp.ymlto drain faster.
What Actually Gets Imported
Before you start, set expectations. Discourse's importer is not a 1:1 mirror — it's an opinionated remapping into Discourse's data model. Some things round-trip cleanly, others lose fidelity, others vanish entirely.
| phpBB concept | Maps to (Discourse) | Fidelity |
|---|---|---|
| Users (with bcrypt/phpBB3 hashes) | Users; password reset on first login | ✓ Full — incl. signature, avatar, registration date |
| Forums / sub-forums | Categories / sub-categories | ✓ Full — permissions reset, you re-grant |
| Topics | Topics | ✓ Full — pinned, locked flags preserved |
| Posts (BBCode) | Posts (Markdown + HTML) | ~ Mostly — standard tags convert; obscure mod tags survive as plain text |
| Attachments & inline images | Uploads | ✓ Only if ATTACHMENTS_BASE_DIR is set right |
| Private messages (1-to-1) | Personal messages | ~ Mostly — group PMs sometimes collapse to threads |
| Topic subscriptions / "watching" | — | ✗ Not imported — users re-watch |
| Reactions / +1 / phpBB karma | — | ✗ Not imported (phpBB has no native concept) |
| Custom profile fields (mod-defined) | — | ✗ Not imported unless you fork the script |
| Ranks & badges | — | ✗ Re-create with Discourse Trust Levels & badges |
Custom BBCode (e.g. [highlight]) | — | ✗ Survives as raw text; needs a discourse remap pass |
For vBulletin migrations the table is essentially the same except reactions and thanks are imported (vBulletin does have those natively, and the importer respects them) and the BBCode loss is bigger because vBulletin lets admins register arbitrary tags.
Prerequisites — What to Set Up Before You Touch the Script
1. Source side: phpBB 3.x
The phpbb3.rb importer only understands phpBB 3.x schema (3.0, 3.1, 3.2, 3.3 all work — 3.3 is the smoothest). If you're on phpBB 2.x, upgrade first using phpBB's own UMIL/converter to at least 3.0, then ideally to 3.3, then point the importer at the upgraded MySQL. Going directly from 2.x is not supported.
You need read access to the phpBB MySQL database — the script never writes to phpBB. Either:
- Open MySQL on the source host to the Discourse server's IP (firewall rule + a read-only MySQL user), or
- Take a
mysqldumpand restore it into a temporary MySQL on the Discourse server, or - Run a temporary MySQL inside the Discourse Docker container (the simplest option for one-shot migrations).
2. Disk for attachments
Estimate: phpBB attachments live in files/ at the forum root. Run du -sh files/ on the source — that's your floor. Discourse re-encodes images and generates thumbnails, so reserve 2-3x that figure on the Discourse host. A 20 GB attachment dir typically needs 50-60 GB of free disk after import.
3. Target side: a fresh Discourse
The official guidance is unambiguous: import into a fresh Discourse. The importer assumes user IDs, post IDs and category IDs that it can allocate freely. Importing into an existing forum with real users in it will collide on user IDs, mangle topics, and generally ruin your day.
If you must import into a non-empty Discourse (e.g. a staff Discourse that already exists), set:
IMPORT_DESTRUCTIVE=1 bundle exec ruby script/import_scripts/phpbb3.rb
This wipes the Discourse database before importing. There is no in-place "merge" mode. If you want to truly merge two forums, run two imports into two fresh Discourses, then export one and re-import via the Discourse-to-Discourse importer.
4. Network: container ↔ source DB
The import script runs inside the app Docker container. From inside that container, you must be able to mysql -h SOURCE_HOST -u user -p phpbb_db. Common pitfalls:
localhostmeans the container, not the host. If your phpBB MySQL is on the Discourse host's loopback, use172.17.0.1(Docker bridge) or the host's LAN IP.- MySQL 8 requires
caching_sha2_passwordor you'll get auth failures — create the user withIDENTIFIED WITH mysql_native_passwordfor the import. - If you mounted a mysqldump locally, just run a temp MySQL inside the same container and you skip the network problem entirely.
Step-by-Step: phpBB → Discourse
-
Spin up Discourse normally. SSH into the target server, install Docker, clone
discourse/discourse_dockerinto/var/discourse, configurecontainers/app.yml(SMTP, hostname, admin email), and run./discourse-setupor./launcher rebuild app. Confirm the empty Discourse loads at your domain. Do not create real users yet. -
Mount the phpBB attachments directory into the container. Edit
containers/app.ymland add a volume in thevolumes:block:
Copy or rsync the phpBBvolumes: - volume: host: /var/discourse/shared/standalone guest: /shared - volume: host: /srv/phpbb-files guest: /shared/import/phpbb-filesfiles/directory to/srv/phpbb-fileson the host. Rebuild:./launcher rebuild app. -
Enter the container.
Everything below runs inside that shell.cd /var/discourse ./launcher enter app -
Configure the importer. The phpBB importer reads a YAML at
script/import_scripts/phpbb3/settings.yml. Copy the example:
Setcd /var/www/discourse cp script/import_scripts/phpbb3/settings.yml \ script/import_scripts/phpbb3/settings.local.yml vim script/import_scripts/phpbb3/settings.local.ymldatabase,username,password,host,table_prefix(usuallyphpbb_), and the absolute container path to attachments underphpbb_base_dir(e.g./shared/import/phpbb-files). -
Dry-run on a sample. Don't commit the full import yet. Edit the script (or use the built-in
POSTS_LIMIT/SKIP_*envs depending on the script version) to import only the first 100 topics. Verify usernames look right, BBCode rendered correctly, attachments resolve. -
Run the real import.
Tail the log in another shell (cd /var/www/discourse bundle exec ruby script/import_scripts/phpbb3.rb \ script/import_scripts/phpbb3/settings.local.yml \ 2>&1 | tee /shared/import.log./launcher enter appin a second window, thentail -f /shared/import.log). Expect: hours, not minutes. A 500k-post forum on a 4-vCPU box took ~9 hours in our test run. -
Username fixup. phpBB allows usernames Discourse refuses (spaces,
%, leading digits, etc.). The importer auto-rewrites them, but the rewrite isn't always pretty. Run a quick audit in a Rails console:User.where("username LIKE ?", "%\\_%").count. UseUserUpdaterto bulk-rename if needed (see the script below). - Wait for Sidekiq to drain. The hardest, least-glamorous step. Section below covers monitoring and tuning.
Environment Variables and Settings That Matter
Different versions of the script read settings from different places. Modern Discourse (post-2018) uses the YAML; older versions used env vars. Here's the canonical set you'll be touching:
| Setting | What it does | Typical value |
|---|---|---|
database | phpBB MySQL DB name | phpbb |
username / password | MySQL credentials | Read-only user |
host | MySQL host as seen from container | 172.17.0.1 or db.internal |
table_prefix | phpBB tables prefix | phpbb_ |
phpbb_base_dir | Container path to phpBB files/ | /shared/import/phpbb-files |
username_as_email | If a user has no email, fabricate one | true |
site_name | Used in email-fabrication domain | example.com |
BATCH_SIZE (env) | Rows fetched per query | 1000 default; lower if OOM |
IMPORT_DESTRUCTIVE (env) | Wipe Discourse DB first | 1 if forum not fresh |
RAILS_ENV | Always production inside container | production |
RAILS_ENV=production bundle exec rake db:migrate before the import. That's already done by ./launcher rebuild; running it again does nothing useful and on certain Discourse versions has side effects.
The Attachment Problem (and Its Fix)
This is the single most common point of failure. Symptoms:
- Import runs to completion but every post that referenced an image shows broken thumbnails.
- Log lines like
WARN attachment file not found: 2_a3f9b2c0c2c3... - Discourse shows the attachment filename as text instead of an image.
The mechanic: phpBB stores attachments by hash filename in files/, with the database row referencing the hash. The importer reads the row, computes {phpbb_base_dir}/{physical_filename}, and tries to File.read it. If the path is wrong, it silently logs a warning and continues.
Fix it
- From inside the container:
ls /shared/import/phpbb-files | head. You should see hash-style filenames like2_a3f9b2c0c2c3a1b4. If you see an empty directory, your volume mount is wrong — re-checkcontainers/app.yml. - If the directory has files but the importer still warns, the
phpbb_base_dirsetting is pointing somewhere else. Match it byte-for-byte to the container path you see inls. - If files are there but renamed (some shared hosts strip the
2_prefix), you need to fork the script'sfind_filenamemethod. This is rare — the default works on stock phpBB.
If you finished the import and only realized attachments are broken later, you don't have to redo everything. Re-run the importer; it skips anything already imported and retries failed attachments.
Username Fixup
phpBB allows usernames like John Doe, 50%off, and even Cyrillic. Discourse's username regex is much stricter (alphanumeric, ., _, -; first char alphanumeric; ASCII only by default). The importer remaps automatically — usually by replacing illegal chars with _ — but the result can be ugly or collide.
After import, audit:
# inside container, in Rails console
./launcher enter app
cd /var/www/discourse
RAILS_ENV=production bundle exec rails console
> User.where("username ~ '_{2,}'").count
> User.where("username LIKE '%\\_\\_%'").pluck(:id, :username).take(20)
For a clean rewrite of the worst offenders:
# Rename users with double-underscore artifacts
User.where("username LIKE '%\\_\\_%'").find_each do |u|
new = u.username.gsub(/_{2,}/, '_').sub(/^_+/, '').sub(/_+$/, '')
next if new.blank? || User.exists?(username_lower: new.downcase)
UsernameChanger.change(u, new, u)
end
If you want to enable Unicode usernames globally first (so the importer stops mangling Cyrillic / CJK names), set unicode_usernames in admin → settings before importing.
Post-Import: Sidekiq Drain
The import script writes raw rows. It does not bake posts (cook Markdown to HTML), build the search index, or generate image thumbnails. All of that happens via background Sidekiq jobs queued during import. On a 500k-post forum that's typically 300-700k jobs queued.
Check queue depth:
# inside Rails console
> Sidekiq::Stats.new.enqueued
=> 318402
> Sidekiq::Stats.new.queues
=> {"default"=>284091, "low"=>34211, "ultra_low"=>100}
Or visit /sidekiq as an admin in the browser — Discourse exposes the Sidekiq web UI.
Tuning the drain
Default Discourse runs one Sidekiq process per Unicorn master. To drain faster, bump it temporarily in containers/app.yml:
env:
UNICORN_SIDEKIQS: 4
# also bump if memory allows
DISCOURSE_DB_POOL: 25
Rebuild: ./launcher rebuild app. Keep an eye on RAM — each extra Sidekiq process is ~300 MB resident. On an 8 GB box don't go above 4. Once the queue is empty, set UNICORN_SIDEKIQS back to the default (1) and rebuild again.
Common slow jobs
Jobs::ProcessPost— re-cooks Markdown, oneboxes external links, generates thumbnails. Slowest. ~80% of the queue.Jobs::PullHotlinkedImages— downloads images that posts hotlink to. Will hit external servers; respect their rate limits.Jobs::EmitWebHookEvent— disable webhooks during import (admin → API → webhooks) so you don't notify integrations 500k times.
Rebake and Remap (the cleanup pass)
Once Sidekiq is empty, run a forced rebake to catch posts that didn't process correctly:
# inside Rails console
> Post.where("created_at > ?", 1.day.ago).find_each(&:rebake!)
Or via rake (faster for full forums):
RAILS_ENV=production bundle exec rake posts:rebake
For URL fixups — old phpBB viewtopic.php?t=123 links scattered through post bodies — use discourse remap:
./launcher enter app
cd /var/www/discourse
# preview first
RAILS_ENV=production bundle exec script/discourse remap \
"oldforum.com/viewtopic.php?t=" "discourse.example.com/t/" --dry-run
# do it
RAILS_ENV=production bundle exec script/discourse remap \
"oldforum.com/viewtopic.php?t=" "discourse.example.com/t/"
# regex variant
RAILS_ENV=production bundle exec script/discourse remap \
--regex "(http://oldforum\\.com)" "https://discourse.example.com"
Subdomain → Root Migration: 301 Map
If your old phpBB lived at forum.example.com and Discourse now lives there too (or vice-versa), you need 301s for the old phpBB URL patterns. The script logs every topic_id → discourse_topic_id mapping in the import database tables; you can dump it and feed it to nginx.
Minimal nginx redirect block on the old host (or in front of Discourse if you kept the same domain):
# /etc/nginx/sites-available/phpbb-redirect.conf
location /viewforum.php {
if ($arg_f) {
return 301 https://forum.example.com/c/$arg_f;
}
}
location /viewtopic.php {
if ($arg_t) {
return 301 https://forum.example.com/t/$arg_t;
}
}
location = /memberlist.php { return 301 https://forum.example.com/u; }
location = /index.php { return 301 https://forum.example.com/; }
Discourse will resolve /t/<id> to the real topic slug and 301 again to the canonical URL. Two hops — but Google handles it fine and your link equity survives.
Validation Checklist (Before You Open the Doors)
-
Row counts match.
Discourse counts will be slightly higher (system user, welcome topic). Anything more than +5 is suspicious.# source MySQL SELECT COUNT(*) FROM phpbb_users WHERE user_type != 1; SELECT COUNT(*) FROM phpbb_topics; SELECT COUNT(*) FROM phpbb_posts; # target Postgres (Rails console) > User.real.count > Topic.count > Post.count - Spot-check 5 random topics. Pick the most-viewed phpBB topics; open them in Discourse; check formatting, attachments, quoted replies.
- Test a user login. Pick a user with a known email; do a "Forgot password" flow; confirm reset email arrives and login works.
- Search works. Search for a phrase you know exists. If results are empty, the search index hasn't built yet — go back to Sidekiq drain.
- Categories and permissions. Default is "everyone can read"; if your phpBB had private forums, re-create those permissions in Discourse before users log in.
- Attachments load. Open a post you know has an image; check the image renders, not a broken-thumbnail icon.
- Email is sane. Send a test email from admin → email; verify it arrives. Discourse will fan out lots of email after launch — make sure SPF/DKIM are correct first.
Notes for vBulletin → Discourse
The pattern is identical:
- Script:
script/import_scripts/vbulletin.rb(3.x, 4.x) orvbulletin5.rb(5.x). - Settings: same YAML pattern, edit
script/import_scripts/vbulletin/settings.yml(or the equivalent for v5). - Attachments: vBulletin has two storage modes — file system (
customavatars,attachmentsfolders) and database BLOB. The importer handles both, but BLOB-stored attachments are SLOW; convert to filesystem first using vBulletin's admin tool if you can. - Custom BBCode: vBulletin admins routinely register tags like
[highlight],[spoiler],[video]. The importer doesn't know your custom tags. After import, rundiscourse remap --regexto convert each one. Common conversions:# [highlight]x[/highlight] → **x** remap --regex "\\[highlight\\](.*?)\\[/highlight\\]" "**\\1**" # [spoiler]x[/spoiler] → [details=Spoiler]x[/details] remap --regex "\\[spoiler\\](.*?)\\[/spoiler\\]" "[details=Spoiler]\\1[/details]" - vBulletin 5's schema differs significantly from 3/4 — use
vbulletin5.rband expect rough edges. Some edge cases (channel-typed forums, blog channels) don't import; you'll lose them or re-create as Discourse categories manually.
Common Errors and Fixes
| Error | Cause | Fix |
|---|---|---|
Mysql2::Error: Can't connect to MySQL server | Container can't reach source DB | Use Docker bridge IP 172.17.0.1, not localhost |
Mysql2::Error: Access denied for user in MySQL 8 | caching_sha2_password | Recreate user with IDENTIFIED WITH mysql_native_password |
WARN attachment file not found | phpbb_base_dir wrong or volume not mounted | See "Attachment Problem" section |
ActiveRecord::RecordNotUnique on username | Two phpBB users normalize to same Discourse username | Pre-rename one in phpBB before import |
NoMethodError: undefined method `cook' | Running script outside container / wrong RAILS_ENV | Always ./launcher enter app, always production |
| Sidekiq queue stops draining | Worker crashed | Sidekiq::ProcessSet.new.size in console; ./launcher restart app |
| Out of memory during import | BATCH_SIZE too high | Set BATCH_SIZE=200, retry |
| Some posts show raw BBCode | Custom phpBB mod tag | Post-import discourse remap --regex |
When to Ask Humans (or AI) for Help
Discourse migrations have an unfortunate property: most failure modes are silent. The script prints "Done" with broken attachments and you only notice three days later when a user complains. SimpleReview can:
- Read the import log and flag warnings you missed (
attachment file not foundburied 50,000 lines deep). - Inspect
containers/app.ymland verify your volumes / env vars match the script settings — prepare a fix if they don't. - Pull
Sidekiq::Stats.newfrom a Rails console snippet, recommendUNICORN_SIDEKIQSbump if the queue isn't draining. - Generate the nginx 301 map from your phpBB topic-ID → Discourse topic-ID dump.
Useful when you don't want to learn Sidekiq internals on your launch day.
Don't Re-Run the 12-Hour Import — Patch the Config Instead
Click on the failing import row, type the symptom, hit Fix it. SimpleReview reads your app.yml and import log, prepares a fix with the corrected mount path, env var, or Sidekiq tuning.
Install SimpleReview Chrome Extension →Running Discourse on your own infra? SimpleReview also reads server logs over SSH →
Frequently Asked Questions
script/import_scripts/phpbb3.rb inside the Discourse Docker container. Mount phpBB's files/ directory into the container, give the container network access to phpBB's MySQL, configure script/import_scripts/phpbb3/settings.local.yml with DB credentials and phpbb_base_dir, then run bundle exec ruby script/import_scripts/phpbb3.rb settings.local.yml. Wait for Sidekiq to drain afterward.script/import_scripts/. phpbb3.rb for phpBB 3.x, vbulletin.rb for vB 3/4, vbulletin5.rb for vB 5, plus vanilla.rb, smf2.rb, mybb.rb, ipboard.rb, etc. They must run inside the Discourse container (./launcher enter app), not on the host.Sidekiq::Stats.new.enqueued in Rails console; bump UNICORN_SIDEKIQS=4 in containers/app.yml to drain faster. Don't open the site to users until the queue is empty.vbulletin.rb for v3/v4, vbulletin5.rb for v5. Identical pattern: container DB access, attachment dir mounted, env/YAML for credentials. The known wart is custom BBCode tags (vBulletin admins can register arbitrary ones) — those survive as plain text and need a discourse remap --regex pass after import.Related Discourse Guides
Sources
- meta.discourse.org — Import from phpBB3
- github.com/discourse/discourse — script/import_scripts/
- meta.discourse.org — Importing from vBulletin
- meta.discourse.org — howto/import category
- meta.discourse.org — Configure Sidekiq and Unicorn workers
- github.com/discourse/discourse_docker — Official launcher / container config