Skip to content

Migrating the Database Backend

Christopher Holloway edited this page Oct 21, 2026 · 20 revisions

Migrating to a different database backend

Source of truth: hmailserver/docs/MigratingDatabaseBackend.md, kept in the repository and copied here. Re-copied from master 92b8f9f63 on 26 September 2026, and brought up the same day to the document as the tag v6.3.4 (51e2dbda0) has it, which the tag v6.3.6 (e3c448aa6) leaves unchanged (MigratingDatabaseBackend.md at v6.3.6, read again on 5 October 2026): an upgrade from 6.3.3 on too old a MySQL or MariaDB stops at step 6041, before anything has changed. Re-copied on 18 October 2026 from the document as the tag v6.3.7 (31d656c0c), the release, tagged and published on 17 October 2026, has it (MigratingDatabaseBackend.md at v6.3.7), which names the Control Panel's page Maintenance → Backup, restore & migration. What it says about the settings store (schema 6042), database TLS and utf8mb4 on MySQL and MariaDB (schema 6102) is new in 6.3.4; where 6.3.3 differs, the page says so. The comparison table under Choosing a target is this wiki's arrangement of the repository's, and the line under it that points to Database TLS is this wiki's own. Re-copied on 20 October 2026 from the document as master has it from batch50, 0c0b6bae7 (MigratingDatabaseBackend.md at 0c0b6bae7): step 3 runs DBSetup.exe - the wizard's Create a new hMailServer database, or DBSetup.exe /unattended - which creates the database and its schema, writes the [Database] section, the password encrypted, and restarts the server on the new database, the login allowed to create a database and the database not existing yet, where it said to create an empty database, point hMailServer.ini at it by hand and run DBSetupQuick.exe, which made no usable database there; the short version, the account of who creates the database under MySQL and MariaDB and step 4's first open of the new database follow it; the PostgreSQL row says that SQL Server's, PostgreSQL's and SQL Server Compact's DDL is transactional, so that a failed schema upgrade is taken back whole, and that only MySQL and MariaDB commit each step's DDL as it runs, where it called PostgreSQL the only one of the four; and the note after the restore says that the schema is created in no transaction on every backend, where it said so of MySQL and SQL Server Compact alone. This wiki's comparison table and its flowchart of the procedure say the same, the table with the one exception that Upgrading.md makes and the document's PostgreSQL row leaves out: on SQL Server Compact, a chain from a database older than 6072 that crosses 6107 has its steps before 6107 committed, so that a failure from 6107 on leaves the database at 6106 (Upgrading Guide). The document's links to the repository's other documents point at them as 0c0b6bae7 has them, or at this wiki's copy where there is one. This wiki's own additions are the flowchart of the procedure under The short version; the diagram of the ids under Why this works; under Before you start, the note that a restore refuses an archive taken by a newer version, the table of the [Database] keys and the note on the machine-bound password; under step 2, the sentence on the backup's log and its verification, the table of the combinations a restore refuses, which stands in for the document's one example of them, the note on BackupMessagesDBOnly on 6.3.3 and the paragraph on why the database-only mode is close to mandatory on a migration; under step 4, the two lines of the script that show what the archive holds, the diagram of what the restore does and the note on the staged directory; the command that counts each domain's accounts, aliases and lists, and its output, under Verifying it worked; the sentence on the stored settings under Rolling back; the notes on the restore emptying the data directory, on the password on another machine and on InnoDB under Known sharp edges; and the pointer to moving to a new machine under What this does not cover. The paragraph on where a message file lives is this wiki's wording of the document's, and gives the public-folder directory as the code names it, #Public (IMAPConfiguration::GetPublicFolderDiskName), where the document at 0c0b6bae7 says .Public_Folder.

How to move an existing installation from one of the four supported databases to another — in practice, off SQL Server Compact, which the installer still chooses by default and which Microsoft stopped maintaining a long time ago.

There is no migration tool, and that is not an omission. Backup and restore are already backend-agnostic, so the migration is three operations you already have: back up, repoint, restore. What was missing was this document.

Read Upgrading Guide instead if you are moving to a newer release of the server. That is a different operation and it keeps your backend.

The short version

  1. Stop the service.
  2. Take a backup with domains, settings and messages selected.
  3. Run DBSetup.exe, which creates the database and its schema on the new backend and points hMailServer.ini at it.
  4. Start the service and restore the backup.

