A vBulletin 3 forum that has been online for fifteen years is not a website in the usual sense. It is a MySQL database that has outlived several PHP versions, a few server moves and at least one administrator who installed a plugin and then left. Moving it to Discourse looks like a software change. In practice it is a data migration, and it fails the way data migrations fail: quietly, in the records nobody looked at.
A long-running install typically carries custom user groups, nested forums, private messages, attachments, polls, years of BBCode, tables created by plugins nobody remembers, inbound links from all over the web, an old MySQL schema, mixed character encodings and a scattering of malformed rows. Discourse has a good importer for vBulletin 3. It cannot fix a source database that is wrong, and it cannot tell you what it skipped unless you read its output.
So treat the job as an ETL project, not an upgrade:
- ExtractDump the database and copy attachments and avatars
- AuditRecord versions, prefixes, storage modes and counts
- CleanProve the real byte encoding and repair it on a copy
- Transform and importRun vbulletin3.rb into a clean Discourse
- ValidateCompare counts and sample records against the source
- Cut overFreeze vBulletin, run the final import, switch DNS
The guide assumes you are comfortable with SSH, MySQL or MariaDB, Docker and general Linux administration. Encoding repair gets its own article, How to Fix Latin1, CP1253 and UTF-8 Encoding Before a vBulletin Migration, because it deserves more space than it can have here. If you want the short version to tick off as you go, use the printable vBulletin 3.x to Discourse migration checklist.
1. Audit the existing vBulletin installation
Before any dump or import, write down what you are migrating. You need:
- the vBulletin version (Admin CP footer, or the
templateversionvalue in thesettingtable) - the database server and version, and the database name
- database size and the largest tables
- the table prefix
- the attachment storage mode (in the database, on disk, or both after a historical move)
- whether custom avatars and profile pictures live in the database or in directories
- plugins, products and any custom tables
- counts of users, forums, threads, posts, private messages and attachments
Confirm you really are on 3.x. vBulletin 4 and 5 have different schemas and different Discourse importers, and a forum that was upgraded to 4.x years ago and then abandoned is more common than you would think.
SHOW DATABASES;
USE forum_database;
SHOW TABLES;
The table list tells you the prefix. vBulletin lets the installer choose one, so you will see vb_, vb3_, a site-specific string, or no prefix at all. The rest of this guide uses vb3_. Substitute your own.
Record baseline counts now:
SELECT COUNT(*) FROM vb3_user;
SELECT COUNT(*) FROM vb3_forum;
SELECT COUNT(*) FROM vb3_thread;
SELECT COUNT(*) FROM vb3_post;
SELECT COUNT(*) FROM vb3_pm;
SELECT COUNT(*) FROM vb3_pmtext;
SELECT COUNT(*) FROM vb3_attachment;
SELECT COUNT(*) FROM vb3_poll;
These numbers are your reference for every validation step later. Without them you cannot tell whether the import lost 3% of posts or whether 3% of posts were soft-deleted spam that never should have come across. Keep them in a file with the date and the dump they came from.
Database size per table is useful for planning disk and import time:
SELECT
TABLE_NAME,
ENGINE,
TABLE_ROWS,
ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024) AS size_mb
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'forum_database'
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC
LIMIT 20;
TABLE_ROWS is an estimate for InnoDB. Use COUNT(*) for anything you intend to compare.
2. Back up everything
A backup you have not restored is a hope, not a backup. We go into why in Why Your Host’s Backup Policy Is a Lie. For the database:
mysqldump \
--single-transaction \
--routines \
--triggers \
--hex-blob \
--default-character-set=binary \
forum_database > vbulletin-original.sql
--default-character-set=binary tells mysqldump not to convert anything on the way out. Whatever bytes are in the columns go into the file unchanged, which is exactly what you want when you do not yet know what those bytes are. --hex-blob writes attachment and avatar blobs as hex literals, which survives any editor or transfer tool that might otherwise touch binary data.
--single-transaction only gives a consistent snapshot for InnoDB tables. Most vBulletin 3 installs are MyISAM, and for MyISAM the flag does nothing useful: rows can change between one table’s dump and the next. For a rehearsal dump that rarely matters. For the final production dump, the forum must be frozen first (section 31), or use --lock-all-tables and accept that the forum stalls while it runs.
Then the files. Depending on the configuration you need some or all of:
- the attachment directory (vBulletin 3 stores files as
attachments/1/2/3/<attachmentid>.attachfor user ID 123) customavatars/andcustomprofilepics/, if those were moved to the file systemimages/, which holds the stock avatars referenced from theavatartable- any upload directories that plugins created
tar -C /var/www/forum -czf vbulletin-files.tar.gz attachments customavatars customprofilepics images
sha256sum vbulletin-original.sql vbulletin-files.tar.gz > vbulletin-backup.sha256
Copy both archives and the checksum file off the server. From here on, vbulletin-original.sql is read-only. You restore from it as often as you need and you never edit it. Every repair is done to a restored working copy, by a script, so it can be repeated against the next dump.
3. Restore to a staging database
Restore onto a separate MySQL or MariaDB server, or at least a separate database. Do not restore over anything the live forum can see.
CREATE DATABASE vbulletin_stage;
mysql --default-character-set=binary vbulletin_stage < vbulletin-original.sql
Check the tables the importer will read are there and have rows:
SELECT COUNT(*) FROM vb3_setting;
SELECT COUNT(*) FROM vb3_user;
SELECT COUNT(*) FROM vb3_forum;
SELECT COUNT(*) FROM vb3_thread;
SELECT COUNT(*) FROM vb3_post;
SELECT COUNT(*) FROM vb3_pmtext;
SELECT COUNT(*) FROM vb3_attachment;
The counts must match what you recorded in section 1. If the restore itself throws errors (duplicate keys, a crashed MyISAM table, a row too large for the target server’s strict mode), solve them here. A source database that does not restore cleanly will produce import failures that look like Discourse bugs and are not. Validate the source on its own terms before you blame the importer.
4. Audit character encoding
This is where old forums usually hurt. MySQL’s metadata tells you what character set a column claims to hold. It does not tell you what was actually written into it.
Over the life of a vBulletin 3 forum, any of these can be true, sometimes in the same table:
- genuine latin1 (MySQL’s
latin1is really Windows-1252) - ISO-8859-1 text from older browsers
- ISO-8859-7 Greek, which MySQL calls
greek - Windows-1253 (CP1253) Greek, which MySQL has no character set for at all
- UTF-8 bytes stored in latin1 columns, because the forum’s HTML said UTF-8 and the connection said latin1
- double-encoded UTF-8, from a past “conversion” that trusted the label
- different encodings in different tables, or different columns of one table
Start with what MySQL thinks:
SELECT
@@character_set_database,
@@collation_database;
SELECT
TABLE_NAME,
TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'vbulletin_stage'
ORDER BY TABLE_NAME;
SELECT
TABLE_NAME,
COLUMN_NAME,
CHARACTER_SET_NAME,
COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'vbulletin_stage'
AND CHARACTER_SET_NAME IS NOT NULL
ORDER BY TABLE_NAME, ORDINAL_POSITION;
Treat the output as a claim to test, nothing more. The next two sections test it.
5. Inspect real data
Look at actual rows, from the oldest and newest ends of each table:
SELECT userid, username
FROM vb3_user
ORDER BY userid
LIMIT 20;
SELECT threadid, title
FROM vb3_thread
ORDER BY threadid
LIMIT 20;
SELECT postid, pagetext
FROM vb3_post
ORDER BY postid
LIMIT 20;
Repeat each with ORDER BY ... DESC. Old records show the encoding from the forum’s early years. Recent ones show what happened after the last server move or PHP upgrade, which is often different.
What your terminal shows depends on your client’s connection character set, so it can mislead you in both directions. HEX() cannot:
SELECT
threadid,
title,
HEX(title)
FROM vb3_thread
WHERE threadid = 12345;
On a multilingual forum, deliberately find non-ASCII records and test those. A forum whose first thousand threads are English tells you nothing about the Greek subforum that opened in 2009. The companion article on fixing latin1, CP1253 and UTF-8 encoding has queries that classify rows by their byte patterns and a table of what Greek text looks like in each encoding.
6. Never blindly convert to utf8mb4
The order of work is fixed:
- Work on a copy.
- Identify the actual byte encoding of each column that matters.
- Test the transformation with
SELECTonly. - Compare the output visually and, where it matters, with
HEX(). - Only then write an
UPDATEorALTER, as a script you can rerun on a fresh restore.
Here is a transformation we used on one migration. In that database, the affected columns held UTF-8 byte sequences that MySQL believed were latin1:
SELECT
title AS original,
CONVERT(
CAST(
CONVERT(title USING latin1)
AS BINARY
)
USING utf8mb4
) AS candidate
FROM vb3_thread
WHERE threadid = 12345;
This is not a universal repair. It takes the text back to latin1 bytes and reinterprets those bytes as UTF-8, which is correct only when the stored bytes really are UTF-8. Run it against genuine latin1 or CP1253 text and you get question marks or garbage. Do not copy it into your migration until HEX() has shown you that your bytes match that case.
7. Validate important text fields
The importer reads text from these columns, and each one needs checking on its own, because they were not all written by the same code path:
vb3_user.usernamevb3_forum.titlevb3_forum.descriptionvb3_thread.titlevb3_post.titlevb3_post.pagetextvb3_pmtext.titlevb3_pmtext.messagevb3_usergroup.titlevb3_poll.questionandvb3_poll.options
Columns the importer does not read can still matter to you. vb3_moderatorlog.threadtitle, for example, is worth checking if you plan to keep an archive of the old database for reference. Inspect plugin tables too. Plugins often created their own tables with whatever default character set the server had that year.
8. Optional database normalization
Once the text is proven correct, you can normalize the staging database so its labels finally match its contents:
ALTER DATABASE vbulletin_stage
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
ALTER TABLE vb3_thread
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
Run table conversions only after the repair, and only for tables whose columns you have verified, because this is the same statement section 6 warns about. Once the labels are honest it is safe. Before that it is destructive.
Converting old MyISAM tables to InnoDB in staging (ALTER TABLE ... ENGINE=InnoDB) can make the database easier to administer and back up consistently. It is not a Discourse requirement. The importer only reads from MySQL, and it reads MyISAM fine.
9. Install a clean Discourse instance
Use the official Docker-based install from the Discourse cloud install guide. It installs to /var/discourse by default and runs the application inside a container at /var/www/discourse.
Size the server for the imported forum, not for an empty one. Uploads, the PostgreSQL database and Discourse backups all live on the same disk by default, and an import with years of attachments can easily double what the old forum used. A VPS with dedicated NVMe storage gives you the room and the predictable disk performance an import needs. A long import on a congested shared disk is painful.
Before importing anything, the empty site should already have:
- its final hostname and working DNS
- HTTPS
- working outgoing SMTP (test with a password reset; SPF, DKIM and DMARC matter here too)
- an administrator account you can log in with
Keep installation problems and import problems apart. If Discourse cannot send mail or renew its certificate, fix that on the empty site. Debugging both at once with 400,000 posts in the database is miserable.
10. Create a dedicated import container
Discourse’s documented import workflow is a second container definition that shares the same data directory but adds importer dependencies. See Set up an environment to migrate another forum to Discourse and Use an import script that requires MySQL on Discourse Meta.
cd /var/discourse
cp containers/app.yml containers/import.yml
In containers/import.yml, add the MySQL dependency template after the existing templates:
templates:
- "templates/postgres.template.yml"
- "templates/redis.template.yml"
- "templates/web.template.yml"
- "templates/web.ratelimited.template.yml"
- "templates/web.ssl.template.yml"
- "templates/web.letsencrypt.ssl.template.yml"
- "templates/import/mysql-dep.template.yml"
Your existing template list may differ. Keep it as it is and append the last line. At the time of writing, templates/import/mysql-dep.template.yml in discourse_docker adds the mysql2 gem and the MariaDB client library and reruns bundle install. It does not install a MySQL server. Your staging database stays on its own server or container and the importer connects to it over the network.
Put the attachment and image copies where the container can see them. /var/discourse/shared/standalone on the host is /shared inside the container:
mkdir -p /var/discourse/shared/standalone/import/data
tar -C /var/discourse/shared/standalone/import/data -xzf vbulletin-files.tar.gz
The import container and the app container use the same PostgreSQL data directory, so they must never run at the same time:
./launcher stop app
./launcher rebuild import
11. Use vbulletin3.rb
Discourse has a dedicated vBulletin 3 importer: script/import_scripts/vbulletin3.rb, added in late 2024 (pull request #29478). Use it.
A lot of older documentation, including the long-running vBulletin 4 migration topic on Meta, points at the generic script/import_scripts/vbulletin.rb. That script was written for vBulletin 4. The vBulletin 3 importer is based on it but adapted to the 3.x schema, and its author’s notes in reply #178 of that topic say usage is otherwise the same as the vBulletin 4 how-to. If a tutorial tells you to run vbulletin.rb against a 3.x database, it predates the dedicated importer.
From reading the current source, vbulletin3.rb handles:
- Forum settings. Board title, company name and webmaster email are taken from the
settingtable. - Groups. User groups become Discourse groups. Group leaders become group owners and can accept membership requests.
- Users. Admin (group 6) and moderator (group 5) status, secondary groups, website, birthday and registration IP come across, with optional user titles via
CREATE_USERTITLE_FIELD. - Avatars and profile pictures. Custom avatars and profile pictures are read from the database, and stock avatars from
IMAGES_DIR. - Categories. Nesting is kept up to three levels, and display order is preserved.
- Permissions. Category permissions are derived from
forumpermission, covering view, create and reply for guests, members, staff and special groups. - Moderators. Per-forum moderators become category moderator groups.
- Topics and posts. Sticky threads become pinned topics. Closed threads are closed. Moderated threads and posts are hidden. Soft-deleted ones are skipped. View counts and reply relationships are kept.
- Polls. Polls are imported together with their votes.
- BBCode. The importer converts BBCode, including quotes with post references and internal thread and post links.
- Attachments. Files are read from disk, with a fallback to database-stored file data.
- Legacy URLs. Permalinks are created for
showthread.php,showpost.phpandforumdisplay.php. - Bans. Rows in the
userbantable become suspensions.
Two things it does not do are easy to miss. Private messages are not converted into Discourse conversations. Each user with PMs gets one archived system message with a text file attached that contains their whole PM history (section 25). And there is no import of thanks, likes or reputation.
12. Create a dedicated database user
The script defaults to root with an empty password on localhost. That is wrong in two ways. The importer runs in a container, so localhost is the container, not your database server, and a root login with socket authentication cannot work over the network. A root account with network access is also something you should not create for a one-off job.
Create a user that can read the staging copy and nothing else:
CREATE USER 'discourse_import'@'IMPORT_HOST'
IDENTIFIED BY 'STRONG_RANDOM_PASSWORD';
GRANT SELECT, INDEX
ON vbulletin_stage.*
TO 'discourse_import'@'IMPORT_HOST';
INDEX is there because the importer tries to add an index on thread.firstpostid to speed up topic queries. It ignores the error if it cannot, so SELECT alone also works, just more slowly on large forums. Use the Docker host’s address for IMPORT_HOST, or '%' only if the database port is firewalled to that host. Never expose MySQL to the internet for an import. An SSH tunnel or a private network between the two machines is enough.
One more reason to use a throwaway account: the importer prints the database user and password on its first line of output, so they end up in your import log. Drop the user once the migration is accepted.
13. Verify database connectivity first
From inside the import container, prove the connection works with the same driver the importer will use:
cd /var/discourse
./launcher enter import
cd /var/www/discourse
su discourse -c 'bundle exec ruby -e "
require \"mysql2\"
c = Mysql2::Client.new(host: ENV[\"DB_HOST\"], username: ENV[\"DB_USER\"], password: ENV[\"DB_PW\"], database: ENV[\"DB_NAME\"])
puts c.query(\"SHOW TABLES\").count
"'
Set the variables from section 14 first. If you prefer the command-line client, install it in the container and run:
mysql \
-h MYSQL_SERVER \
-u discourse_import \
-p \
vbulletin_stage
SHOW TABLES;
If this fails, do not run the Ruby importer yet. Fix the firewall, the grant host or authentication first. On MySQL 8, a client library that cannot negotiate caching_sha2_password without TLS fails here with an authentication error, and it is much easier to see in a three-line test than in an importer stack trace.
14. Configure the import
vbulletin3.rb reads its configuration from environment variables. These are the names in the current source. Other Discourse importers use different ones (vbulletin5.rb uses DB_PASS and DB_PREFIX, for example), so do not copy variables between scripts.
| Variable | Default | Purpose |
|---|---|---|
DB_HOST | localhost | MySQL or MariaDB host holding the staging copy |
DB_NAME | vbulletin | Database name |
DB_USER | root | Database user |
DB_PW | empty | Database password |
TABLE_PREFIX | vb_ | vBulletin table prefix; set "" explicitly if you have none |
TIMEZONE | America/Los_Angeles | Time zone name passed to TZInfo |
ATTACHMENT_DIR | placeholder path | Root of the vBulletin attachment directory |
IMAGES_DIR | placeholder path | Directory that contains the paths stored in avatar.avatarpath, usually the old forum root |
FORUM_URL | localhost/ | Hostname and path of the old forum, no scheme, used to turn absolute links into internal ones |
FORUM_GENERAL_ID, FORUM_FEEDBACK_ID, FORUM_STAFF_ID | unset | Optional vBulletin forum IDs to map onto Discourse’s default General, Site Feedback and Staff categories |
CREATE_USERTITLE_FIELD | 0 | Set to 1 to create a “User title” profile field |
A typical set, inside the import container:
export DB_HOST="MYSQL_SERVER"
export DB_NAME="vbulletin_stage"
export DB_USER="discourse_import"
export DB_PW="STRONG_RANDOM_PASSWORD"
export TABLE_PREFIX="vb3_"
export TIMEZONE="Europe/Athens"
export ATTACHMENT_DIR="/shared/import/data/attachments"
export IMAGES_DIR="/shared/import/data"
export FORUM_URL="forum.example.com/"
FORUM_URL deserves care. The importer uses it to recognize links that point at the old forum, such as https://forum.example.com/showthread.php?t=123 or an attachment.php?attachmentid= image, and rewrite them as links to the imported topic, post or upload. Leave out the scheme, include the trailing slash, and include the subdirectory if the forum lived in one (forum.example.com/vb/). If members linked to the forum under more than one hostname over the years, only one of them can be rewritten this way; the rest still work through permalinks if those hostnames point at Discourse.
15. Verify the table prefix
SHOW TABLES LIKE '%user';
If you see vb3_user, vb3_post and vb3_thread, then:
export TABLE_PREFIX="vb3_"
The default is vb_, not empty. A database with no prefix needs TABLE_PREFIX="" set explicitly. A wrong prefix produces errors like Table 'vbulletin_stage.vb_user' doesn't exist, which look like a broken database when the only problem is one environment variable.
16. Handle the setting table edge case
This one is a quirk of the importer as it stands today, and it may be fixed upstream by the time you read this, so check the current code before applying anything.
In the current vbulletin3.rb, almost every query uses TABLE_PREFIX. Two tables do not: setting (read by import_settings) and usergroupleader (read while importing groups and users). They are queried by their bare names. On a database with a prefix, the import stops early with an error that setting does not exist, although vb3_setting is right there.
Check before you work around it:
grep -nE 'FROM (setting|usergroupleader)\b' script/import_scripts/vbulletin3.rb
If that prints matches and your tables are prefixed, the least invasive fix in staging is a pair of views:
CREATE VIEW setting AS
SELECT *
FROM vb3_setting;
CREATE VIEW usergroupleader AS
SELECT *
FROM vb3_usergroupleader;
Create them as an administrative user; the import user only needs SELECT on them, which its grant on vbulletin_stage.* already covers.
- Never rename production tables to satisfy an importer.
- Do not create the views unless the current importer actually queries the unprefixed names.
- A two-line patch adding
#{TABLE_PREFIX}to those queries is an equally valid fix, and the better one if you maintain your own fork of the script. If you do patch it, consider sending the change upstream.
17. Verify importer dependencies
vbulletin3.rb requires three libraries: mysql2, htmlentities and php_serialize. htmlentities is already part of Discourse. mysql2 comes from the mysql-dep template. php_serialize (the php-serialize gem) comes from nowhere: it is not in Discourse’s Gemfile and the template does not add it. The importer needs it to decode the serialized recipient lists in pmtext.touserarray, and if it is missing the script prints install instructions and exits.
The script’s own message suggests appending the gem to the Gemfile and running bundle install inside the container. That works, but it is lost at the next rebuild, and an import environment you cannot rebuild is an import environment you cannot rehearse. Put it in import.yml instead, following the same pattern the mysql-dep template uses:
hooks:
after_bundle_exec:
- exec:
cd: $home
cmd:
- echo "gem 'php-serialize', require: false" >> Gemfile
- su discourse -c 'bundle install --jobs $(nproc --ignore=1)'
If your import.yml already has an after_bundle_exec section, add these commands to it rather than creating a second one. Rebuild, enter the container, and confirm both gems load:
su discourse -c "bundle exec ruby -e \"require 'mysql2'; puts Mysql2::VERSION\""
su discourse -c "bundle exec ruby -e \"require 'php_serialize'; puts 'php_serialize OK'\""
18. Run the test import
Imports of large forums run for hours. Start one inside tmux or screen so a dropped SSH session does not kill it:
tmux new -s discourse-import
cd /var/discourse
./launcher enter import
cd /var/www/discourse
Export the variables from section 14, then:
su discourse -c 'bundle exec ruby script/import_scripts/vbulletin3.rb' \
2>&1 | tee /shared/import/vbulletin-import.log
su without - keeps the exported variables, which is how Discourse’s own guides run importers. /shared/import/vbulletin-import.log lands in /var/discourse/shared/standalone/import/ on the host, so the log survives the container. Time the run; it is the main input for sizing the production maintenance window.
The base importer remembers what it has already imported, so if a run dies halfway, rerunning continues rather than duplicating. That is handy while debugging. For rehearsals, start each one from an empty Discourse, so you are measuring the process you will actually run on the day.
When the import finishes, stop the import container and start the app again:
./launcher stop import
./launcher start app
19. Read the import log
The last line saying it is done means the script reached the end. It does not mean the data is right. Read the whole log, or at least search it, for messages about:
- users that failed validation or were skipped
- categories and the permissions applied to them
- threads and posts that could not be created
- private message archives
- uploads, avatars and attachment files not found
- invalid or unconvertible text
- missing relationships (posts whose thread or user no longer exists)
- timestamps
- BBCode that did not convert
- group permissions
grep -inE 'error|fail|skip|not found|invalid|missing' /var/discourse/shared/standalone/import/vbulletin-import.log | less
Every repeating warning has a cause. Find it during rehearsal, not after launch.
20. Timestamp issues
vBulletin 3 stores dates as Unix timestamps in dateline, joindate and similar columns. A Unix timestamp is an absolute instant; it has no time zone. Converting one to a Ruby time is conceptually Time.zone.at(timestamp.to_i).
The current importer passes timestamps through TZInfo using the TIMEZONE variable. Older importer revisions and older TZInfo versions did not always agree on how that should work, and some migration write-ups describe patching the conversion to get past errors or one-hour shifts. Do not apply those patches pre-emptively to current code. Instead, after a test import, compare a handful of posts across the year (including dates either side of a daylight-saving change) with their dateline values:
SELECT postid, dateline, FROM_UNIXTIME(dateline) AS server_local
FROM vb3_post
WHERE postid IN (1001, 250000, 415000);
Only if the import fails at the conversion, or the dates are visibly shifted, change it, and then the simplest correct fix is to treat the value as the absolute instant it already is.
21. Validate users
Compare against the source:
SELECT COUNT(*) FROM vb3_user;
The Discourse count will be lower, and that is often correct. The current importer skips accounts still in the “awaiting email confirmation” group (usergroup 3) that never posted and registered long ago; read the WHERE clause in import_users for the exact rule. Other legitimate differences include duplicate or invalid email addresses that Discourse will not accept, system accounts, and long-dead registrations. Explain the difference; do not just accept it.
Then check individual accounts by hand. Pick at least:
- an administrator
- a moderator
- an ordinary active member
- a banned user
- one of the oldest accounts
- one of the newest accounts
- a user with a non-ASCII username
For each, check the username, email, join date, groups, permissions, avatar, profile fields and that their posts are attributed to them.
Passwords need a decision. The importer stores each user’s vBulletin hash and salt in an import_pass custom field, but Discourse cannot verify those hashes by itself. With the third-party discourse-migratepassword plugin installed, members can log in with their old password. Without it, members use “forgot password” with the email address they had on the old forum. Either is fine. Members finding out on launch day is not, so decide early and test whichever you choose.
22. Validate categories and permissions
Discourse categories and vBulletin forum permissions do not map one to one. The importer derives category permissions from forumpermission bitmasks, which covers the common cases well. Test every kind of forum you had:
- public forums
- registered-only forums
- private forums for a special group
- moderator-only forums
- admin-only forums
- nested child forums
- read-only forums
- forums for special groups such as subscribers or staff alumni
Test as an anonymous visitor and as ordinary members in different groups, not as an administrator. Admins bypass most category restrictions, so an admin can browse the entire site and miss that the private forum is now public or the main forum is now invisible to members.
23. Validate threads and posts
SELECT COUNT(*) FROM vb3_thread WHERE visible <> 2;
SELECT COUNT(*) FROM vb3_post WHERE visible <> 2;
visible = 2 marks soft-deleted content, which the importer does not bring across, so compare against these filtered counts rather than the raw totals. Then open topics and check:
- titles, authors and timestamps
- the first post and the reply order
- sticky (pinned) and closed state
- moderated content arriving hidden rather than public
- BBCode, quotes with attribution, links and code blocks
- smilies and embedded media where you used them
- polls and their results
24. Check the beginning, middle and end of the database
Most people check the first page of the newest forum and stop. Sample the whole range of IDs instead, and pay attention to the end:
SELECT
postid,
threadid,
userid,
username,
title,
pagetext
FROM vb3_post
ORDER BY postid DESC
LIMIT 50;
The last rows in a long-lived forum are where you find spam that arrived after moderators stopped looking, posts created by a plugin, half-written records from a crash, and data from the forum’s final years that behaves differently from everything else. Pick some IDs from the middle of the range too, and open each one in Discourse.
25. Validate private messages
SELECT COUNT(*) FROM vb3_pm;
SELECT COUNT(*) FROM vb3_pmtext;
SELECT
pmtextid,
fromuserid,
fromusername,
title,
message,
dateline
FROM vb3_pmtext
ORDER BY pmtextid DESC
LIMIT 20;
Remember how the importer handles PMs. Every user who has rows in pm gets one closed, archived message from the system user, titled as their private message archive, with pm-archive-<userid>.txt attached. The file is YAML with one entry per message: sender, recipients, subject, body and date. Validation means:
- the number of archive messages matches the number of distinct users in
vb3_pm - a sample of archives contains the right messages for that user
- sender, recipients, subject, body and timestamp are correct in the sampled entries
- non-ASCII subjects and bodies read correctly in the file
If your community expects real, threaded conversations in Discourse, budget for custom work. The older vBulletin 4 importer does create Discourse PMs, but it expects vBulletin 4’s tables, so it is a starting point for that work, not a drop-in.
26. Migrate attachments
vBulletin 3 can store attachments in the database (attachment.filedata) or on disk, and forums that switched modes years ago can have both. The importer handles both: it looks for the file under ATTACHMENT_DIR in vBulletin’s per-user directory layout and falls back to filedata from the database if the file is missing.
That fallback is also why a wrong ATTACHMENT_DIR is easy to miss. If the directory is wrong and the database blobs are empty, the posts still import. They just have broken attachments.
Compare the source count:
SELECT COUNT(*) FROM vb3_attachment;
with the number of uploads Discourse created, and then open real attachments:
- images, both inline and attached
- PDFs
- ZIPs and other archives
- the largest attachments (check Discourse’s maximum attachment size settings before importing)
- the oldest attachments
- attachments referenced inline with
[attach]tags inside posts
Do not conclude attachments worked because posts imported. They are separate steps, and the importer only links attachments whose post still exists, so orphaned attachment rows are expected to drop out. Count them in the source so the difference is explained.
27. Legacy URLs and SEO
An old forum’s URLs are an asset. They carry search rankings, they are linked from other sites, they sit in members’ bookmarks, and thousands of them are inside the forum’s own posts. Typical vBulletin 3 addresses:
/showthread.php?t=12345
/showpost.php?p=67890
/forumdisplay.php?f=12
The importer creates Discourse permalinks for these three patterns and sets permalink_normalizations so that variants carrying extra query parameters (showthread.php?s=abc&t=12345&page=2) reduce to the stored form. When the old hostname points at Discourse, those requests answer with a redirect to the imported topic, post or category. You can review them under the permalinks page in Discourse’s admin settings.
Test them, do not assume:
curl -sI "https://forum.example.com/showthread.php?t=12345" | grep -iE '^(HTTP|location)'
curl -sI "https://forum.example.com/showpost.php?p=67890" | grep -iE '^(HTTP|location)'
curl -sI "https://forum.example.com/forumdisplay.php?f=12" | grep -iE '^(HTTP|location)'
Each should produce a single redirect to the right page. If the forum lived in a subdirectory, test with that path too. If it used an SEO URL plugin such as vBSEO, its rewritten URLs are not covered by these permalinks and need their own mapping.
Where an old URL has no meaningful equivalent, let it return 404. Redirecting every unknown URL to the homepage is tempting and wrong: search engines treat mass redirects to the homepage as soft 404s anyway, and visitors land somewhere that has nothing to do with what they clicked.
28. Watch web logs for old resources
After launch, the logs tell you which old URLs the outside world still uses. Look for patterns like:
/vb/attachment.php?attachmentid=...
/vb/image.php?u=...
/showthread.php?t=...
/showpost.php?p=...
They come from search crawlers re-checking indexed pages, social media crawlers, bookmarks, links on other sites and links inside old posts that were not rewritten. Grouping the 404s by path shows you whether a missing redirect pattern matters or is just a crawler working through old URLs. Attachment and avatar URLs have no permalinks, so expect those to 404. Add a mapping only where real visitors are arriving.
29. Post-import review
After each test import, look beyond the content:
- Sidekiq: the queue should drain, and failed jobs should be explained
- Rails logs (
/var/discourse/shared/standalone/log/rails/production.log) - Nginx logs (
/var/discourse/shared/standalone/log/var-log/nginx/) - uploads: total size, and sample files load
- SMTP: notifications and password resets arrive
- search returns imported content, once background indexing has caught up
- background jobs such as post rebakes complete
- user and category permissions as described above
- site settings the importer changes (category nesting, fixed category positions, permalink normalizations) are what you want for production
Open the site in a private window as an anonymous visitor as well. That is how search engines and most new arrivals will see it.
30. Run multiple rehearsals
One import is not a migration plan. Plan for at least three:
- Test import 1 surfaces encoding and database problems. Fix them in your repair scripts, not by hand.
- Test import 2 surfaces attachment, avatar and permission problems.
- Test import 3 is the final rehearsal, run exactly as production will be: fresh restore, scripted repairs, same importer revision, same configuration, timed.
Then the production cutover. By the third run, every step should be a script or a written command. If any step is “and then I fixed a few rows in phpMyAdmin”, it will be forgotten on the day.
31. Prepare cutover
Before the production import:
- lower the DNS TTL a day or more ahead if the hostname is changing servers (see DNS propagation in our knowledge base)
- announce the maintenance window to members, with the expected duration from your rehearsal timings
- close registrations
- disable posting
- put vBulletin into read-only or maintenance mode
- take the final database dump
- run the final sync of attachment and avatar directories
- record checksums
- record final source counts
The order matters. Anything posted after the final dump will not exist in Discourse, and nobody will notice until the member who wrote it does. Freeze first, then dump.
32. Final production import
The production run is the rehearsed pipeline, executed once more against the frozen data:
- Freeze vBulletin
- Final database dump
- Final attachment and file sync
- Restore a clean staging source
- Apply the documented encoding repairs
- Run the importer
- Let post-import jobs finish
- Validate
- Open Discourse
33. Go / no-go validation
Run through this before opening the site. A failed critical check means you delay, not launch and fix later. Data integrity problems found after members start posting are much harder to repair, because you can no longer just reimport.
| Area | Check | Critical |
|---|---|---|
| Users | Count matches the source, minus explained exclusions | Yes |
| Admins | Admin accounts exist and can log in | Yes |
| Moderators | Moderators have the right category moderation rights | Yes |
| Categories | Tree, order and nesting match the old forum | Yes |
| Private forums | Invisible to anonymous users and ordinary members | Yes |
| Threads | Count matches the source, excluding soft-deleted | Yes |
| Posts | Count matches; first, middle and last records checked | Yes |
| Encoding | Non-ASCII usernames, titles and posts read correctly | Yes |
| Private messages | Archive messages delivered; samples correct | Yes |
| Attachments | Counts explained; old, large and inline files open | Yes |
| Polls | Questions, options and results present | No |
| URLs | Sampled legacy URLs redirect to the right page | Yes |
| SMTP | Password reset and notifications arrive | Yes |
| HTTPS | Valid certificate on the final hostname | Yes |
| Search | Imported content is searchable | No |
| Background jobs | Sidekiq queue drained, no unexplained failures | Yes |
“No” in the last column does not mean optional. It means a known gap in that area can be fixed after launch without corrupting anything.
34. Switch production to Discourse
Point DNS at the Discourse server, or move the old hostname to it, then check from outside your own network:
- DNS resolves to the new server
- HTTPS is valid
- outgoing mail is delivered to an external mailbox
- anonymous browsing works
- login works, with whichever password approach you chose
- password recovery works
- a member can create a topic
- a member can reply
- a member can upload a file
- legacy URLs redirect correctly on the live hostname
Keep the original vBulletin installation offline but intact through an agreed acceptance period. Do not wipe the old server the day Discourse goes live. If you discover a forum whose permissions were wrong or a set of attachments that never came across, the original data is what you will fix it from.
35. Post-migration monitoring
The first days after launch are when edge cases show up, because real members do things no rehearsal did. Watch:
- 404 and 500 rates
- Sidekiq failures
- SMTP errors and bounces
- CPU, RAM and disk
- PostgreSQL and Redis
- upload storage growth
- login failures and password-reset volume
- legacy URL traffic and which old paths still get hits
Our knowledge base has a guide to monitoring a VPS if you do not have this in place already.
Common problems
Wrong table prefix
Table 'vbulletin_stage.vb_user' doesn't exist
while the real table is vb3_user. TABLE_PREFIX is unset (so it fell back to vb_) or wrong. Set it to the exact prefix, or to "" for none (section 15).
Missing setting table
Table 'vbulletin_stage.setting' doesn't exist
on a prefixed database. The current importer reads setting and usergroupleader without the prefix. Confirm in the source, then use staging-only views or a small patch (section 16).
mysql2 is missing
cannot load such file -- mysql2 means the import container was built without the mysql-dep template, or you are running the importer in the app container. Check import.yml and rebuild.
php_serialize is missing
The script prints php_serialize not found. and exits. Add php-serialize through an after_bundle_exec hook in import.yml and rebuild (section 17).
Greek or other non-English text is corrupted
The source bytes do not match their declared character set, or a past conversion already double-encoded them. Inspect with HEX(), identify the real encoding per column, and repair on staging before importing. The full method is in How to Fix Latin1, CP1253 and UTF-8 Encoding Before a vBulletin Migration.
Attachments are missing
Check ATTACHMENT_DIR points at the directory that contains the per-user digit folders (1/2/3/...), that the files are readable inside the container, and whether the forum ever stored attachments in the database. Look for file-not-found messages in the log.
Import stops on one specific post
Find the post the log names and look at it directly:
SELECT
postid,
threadid,
username,
title,
pagetext
FROM vb3_post
WHERE postid = 123456;
SELECT HEX(pagetext)
FROM vb3_post
WHERE postid = 123456;
Common causes are invalid byte sequences in an otherwise clean column, a post far longer than usual, or malformed BBCode. Fix the row in your repair script so the fix is repeatable. Do not hand-edit staging and forget.
Lessons learned
- Never modify the only source database. Keep the original dump untouched and work on restores.
- Never trust charset labels. Inspect the stored bytes.
- Rehearse before production. Run at least one full rehearsal before the real migration.
- Check attachments separately. Imported posts do not prove that attachments migrated.
- Do not ignore PMs and permissions. These are where members notice problems first.
- Preserve valuable legacy URLs. Inbound links and rankings took years to earn.
- Change one variable at a time while debugging. Otherwise you will not know which change fixed it.
- Make every database repair reproducible. A fix that is not in a script will be missing from the production run.
- Treat the migration as a data-engineering project. It is not a software upgrade.
Frequently asked questions
Can vBulletin 3 be migrated directly to Discourse?
Yes. Discourse ships a dedicated importer for vBulletin 3, script/import_scripts/vbulletin3.rb, which reads the vBulletin MySQL database directly and writes into Discourse. You do not need to upgrade to vBulletin 4 or 5 first, and you should not use the older vbulletin.rb importer, which was written for vBulletin 4.
Will existing user accounts be preserved?
Accounts, emails, join dates, group memberships, avatars, admin and moderator status, and bans are imported. The importer deliberately skips old accounts that never confirmed their email address and never posted, so the Discourse user count is usually a little lower than the vBulletin count.
Do users keep their existing passwords?
Not on their own. The importer stores each user’s vBulletin hash and salt in a custom field, but Discourse can only check those hashes with the third-party discourse-migratepassword plugin installed. Without it, members log in by using the password reset link with the email address they had on the old forum.
Can private messages be migrated?
The vBulletin 3 importer does not turn old private messages into Discourse conversations. Each user who had private messages receives one system message with a text file attached that contains their full message archive. If you need real threaded conversations in Discourse, that requires custom importer work.
What happens to vBulletin attachments?
They become Discourse uploads embedded in the posts that referenced them. The importer reads files from the vBulletin attachment directory and falls back to file data stored in the database, so it handles both storage modes. Always compare attachment counts and open a sample, because a missing directory produces imported posts with broken attachments rather than a failed import.
How long does a large migration take?
The import itself scales with posts and attachments. The author of the vBulletin 3 importer reported about 5.5 hours for roughly 7,000 users and 415,000 posts on a workstation, and their forum was offline for about 30 hours in total. Planning, encoding work and rehearsals usually take far longer than the final run.
Will old vBulletin URLs continue to work?
Thread, post and forum URLs can. The importer records Discourse permalinks for showthread.php, showpost.php and forumdisplay.php addresses and sets normalization rules for their query strings, so those old links answer with a redirect as long as the old hostname points at Discourse. Attachment and avatar URLs are not mapped and should return 404.
Why does character encoding cause problems with old forums?
Many vBulletin 3 forums stored text in latin1 columns while browsers submitted UTF-8 or a regional code page such as Windows-1253. MySQL’s charset label then no longer describes the stored bytes, and any conversion that trusts the label turns readable text into mojibake. The bytes have to be inspected and the repair proven on a copy before importing.
Migrating a large vBulletin community?
A forum that has been online for many years holds far more than its front page suggests. Encoding problems, legacy plugins, custom permissions, attachments and years of accumulated records all shape how the migration goes.
GOZEN Host can help with migration planning, staging infrastructure, database preparation, data validation and the vBulletin to Discourse migration itself. If you are planning to move an established community, talk to our team before making changes to the production forum. Discourse runs well on our VPS hosting, and the migration checklist is free whether you host with us or not.
Your Hosting Partner of Choice.
Migrating a large vBulletin community?
We help with migration planning, staging servers, database preparation and validation for vBulletin to Discourse moves.
Manos P. writes about hosting infrastructure, performance, and migrations at GOZEN HOST LLC, a Top 25 WordPress Hosting Provider for 2026 (HostAdvice).
Last updated: Oct 06, 2026