Your mail files never move. Nothing is exported to an intermediate format you have to trust.

flowchart TD
    A["Hold inbound mail, or accept<br/>that anything arriving during the<br/>window is lost to the new database"] --> B["Stop the service"]
    B --> C["Start it again and take the backup:<br/>domains + settings + messages"]
    C --> D{"Is the mail store large?"}
    D -- yes --> E["Set BackupMessagesDBOnly=1<br/>BEFORE the backup, and leave it set<br/>for the restore. The .eml files stay<br/>where they are and are never touched.<br/>Since 6.3.4: stored, not typed into the ini"]
    D -- no --> G
    E --> G["Run DBSetup.exe: Create a new<br/>hMailServer database. It makes the<br/>database and its schema, writes the<br/>Database section of hMailServer.ini<br/>and restarts the server on it"]
    G --> K["LoadBackup, tick all three<br/>restore options, StartRestore"]
    K --> L["The server reinitializes itself"]
    L --> M["Verify: counts, IMAP folders,<br/>one whole message body,<br/>settings, send and receive,<br/>an empty ERROR log"]
    M -- "something is wrong" --> N["Put the old Database section back<br/>and restart. The old database was<br/>only ever READ"]
    M -- "all good" --> O["Keep the old database until<br/>you are certain, then retire it"]
Loading

Why this works

Worth understanding before you run it, because it tells you what can and cannot go wrong.

Nothing in the archive is tied to a database. Neither BackupExecuter nor BackupRestorer looks at the database type — there is no branch on it anywhere in either. The archive is XML plus, optionally, the message files.

The XML carries no identity values. Account::XMLStore writes Name, Password, MaxAccountSize and the rest, and no account id; XMLLoad sets none. On restore, SaveObject sees an object whose id is 0, inserts it, and lets the new database assign its own. Relationships are rebuilt through the object graph — accounts under their domain, folders under their account, messages under their folder — not by raw id. So the fact that MSSQL and PostgreSQL will hand out completely different numbers than SQL CE did is not something the restore has to cope with. It never sees the old ones.

  In the OLD database              In the archive                In the NEW database
  ------------------               --------------                -------------------
  hm_domains                       <Domain Name="example.com">   hm_domains
    domainid = 17          --->      no id at all         --->     domainid = 1
      |                                |                            |
  hm_accounts                        <Account Name="alice">      hm_accounts
    accountid = 412        --->        no id at all        --->     accountid = 1
    accountdomainid = 17               nesting IS the link         accountdomainid = 1
      |                                |                            |
  hm_imapfolders                     <Folder Name="INBOX">       hm_imapfolders
    folderid = 9001        --->        no id at all        --->     folderid = 1
      |                                |                            |
  hm_messages                        <Message                    hm_messages
    messageid = 88231                   Filename="{guid}.eml">     messageid = 1
    messagefilename ------------------  the SAME name ----------->  messagefilename
                                        |
                                        v
                            <DataFolder>\example.com\alice\AB\{AB34...}.eml
                              never moved, never renamed

The message store on disk is addressed by name, not by id. A message file lives at

  <DataFolder>\<domain>\<local part>\<xx>\{guid}.eml

where <xx> is the first two characters of the guid itself - the file name includes the braces, and the subfolder is taken from just after the opening one. A public-folder message lives at <DataFolder>\#Public\<xx>\{guid}.eml.

Those paths are built from the domain and account names and the file's own guid. Change every id in the database and every file is still exactly where the server will look for it.

That is the whole of it. The migration is possible because nothing identity-shaped crosses the archive boundary, which is also why it can cross a backend boundary.

Choosing a target

MS SQL Server PostgreSQL MySQL / MariaDB SQL Server Compact
[Database] Type MSSQL PostgreSQL MYSQL MSSQLCE
[Database] Internal 0 0 0 1
Runs where Its own server, or LocalDB Its own server Its own server In-process, one file
DDL in a transaction Yes Yes No, each step's DDL commits as it runs Yes
So a failed schema upgrade leaves Nothing applied Nothing applied The steps before it applied, and perhaps part of it (Upgrading Guide) Nothing applied, but for a chain from a database older than 6072 that crosses 6107, which a failure from 6107 on leaves at 6106 (Upgrading Guide)
Isolation the server uses READ UNCOMMITTED its default its default its default
Transactions used at all yes yes only if every table reports InnoDB at connect time yes
Encryption in transit Install the Microsoft OLE DB Driver for SQL Server: through MSOLEDBSQL the whole session is encrypted by default, the certificate unchecked, and TlsMode=verify-full under [Database] checks it against the Windows certificate store; without it the server falls back to SQLOLEDB, which encrypts only the login unless TlsMode asks for more (Database TLS) TlsMode=verify-full with TlsCaFile=<CA file> under [Database] - or libpq's own PostgreSQLSslMode and PostgreSQLSslRootCert, which win when set; without them libpq encrypts when offered and verifies nothing Encrypted whenever the server offers TLS, made without it when the server has none; TlsMode=verify-full and TlsCaFile require TLS and check the certificate. Until 25 September 2026 it was never encrypted through MariaDB Connector/C - the bundled client on Windows and Ubuntu's on Linux n/a - no network
Choose it when You already run SQL Server, or you want the strongest transaction support of the four You want the best-behaved backend in this codebase You already run it - MySQL 5.7.9 or MariaDB 10.2.2 or later, every table utf8mb4 (see MySQL and MariaDB below) Only if you are migrating to a test rig

Encryption in transit, backend by backend, is in full on this wiki's copy of DatabaseTls.md, Database TLS.

SQL Server Compact is the default and it is the one real dependency liability in this tree; see the SQL Server Compact row in Roadmap.md for the three defects that are specific to it.

The schema is created for you either way — you do not need to run the SQL scripts by hand.

MySQL and MariaDB

MySQL 5.7.9 or MariaDB 10.2.2, or later. Since schema 6102 every table is utf8mb4 under utf8mb4_general_ci, InnoDB, with ROW_FORMAT=DYNAMIC, and a key over a varchar(255) - an account's address, a folder's name - is 1020 bytes at four bytes a character. That is more than an InnoDB key part may be in the older COMPACT rows (767 bytes) and more than a whole MyISAM key (1000), and those two releases are the first whose InnoDB, as it ships, takes a key part of up to 3072 bytes and makes its tables DYNAMIC. The longest key in the schema is 1060 bytes. The schema was proven on MySQL 5.7.44 and 8.4.11 and on MariaDB 10.6.28, 11.4.13 and 11.8.9 on 25 September 2026. On an older server as it ships, the create script stops at its first table, and an upgrade from 6.3.3 at the first step, 6040 to 6041, whose opening statement makes a probe table with such a key (6101 opens with the same probe, and 6102 with hm_accounts) - with error 1071, "Specified key was too long; max key length is 767 bytes", on a MySQL 5.7 set up as 5.6 shipped - and the upgrade has then changed nothing.

Why utf8mb4. MySQL's utf8 is utf8mb3: three bytes a character, so nothing outside Unicode's Basic Multilingual Plane fits in it - no emoji, and none of the CJK Extension B characters some names are written in. The server has always spoken utf8mb4 to MySQL (SET NAMES utf8mb4), so before schema 6102 such a character in a contact, an address book, a folder, a subject in the message index, a webmail setting or a signature was refused with error 1366 ("Incorrect string value") under strict mode, the default since MySQL 5.7.5 and MariaDB 10.2.4, and was made a question mark where strict mode is off. SQL Server, SQL Server Compact and PostgreSQL always stored it.

Why utf8mb4_general_ci. It is the twin of utf8_general_ci, the collation the tables had: case-insensitive and accent-insensitive, and the same answer for every character a utf8 table could hold, so an address, a name or a folder compares as it did and no unique index sees a duplicate it did not see before. The Unicode collations would not: utf8mb4_unicode_ci takes ß for ss and a full-width letter for its ordinary one, and utf8mb4_0900_ai_ci exists only on MySQL 8 and counts trailing spaces. What utf8mb4_general_ci does not do is tell characters outside the Basic Multilingual Plane apart: it gives them all one weight. Two address books of one account called Family 👪 and Family 🎉 are one name to its unique index, and the second is refused as a duplicate - as it was refused before, when neither could be stored at all. Three columns compare exactly (utf8mb4_bin), as they did in utf8_bin: a known sender's address, the address an automatic reply went to, and the domain of a day's statistics.

The database and the account. On Windows, DBSetup makes the database itself, utf8mb4, in step 3 - and refuses a name that exists, since the backend refuses its CREATE DATABASE - so what you make beforehand is the account, with every privilege on the database's name, which on MySQL and MariaDB also lets it create the database:

GRANT ALL PRIVILEGES ON hmailserver.* TO 'hmailserver'@'%';

On Linux, hmailserver --create-database makes the database the same way, and also makes the schema in an empty database that exists already; one you make yourself, make utf8mb4 too:

CREATE DATABASE hmailserver CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

Every table names its own character set, so a database made with another default still works, but the upgrade steps create, alter and index tables, and the 6102 step also changes the database's own default character set, which needs the ALTER privilege on the database.

Before you start

Take a filesystem-level backup of the whole installation as well. Not because the restore is unreliable, but because it is the only thing that will get you back if you discover a problem after the old database has been retired. See Rolling back.

Check that your messages are all inside the data folder. Backup refuses to run if any message row points at a file outside DataFolder — it fails with "All messages are not located in the data folder". This only happens on installations where messages were imported with absolute paths. If it fires, fix those rows before going any further; the migration is not the place to discover it.

Do not restore an archive taken by a newer hMailServer. The restore refuses it, by design and with no override:

This backup was taken by hMailServer X, which is newer than the Y running here. Restoring it would silently discard anything the newer version stores and this one does not. Upgrade this server to at least version X and restore again. Nothing has been changed on this server.

Migrate on one version, then upgrade - not the other way round.

Note your current settings. Open hMailServer.ini (normally C:\Program Files\hMailServer\Bin\hMailServer.INI) and keep a copy. The [Database] section is the part you are about to change:

[Database]
Type=MSSQLCE
Server=
Database=hMailServer
Username=
Password=
Port=0
Internal=1
Key Meaning
Type MSSQL, MYSQL, PostgreSQL or MSSQLCE, matched case-insensitively
Server Host name or instance. Empty for the internal engine
ServerFailoverPartner MS SQL mirroring partner, when you have one
Database Database name
Username / Password The login. See the DPAPI note below
PasswordEncryption Written by the server; says how Password is protected
Port 0 uses the backend's default
Internal 1 only for the embedded SQL CE engine
NumberOfConnections Pool size; default 5
ConnectionAttempts / ConnectionAttemptsDelay Retries at startup; defaults 6 and 5 seconds

The password in this file may be machine-bound. With [Settings] ProtectStoredSecretsWithDPAPI=1, which is the default, the server writes Password through machine-scoped DPAPI (CRYPTPROTECT_LOCAL_MACHINE) and records PasswordEncryption accordingly. It cannot be decrypted on another machine. If you are also moving hosts, set the new backend's password on the new machine - through DBSetup.exe, which writes the section with the password protected, or by writing the plain value with PasswordEncryption=0, which the server reads as it is and does not re-protect - rather than copying the encrypted one across.

The administrator password lives in this file too, not in the database, so it carries over untouched.

The procedure

1. Stop the service

Stop-Service hMailServer

Wait for it to actually stop before continuing. A backup taken while mail is being delivered is a backup of a moving target.

Restart it for the backup itself — the backup runs through the running server — but stop accepting new mail first if you can, either by stopping the SMTP TCP/IP port in the administration tool or by holding the traffic upstream. Anything that arrives after the backup and before the cutover is delivered into the old database and will not be in the new one.

2. Take the backup

In the Control Panel, under Maintenance → Backup, restore & migration, tick all three of Back up domains, Back up messages and Back up settings, set a destination with room for it, and click Start backup now. Or from a script:

$app = New-Object -ComObject hMailServer.Application
$app.Authenticate("Administrator", "<password>")
$b = $app.Settings.Backup
$b.BackupDomains  = $true
$b.BackupSettings = $true
$b.BackupMessages = $true
$b.Destination    = "D:\hmail-migration"
$app.BackupManager.StartBackup()

The archive lands in the destination folder as HMBackup <local time>.7z. That one file is the whole backup — the hMailServerBackup.xml index you may see appear next to it during the run is written into the archive and then deleted. $app.Settings.Backup.LogFile names the backup log, and the run verifies the archive before it counts as a backup at all: an archive that fails verification is deleted, or left in place with a log line saying it does not count.

All three of domains, settings and messages are required. The restore refuses combinations that would delete something and put nothing back, and it says so before it changes anything:

If you ask for And the archive has The restore says
domains no domains "Restore refused: this backup does not contain any domains, so restoring domains from it would delete every domain, account and alias on this server and put nothing back."
settings no settings "...would delete the SSL certificates, TCP/IP ports, IP ranges, global rules, blocked attachments and anti-spam lists and put nothing back."
messages no messages "Restore refused: this backup does not contain any messages."
messages without also asking for domains "Restore refused: messages can only be restored together with the domains they belong to."

Every one of those ends "Nothing has been changed on this server", and that is literally true: the checks run before the first deletion.

If your mail store is large, use the database-only mode. Set BackupMessagesDBOnly to 1 before both the backup and the restore — in the Control Panel, over the REST API (PUT /api/v1/settings/ini/BackupMessagesDBOnly with 1), or with hmctl ini set BackupMessagesDBOnly 1 — and restart the service, because it is read at start. It is stored in the database, which is the settings store since schema 6042, and is not typed into hMailServer.ini: a line added to the file is taken into the store only while the store has no row for it (a setting never stored, or one deleted since), and while it has one, an edit to the file is named in the error log (HM5804) and put back. This stores and restores the message rows — which is what you are actually migrating — while leaving the .eml files alone on disk. On an installation with a few hundred gigabytes of mail this is the difference between minutes and most of a day, and since the data folder is not moving, copying it out and back achieves nothing.

On 6.3.3, which comes before the settings store (schema 6042, since 6.3.4), BackupMessagesDBOnly is a line in the file: put

[Settings]
BackupMessagesDBOnly=1

in hMailServer.ini before both the backup and the restore, and delete the line afterwards.

This is not only a speed optimisation, and on a backend migration it is close to mandatory. A normal restore empties the live data directory and copies the staged message store into it (BackupExecuter::RestoreDataDirectory_ deletes every file and directory under DataFolder, then copies). With BackupMessagesDBOnly=1 there is no staged message store, that step is skipped entirely, and your .eml files are never touched - which is exactly what you want when the files are not moving anywhere. It is the difference between "the mail was never at risk" and "the mail existed only in a staging directory for an hour".

The setting has to be identical for the backup and the restore, and the restore runs against the new database, which has a settings store of its own. It comes across without being set again, the way every setting does (see step 4): the new database's store starts empty, and the first open of it - DBSetup's restart of the server, in step 3 - adopts the [Settings] section of hMailServer.ini, which is the server's copy of the old store and has held BackupMessagesDBOnly=1 since the moment it was stored. Check it on the new database before you restore (hmctl ini get BackupMessagesDBOnly, or the Control Panel); if the old server named it in HM5806 or HM5803 - its line could not be written into the file - set it again and restart. Remove it afterwards, in the same place, so your ordinary scheduled backups go back to including the files.

3. Create the new database

Run the database setup wizard from the installation's Bin folder, C:\Program Files\hMailServer\Bin\DBSetup.exe, and choose Create a new hMailServer database, then the backend, the server, a name for the database and the login. The wizard creates the database on the server, runs the create script in it, writes the [Database] section of hMailServer.ini through the service and restarts the server on the new database. So the login must be allowed to create a database - dbcreator on SQL Server, CREATEDB on PostgreSQL, every privilege on the database's name on MySQL and MariaDB (MySQL and MariaDB) - and the database must not exist yet: the backend refuses the wizard's CREATE DATABASE of a name that exists, and the wizard's other choice, Select a new hMailServer default database, creates no schema, so an empty database named that way is refused at the restart - the server reads no schema version from it (HM5010, "Database version could not be detected") - and /unattended with HM_CREATE_DATABASE=0 stops there, saying the database holds no hMailServer schema, without starting DBUpdater.

Without a window, the same step is DBSetup.exe /unattended - it needs no elevation here, since the service writes the file - with the database in the variables the installer's /db switches set and the administrator password in HM_ADMIN_PASSWORD (WindowsOnArm.md, A new installation without a window, has the table of them and the exit codes). It is a Windows program without a console, so the lines it writes go to the files the block names:

$env:HM_DB_TYPE = 'MSSQL'            # MSSQL, PostgreSQL or MySQL (MariaDB too)
$env:HM_DB_HOST = 'sql01.example.com'
$env:HM_DB_NAME = 'hMailServer'
$env:HM_DB_USER = 'hmailserver'
$env:HM_DB_PASSWORD_FILE = 'C:\secure\hmailserver-db-password.txt'
$env:HM_ADMIN_PASSWORD = '<administrator password>'
$setup = Start-Process -FilePath 'C:\Program Files\hMailServer\Bin\DBSetup.exe' -ArgumentList '/unattended' `
   -Wait -PassThru -RedirectStandardOutput "$env:TEMP\dbsetup.txt" -RedirectStandardError "$env:TEMP\dbsetup-error.txt"
Remove-Item Env:HM_ADMIN_PASSWORD
if ($setup.ExitCode -ne 0) { throw "Database setup failed with exit code $($setup.ExitCode): $(Get-Content "$env:TEMP\dbsetup-error.txt")" }

It checks every setting before it contacts the server, and a setting it refuses changes nothing; it exits with a non-zero code when the step fails, so it is safe to run from a script. Either way, the section it writes has this shape, the password encrypted rather than as typed (PasswordEncryption=6, the machine's secret store; Secrets at Rest):

[Database]
Type=MSSQL
Server=sql01.example.com
Database=hMailServer
Username=hmailserver
Password=<the password, encrypted>
PasswordEncryption=6
Port=0
Internal=0

Type is one of MSSQL, MYSQL, PostgreSQL or MSSQLCE, matched case-insensitively; Internal=0 is anything other than the embedded engine; Port=0 is the backend's default. This is the section Rolling back puts back by hand.

Not DBSetupQuick.exe. It creates only the built-in SQL Server Compact database, when [Database] names no type; once the section names any other backend it starts DBUpdater, the schema upgrader, which cannot build a schema from an empty database: on SQL Server and on MySQL and MariaDB its run stops before its first step, at the prerequisite of step 6107, which reads hm_dbversion - a table an empty database does not have (the error names the greylisting triplets' keys) - so nothing is made, and the server goes on refusing the database as one whose version it cannot read (HM5010); on PostgreSQL the first step's script, 0 to 1100, does not exist, and no path is built. Pointing hMailServer.ini at an empty database by hand and running DBSetupQuick, which this page said to do until 19 October 2026, made no usable database. Drop that database before step 3: DBSetup's CREATE DATABASE refuses a name that exists.

A service that runs as NT SERVICE\hMailServer may not write hMailServer.ini, so either run of DBSetup is refused before anything is made, and the refusal says what to do: move the service to LocalSystem for this step and back afterwards (The Service Account, Moving it, and going back).

4. Restore

When the server first opens the new database - at the restart DBSetup asks for at the end of step 3 - it finds the settings store empty and adopts every line of the [Settings] section of hMailServer.ini into it - the server's copy of the old store, as of the last time a setting was written into the file - and says so in the application log ("setting(s) from the [Settings] section of hMailServer.INI are now stored in the database"). A setting the old server could not write into the file, which it reported as HM5806 or HM5803, is not in that copy. Restoring with Back up settings, below, writes the archived settings over the adopted ones either way.

The service has been running on the new database since that restart, so Start-Service below does nothing on its own; restore the archive:

Start-Service hMailServer

$app = New-Object -ComObject hMailServer.Application
$app.Authenticate("Administrator", "<password>")
$backup = $app.BackupManager.LoadBackup("D:\hmail-migration\HMBackup 2026-08-13 220511.7z")

# Confirm the archive really holds all three before asking for them
'{0} {1} {2}' -f $backup.ContainsDomains, $backup.ContainsSettings, $backup.ContainsMessages

$backup.RestoreDomains  = $true
$backup.RestoreMessages = $true
$backup.RestoreSettings = $true
$backup.StartRestore()

What that does, in order, from BackupExecuter::StartRestore:

sequenceDiagram
    participant You
    participant R as BackupRestorer
    participant E as BackupExecuter
    participant DB as The NEW database
    participant FS as The data directory
    You->>R: StartRestore
    R->>R: Extract and read the index
    R->>R: Refuse an archive from a newer version
    R->>R: Refuse an option the archive cannot satisfy
    R->>FS: Stage the message store into a temp directory - skipped entirely under BackupMessagesDBOnly
    Note over R,FS: Everything above happens BEFORE anything is deleted
    R-->>E: Accepted
    E->>DB: Delete every domain
    E->>DB: Delete the public folders
    E->>FS: Empty the data directory and copy the staged store in - skipped under BackupMessagesDBOnly
    E->>DB: Load domains, accounts, folders and messages from the XML
    E->>DB: Load settings LAST, because they refer to the domains
    E->>E: Reinitialize the server
Loading

The server restarts itself at the end of a restore. That is expected.

If the copy in the middle fails, the staged directory is kept rather than cleaned up, and the failure message names it - at that moment it is the only copy of the messages that exists. Do not delete it until they are back.

Verifying it worked

Do all of these. The failure mode worth catching is a partial restore, which looks like a working server to anyone who only checks that it starts.

  • Counts match. Domains, accounts, aliases and distribution lists, compared against what you wrote down before you started:

    foreach ($d in $app.Domains) {
       '{0,-30} {1,4} accounts {2,4} aliases {3,4} lists' -f `
          $d.Name, $d.Accounts.Count, $d.Aliases.Count, $d.DistributionLists.Count
    }
    example.com                      42 accounts    7 aliases    3 lists
    example.net                       6 accounts    0 aliases    0 lists
    
  • Mail is visible. Log in over IMAP as a real account and check that the folder list and the message counts are what they were. This is the one that proves the message rows and the files found each other again.

  • A message opens. Fetch a whole message body, not just a header — that proves the row's file path resolves on disk under the new ids.

  • Settings survived. SSL certificates, TCP/IP ports, IP ranges, routes, global rules and the anti-spam lists. These are the part of the backup that is easiest not to notice is missing.

  • Send and receive. One message in, one message out.

  • The error log is empty. hmailserver_ERROR_<date>.log in the log folder. Anything in it that appeared during the restore is worth reading before you retire the old database.

Rolling back

Until you delete the old database, rollback is exact and takes a minute: stop the service, put the original [Database] section back in hMailServer.ini, start the service. The old database is untouched by everything above — the migration only ever read from it. Since 6.3.4 that includes its stored settings: the rolled-back server runs on the old database's values and rewrites the file's [Settings] section to match where the service may write the file, so a setting changed on the new database since the cutover is undone, and may be named in the error log as HM5804 on that start.

The settings come back with it. The file's [Settings] section now holds what the new server stored, so at that start each setting changed since the cutover is named in the error log as HM5804 and put back to the old database's value - which is the rollback working, not a fault - and a setting first stored on the new server, which the old database has no row for, is adopted into it.

Keep it until you are satisfied, and remember that any mail that arrived after the cutover exists only in the new database. That is the one thing rolling back loses, and it is why the cutover is worth doing during a quiet window with inbound traffic held.

There is no downgrade path for the schema, but that does not apply here: you are not changing schema version, only which server holds it.

Known sharp edges

Any mail delivered between the backup and the cutover is lost to the new database. Nothing in the process merges the two. Hold inbound traffic, or accept the gap knowingly.

Without BackupMessagesDBOnly=1, the restore empties your data directory. It deletes every file and directory under DataFolder and copies the staged store in its place. On a backend migration - where the files are not moving anywhere - that is a large, slow, entirely unnecessary risk. Set the flag.

Ids change, and anything of yours that stored one will break. Nothing inside the server depends on id continuity, which is the entire basis of this document — but if you have external scripts, reporting or an integration that recorded hm_accounts.accountid or hm_domains.domainid, those values will not mean the same thing afterwards. Key on the address or the domain name instead.

The database password does not survive being copied to another machine when DPAPI protection is on, which it is by default. Set it on the machine that will use it.

The SQL log device's table is not part of the backup. If your logging device is SQL (Logging.Device = hLogDeviceSQL, the Control Panel's Logging page), historical log rows stay in the old database. Export them separately if you need them; they are ordinary rows in hm_log.

The schema is created in no transaction, on every backend. The create script runs statement by statement, so a creation in step 3 that fails part-way leaves the database partly built, on all four. Drop it and start step 3 again rather than re-running the tool over the remains, which it refuses anyway: the backend refuses a CREATE DATABASE of a name that exists.

MySQL needs InnoDB throughout. The server only issues transactions if every table reports InnoDB at connect time, and silently does not if any table does not. Check after the schema is created, not after the restore.

What this does not cover

Moving the data folder to different storage, which is a separate operation and is not coupled to the backend at all — the path is [Directories] DataFolder, and the only requirement is that the service account can reach it.

Moving to a new machine, which is covered in Upgrading Guide under Special cases, and which is where the DPAPI note above matters most.

Home — full index

Start here

1. Install and run

2. Secure it

3. Operate it

4. Extend it

5. Contribute to it

Look it up — from any journey


About this wiki

Clone this wiki locally