Repository navigation
Upgrading Guide
Source of truth: hmailserver/docs/Upgrading.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), the release, has it: the installer's
HM_ADMIN_PASSWORD, the named lock that serialises the schema steps on MySQL and MariaDB, and the probe that stops an upgrade from 6.3.3 at step 6041 on too old a MySQL or MariaDB, with the repair of a database an earlier build left half done. The flowchart of one run, the exit codes, the backends table, the notes for contributors and the corrections under Verified against the code are this wiki's own additions.
How to move an existing hMailServer installation to this fork, or from one release of it to the next, and what the database upgrade actually does. Written because the upgrade is the one operation where an unpleasant surprise costs you mail rather than time.
This is the mechanism. For the step-by-step procedure, the per-step cost table and the decision tree, see Upgrading hMailServer.
Run the new installer over the existing installation. It preserves your configuration and your mail, stops the service, upgrades the database schema, and starts the service again. There is no separate migration step and no export and re-import.
Take a backup first anyway. Not because the upgrade is unreliable - the chain below has run continuously since hMailServer 4 - but because the one thing an upgrade cannot do is undo itself. See Rolling back.
Sealed secrets are off after an upgrade until you turn them on. From schema 6103
each account's second factor and the [Settings] secrets can be sealed in the
database under the installation's stored-secrets key, but an upgrade makes no key:
the server goes on storing them as written, says so in the application log at each
start, and the setup checklist offers the step. To turn sealing on, from an elevated
prompt on one server (sudo hmailserver ... on Linux):
.\hMailServer.exe --create-stored-secrets-key
.\hMailServer.exe --export-stored-secrets-key D:\Escrow\hmailserver-stored-secrets.key
Restart-Service hMailServerthen --import-stored-secrets-key the same file on every other server that shares the
database, and restart each. Keep the exported file with the database backups, stored
apart from them: from then on a database-level backup restored without it opens none
of those secrets (SecretsAtRest.md). A new installation's installer
makes the key itself and says to export it.
The upgrade chain is continuous, from schema version 0 through to whatever
version this build requires, applied one step at a time. A database at any
intermediate version is brought forward step by step, so there is no "you must
first upgrade to 5.x" hop to plan around.
The step count and the target version are deliberately not written here.
They have gone stale on this page four times now, and a version number is
exactly the sort of fact a reader trusts without checking. Read them from the
tree instead: REQUIRED_DB_VERSION in
hmailserver/source/Server/Common/Application/Constants.h is the version this
build demands, and build\check-schema-versions.ps1 prints both numbers and
proves the chain reaches one in exactly the other.
That includes databases created by the original upstream project. This fork did not branch the schema; it extended it. The current version is a superset, reached by the same mechanism upstream used.
How far back the chain reaches depends on your backend, and this is the one qualification worth knowing before you start. The steps are registered once, for every backend, but the SQL scripts they run are per-dialect and they do not all exist:
| Backend | Steps that ship | Reaches back to |
|---|---|---|
| MySQL / MariaDB | every step | schema 0 (hMailServer 1.0) |
| Microsoft SQL Server | every step | schema 0 (hMailServer 1.0) |
| PostgreSQL | every step from 5001 onwards |
schema 5001
|
| SQL Server Compact (internal) | every step from 5001 onwards |
schema 5001
|
In practice that is not a gap, because neither PostgreSQL nor the internal SQL CE
database was a supported backend before hMailServer 5, so no database older than
5001 exists on either. But it is why the answer is "any version of your
backend" rather than "any version". If a step's script is missing, DBUpdater says
so by name and stops before touching anything - it checks that every file on the
path exists before it runs the first one.
flowchart TD
A["Read hm_dbversion"] --> B{"Newer than this build requires?"}
B -- yes --> C["Refuse by name: a database from a newer<br/>version, and downgrading is not supported"]
B -- no --> D{"Equal?"}
D -- yes --> E["Nothing to do, exit code 0"]
D -- no --> F["Walk the registered steps from the current<br/>version, bounded by the number registered,<br/>and check every script file exists"]
F -- "no step is registered for this version" --> G["Refuse by name"]
F -- "a script file is missing" --> H["Refuse, naming the file"]
F --> I["BEGIN TRANSACTION"]
I --> J["Prerequisites script<br/>MS SQL and PostgreSQL only"]
J --> K["For each step in order:<br/>EnsurePrerequisites, then that step's<br/>script for this dialect"]
K -- "a statement throws" --> L["ROLLBACK where the backend can,<br/>show the script and the error"]
K --> M["Run the schema probes on the SAME<br/>connection, so they see uncommitted DDL"]
M -- "a probe is not satisfied" --> N["ROLLBACK where the backend can,<br/>naming the object that is missing"]
M --> O["COMMIT"]
O --> P["Read hm_dbversion back"]
P -- "not the required version" --> Q["Report: the scripts ran without error,<br/>but the database is at the wrong version.<br/>Nothing can be rolled back from here"]
P --> R["Delete the stale ERROR log, exit code 0"]
Three parts of that are worth understanding rather than skimming.
The whole path is validated before the first statement runs. Building the path also proves each file exists; a missing dialect script therefore stops the upgrade while the database is still untouched, not halfway through.
The probes exist because of a specific trap. Three of the four dialects mark
their ALTER TABLE statements [IGNORE-ERRORS] so that re-running a step is
harmless. The data-access layer matches that marker anywhere in the statement and
then discards every error - while the update hm_dbversion statement in the same
script carries no marker and always runs. A marked ALTER that failed for a real
reason would therefore stamp the new version onto a database that does not have the
column. Each such step now carries a probe that must be satisfied before the commit,
and the probes run on the transaction's own connection so they can still see
uncommitted DDL. build\check-schema-versions.ps1 is what makes sure a probe exists
to run.
The version is read back rather than assumed. hm_dbversion is written by a
statement of its own, in the same script as the schema change but independent of
whether it worked, so the last thing DBUpdater does is re-query it. Nothing can be
rolled back at that point, so a mismatch is reported and the ERROR log is
deliberately left in place rather than cleaned up.
Its exit codes, which is what a scripted install actually reads:
| Code | Meaning |
|---|---|
| 0 | Success, or the database was already at the required version |
| 1 | The upgrade failed |
| 2 | A configuration error: unknown database type, no script directory, or no registered path to the required version |
| 3 | Authentication was cancelled - or, under /silent, could not be satisfied |
- The schema only. Messages live on disk, not in the database - the database holds accounts, domains, settings and message metadata. No upgrade step rewrites message files.
- Every supported backend. Separate script sets exist for MySQL/MariaDB, MS SQL Server, PostgreSQL and the embedded SQL CE. The installer picks the set matching your configured backend.
-
Nothing is deleted that carries live data. One table has been dropped in the
chain's history and it is worth knowing about because a schema comparison will
show it:
hm_adsynchronization, created by step3402 -> 4000(which ships for MS SQL and MySQL only) and dropped by step5004 -> 5005on all four dialects. NoCreateTablesscript creates it on any backend, so a database that came through the chain and a freshly created one agree about it afterwards. If you do find a table a fresh database does not have, it came from somewhere other than this chain. -
Not
hMailServer.ini- with one change since 6.3.4. DBUpdater never reads or writes the file's settings. Since 6.3.4, schema 6042 makes the database the store for the[Settings]section, and the move itself is done by the server at its first start afterwards: every key in the section that the database has never held is taken into it, and from then on the section is a copy the server rewrites and an edit to it is undone (HM5804). A value typed into the file after the old server last started, and never picked up by it, is one of those undone edits - restart the old server once before upgrading. The rules are in Settings Overview.
An upgrade keeps the account the service runs as - LocalSystem, for an
installation that began before 25 September 2026 - unless the installer's
Service account page is answered otherwise; a new installation runs it as the
virtual account NT SERVICE\hMailServer. The page offers the way each side, and
/serviceaccount=virtual|localsystem|keep does the same for a silent upgrade.
A move is made after the database is upgraded, and only once the service's folders
are granted cleanly: an attended installation asks about any folder or key file
the configuration names outside the program folder, a silent one leaves the
service on LocalSystem when there is one. Under the virtual account the live
update is checked and an administrator installs it from the release page, since
the service cannot run an installer itself, and hMailServer.INI is read-only to
the service - the administrator password is then changed with
hMailServer.exe /SetAdministratorPassword. ServiceAccount.md
has the rest.
The chain is the same everywhere; what a failed step leaves behind is not.
| Backend | DDL in a transaction | So a failed upgrade leaves |
|---|---|---|
| PostgreSQL | Transactional | The database exactly as it was. This is the best-behaved of the four |
| Microsoft SQL Server | Transactional for most DDL; the server also rolls back on its own for some errors | Normally as it was |
| MySQL / MariaDB | Commits DDL as it executes | The steps that ran, applied. Re-running resumes from whatever hm_dbversion now says |
| SQL Server Compact | Commits DDL as it executes | The same |
This is why DBUpdater's messages say "rolled back where the backend allows it"
rather than promising a rollback it cannot deliver. On the two that commit as they
go, the recovery is to re-run: the chain resumes from the version the database
actually reports, and [IGNORE-ERRORS] makes a re-applied ALTER harmless.
-
Back up. Both halves, and they are separate things:
- the database - with your backend's own tools (
mysqldump,pg_dump, SQL Server backup). The built-in backup covers this too, but a native dump is what you want if you need to restore into a different server. - the data directory - the message files. This is the part that cannot be reconstructed from anything else.
- the database - with your backend's own tools (
-
Note your current versions. The server version is in the Control Panel. The
schema version is in a table of its own -
select value from hm_dbversion, a single-column, single-row table, not a row in the settings table. (The Control Panel shows it on the Server status page and DBUpdater reads it too; the SQL is here for when the server will not start and neither of them will connect.) Write both down - if you need support, they are the first two questions. - Run the new installer. It stops the service, installs, upgrades the schema, and restarts.
- Check it came up. The service should be running and listening on your configured ports. Then check the ERROR log for the window around the upgrade - an empty ERROR log is the signal you want.
- Send a message to yourself, end to end, and read it back over IMAP or POP3. A service that starts is not the same as a server that delivers.
Everything in this section is new in 6.3.4.
Since 23 September 2026 an upgrade no longer needs a booked window: the server drains before it stops, and it can bring its own schema forward when it starts. What a single node can and cannot promise is written down at the end of this section, because the difference matters when you plan.
The sequence. Whether the live update runs it (POST /api/v1/update/install,
Status.InstallUpdate, or the unattended window) or you do:
-
Drain. The SMTP, IMAP, POP3 and ManageSieve listeners are closed and the delivery queue is paused. A connection attempted from now on is refused at the socket - not accepted and then cut - which every MTA on the internet treats as "try again later" and retries on its own schedule, so no mail is lost and no sender sees a bounce. The sessions already open finish at their own pace; a delivery already talking to a remote finishes, and a queued message not yet started stays in the queue, unlocked.
/readyzanswers503 not ready: draining; the metrics listener, the REST API and the web services stay up, so the Deck, a load balancer's probe and the update helper can all still see the server. The application log saysDrain: listeners closed; N session(s) and M delivery(ies) in flight; ....What a person sees, from the code: a mail client's IMAP, POP3 and SMTP submission are refused and retried by the client, and so is a Sieve client on ManageSieve; the webmail keeps working - it reads mail over the REST listener in-process, and its send (
POST /api/v1/me/messages) writes the message into the delivery queue rather than opening an SMTP session, so a message sent from it during the drain is accepted and waits, unlocked, in the queue until the drain ends; CalDAV and CardDAV are served by the web-services listener and keep answering; the Deck and every/api/v1route keep answering. -
Stop. The service stop finds the doors already closed and waits up to
ShutdownDrainSecondsfor what is still in flight (nothing, usually, by now: a stop after a drain does not wait again for what has already drained), then tears the servers down. WithShutdownDrainSecondsat its default of0the doors still close first, but nothing is waited for - set it on a production server (30 is a reasonable figure; the HA runbook says the same), or a session mid-DATA at the moment of the stop is cut. -
Install. The binaries are replaced. The Windows installer runs DBUpdater as it always has; the
.deband.rpmrunhmailserver --upgrade-databasein their post-install step; the container's entrypoint runs it at every start. -
The schema step, in the service's own start. When the database is older than the binary and
[Database]inhMailServer.inicarriesUpgradeOnStart=1, the service applies the upgrade chain itself as it starts - the same codehmailserver --upgrade-databaseruns, one step at a time, one log line per step (Schema: 6086 -> 6087 (Upgrade6086to6087MSSQLCE.sql)), each step in a transaction where the backend has one - and then continues the start. Without it, or when a step fails, the start is refused withHM5011as before, and the message names the failing step and the version the database is at; the steps before it have been applied and a second start resumes there. The key is in the file, not the settings store, by necessity: it has to be read before the database is at a version this build can open. It is off by default. Nothing is backed up first - Take a backup above is the rule, and this is the step that makes it matter. One node at a time: set it on the node you start first; a second node that starts against the same database reads the new version and does nothing. Two nodes that do start together are serialised step by step - on PostgreSQL and SQL Server by the version row, which each step locks (select ... for update,with (updlock, holdlock)) for the length of its transaction; on MySQL and MariaDB, whose first DDL statement in a step commits the transaction and frees that lock, by a named lock (GET_LOCK('hmailserver_schema_upgrade')) held until the step is over and waited on for up to four hours - so the second blocks, then finds the step done and moves on - but that is the net, not the plan. Until 26 September 2026 MySQL had only the row lock, so the second node ran the same step beside the first and failed on a table or a column already there. A server that refusesGET_LOCKaltogether - MariaDB Galera with wsrep on - still upgrades, with only the row lock, and the upgrade's output says so for each step: there, upgrade from one node at a time. -
Start.
/readyzanswers 200 again once the database probe agrees, and the load balancer routes to the node again.
What is refused, and for how long. From the drain to the moment the new version is listening: the length of the install plus the service's start, which is seconds to a couple of minutes on a single node, spent as refused connections, which senders retry. That is the honest limit of a single node. A rolling restart with no refusal at all needs a second node (section 7 of Roadmap2, the HA runbook's planned failover), and the drain is what that failover now uses.
Maintenance mode without an upgrade. The drain is a state of its own, with
three routes, all server-wide (manage_server to enter and leave, view_configuration
to read; refused to a domain-restricted or read-only key; recorded on the audit
trail as server / drain / entered and left):
POST /api/v1/server/drain enter: listeners closed, queue paused, /readyz 503 "draining"
GET /api/v1/server/drain {"draining":true,"since":"2026-09-23 10:15:02","sessions":1,"deliveries":0,"drained":false}
DELETE /api/v1/server/drain leave: listeners reopened on the same ports, queue resumed, /readyz 200
POST while draining, or when the server is not running, answers 409 with the
reason; so does DELETE when nothing is draining. drained is true once nothing
is in flight, which is when a stop would be instantaneous. If a port cannot be
reopened on DELETE - something else took it while the listeners were closed -
the answer names it in not_reopened, the error log carries HM7150, and the rest
are open; a restart tries every port again. If none could be reopened because the
io threads did not answer, the state stays draining and DELETE answers 500
with the same document and error: the listeners could not be reopened, so that
a running is never declared over closed ports; try again, or restart. The state
shows as draining in /healthz, hmailserver_state, the Deck's dashboard and
its update view (which carries Drain and Resume buttons), the Control
Panel's status page and Application.ServerState (hStateDraining, 5). A live
update refuses to apply while somebody else's drain is in progress ("already
draining"): leave it, or wait for it to end, and apply again. The update's own
drain is watched. The helper is meant to stop the service, after which the
outcome it writes is read at the next start. The helper writes it only once it
has seen the service running again, and the service says it is running before
it has loaded anything, so the first thing a start does is look: when the
helper's log in the Updates directory (apply-<version>.log, or on Linux the
apply.XXXXXX it writes until it exits) is newer than the last outcome
reported and there is no outcome yet, the start-up update task looks for it
every second, for UpdateServiceWaitSeconds plus two minutes, and reports it
as soon as it is there rather than at the quarter-hour run after. A helper that finishes without
stopping it - an installer that failed while the service stayed up, or one that
did nothing - or that never gets that far would otherwise leave the server
drained for ever. So after the helper is started the server polls for its
outcome every five seconds, for up to ten minutes (a constant,
UPDATE_APPLY_DRAIN_MINUTES in UpdateInstaller.cpp, not a setting): an outcome
written while the service is still running, or the ten minutes passing with no
stop, and the server leaves the drain itself, records left on the audit trail
as server, logs why, and reports the outcome then rather than at the next start
(HM7151 when the helper wrote nothing at all). A reinitialise
ends maintenance mode: SIGHUP, the Control Panel's Reinitialize, POST /api/v1/server/reinitialize, a backup restore and an ACME deployment all stop
and restart the servers in place, and the restarted servers accept again; the
server records the left event itself (actor server), so the trail pairs your
entered with what ended it. Apply now, on the Control Panel's pending bar
and the Deck's Settings view (SettingsApply.md), is a
reinitialise too: while the server is draining its question says the drain ends,
and POST /api/v1/server/reinitialize answers drained: true when it did.
On Windows, hMailServer.exe --upgrade-database now runs the same chain from
the command line, for all four backends, without DBUpdater's window or the
administrator password; it attaches to the prompt it was started from and exits
0 (done, or nothing to do), 1 (failed; the output names the step) or 2 (no
[Database] configured). Stop the service first. On Linux the option is what it
was; it now runs the backend's prerequisites script before the chain, as
DBUpdater always has.
What is thinner than a window-free upgrade. A single node refuses
connections for the length of the install; there is no rolling restart without a
second node; the in-process schema step does not run DBUpdater's after-the-fact
schema probes (SchemaVerification.cs), which the installer's path keeps; and the
drain's state is per process, so a warm-standby pair drains one node at a time.
The installer checks both that the database tool launched and its exit code, and
a non-zero exit code now fails the installation: the specific dialog naming the
exit code, and telling you to re-run DBSetupQuick.exe, is shown, the service start is still attempted, and then setup raises and
finishes with a non-zero exit code of its own. A scripted deployment can therefore
tell, which it could not in earlier releases - the dialog used to be the only
signal and the wizard still reported success. If you script installs, treat a non-zero exit code, or a hm_dbversion that is still on
the old value afterwards, as the failure; do not treat "the installer finished" as success.
Two other things the installer refuses to do, both of which produce a failed install rather than a broken one:
-
It will not proceed if the service did not stop. The wait is bounded by
ShutdownDrainSecondsplus thirty seconds, derived from your own setting because that setting deliberately holds the stop open while sessions finish. If the service is still running when the file copy starts, Inno offers Retry/Ignore, and Ignore leaves the old binary in place while the schema moves forward - after which the server refuses to start at all. Refusing to move past the Ready page is the only point at which that outcome can still be prevented. -
It will not silently skip the .NET runtime. The bundled .NET 10 Desktop
Runtime is installed
/quiet /norestartwhen it is missing, and afterwards the probe is re-run rather than the exit code being interpreted - the only question that matters is whether the runtime is present now. A failure here is reported and named, and it matters because the database tools are themselves .NET applications.
If it does fail, the service may be installed but the schema only partly upgraded.
The server does not paper over that: on startup it compares the database's version
against the version it requires and refuses to run if they differ, in either
direction, reporting HM5011 with both numbers appended. The two messages are worth
knowing because they are what you will search for:
The database is too old for this version of hMailServer. Please run hMailServer Database updater (DBUpdater.exe) to upgrade it.The database is too new for this version of hMailServer. Please upgrade hMailServer.
Both are followed by a space and then
Database version: <n>, Required database version: <m>.
A third, Database version could not be detected. (HM5010), means hm_dbversion
could not be read at all - a connection or permission problem rather than a version
one.
For the first, run DBUpdater.exe from the installation's Bin folder - that is
the standalone updater, and it is what the error message names. (The installer drives
DBSetupQuick.exe for the same job during an install; either will bring the schema
forward.) It picks up from whichever version the database actually reports, so a
retry after a partial upgrade resumes rather than restarting. If it fails again, the
log names the step it stopped on, and that step number is the useful thing to put in
an issue.
The second message means the database has already been upgraded past this binary - you are running an older server against a newer schema. Install the matching or newer server rather than trying to downgrade the database.
There is no downgrade path, and this is the one genuine sharp edge in the process.
The schema upgrade is one-way: an older server will refuse to run against a newer
dbversion rather than misinterpret it, which is the correct behaviour but means
"just reinstall the old version" does not work on its own.
To roll back you need the pre-upgrade database backup. Restore it, then reinstall the older server. The data directory does not normally need restoring - message files are not rewritten by an upgrade - but if you restore an older database against a newer data directory, any mail that arrived after the backup will exist on disk with no metadata row, and will be invisible.
Which is the real argument for the backup: not fear of the upgrade, but the fact that rollback is only as good as the snapshot you took first.
Rolling back across schema 6042 - from 6.3.4 to 6.3.3 or earlier - keeps the
hMailServer.ini the newer server left: its [Settings] section is a full copy of the
stored settings, and the older server reads the file as the truth, so settings changed
after the upgrade survive. Upgrading hMailServer 18.6 has the detail.
Restore hMailServer.ini with it, when the version you roll back from sealed the
stored secrets (schema 6103 on, SecretsAtRest.md). Such a server
writes the [Settings] secrets into the file as HMK1: envelopes, and an older server
reads the file's lines as they are: its /Register - which the older installer runs -
hands ServiceAccountPassword's envelope to Windows as the service account's password,
and the service then cannot log on and does not start; its first moments, before the
restored database puts its rows back into the file, run SRS, BATV and OAuth2 on the
envelopes. So restore the pre-upgrade hMailServer.ini beside the database, or, where
you have none, set ServiceAccountPassword (and any other of those secrets you use)
again with the older version's Control Panel once it runs, and run hMailServer.exe /Register again.
Moving to a different database backend is not an upgrade and the chain does not do it. There is no supported in-place conversion between backends. The route is a backup, a repoint and a restore, which is written up in Migrating the Database Backend.
Moving to a new machine: install the same version on the new machine, restore the database and the data directory, and confirm the data-directory path in the settings matches where you actually put it. Upgrade afterwards, as a separate step, so that if something goes wrong you know which of the two changes caused it.
Two values that are not in the database go with it by hand, before the new machine's
service first starts on the restored database: PasswordPepper, whose value is copied
into the new machine's hMailServer.ini (Backup.md), and - from schema
6103, where the installation has one - the stored-secrets key every account's second
factor and the [Settings] secrets are sealed under. Export it on the old machine
(hMailServer.exe --export-stored-secrets-key FILE, elevated, or sudo hmailserver --export-stored-secrets-key FILE on Linux) - or take the file exported when the key was
made, if the old machine is gone - and import it on the new one with
--import-stored-secrets-key FILE. The new machine's installer made a key of its own for
the empty database it installed with: remove that [Security] StoredSecretsKey line from
the new machine's hMailServer.ini first, or the import is refused. Never copy the line
from the old machine, which is sealed under its own DPAPI. Without the key every second
factor refuses every code on the new machine and those settings read as not set, and the
error log says HM7495 and names this step; with the key lost for good,
--forget-sealed-secrets KEYID clears what it sealed (SecretsAtRest.md). A
move through a backup archive rather than the database does not need the key: the archive
carries those secrets opened, and the restore seals them under the new machine's own. It
still needs the pepper.
Where the
[Settings]values come from on the new machine. On 6.3.3 they belong tohMailServer.ini: a built-in restore that includes settings writes them into the new file as well as the database, but a database restored with its own tools does not, and a new file with no[Settings]keys makes the mirror drop the restored rows at the first start - copy the old file's[Settings]section across (never its[Database]password; see below). Since 6.3.4 the database is the store, so the values arrive with either kind of restore and are written into the new file at the first start - exceptPasswordPepper, which that build keeps in the file only and leaves out of every backup. Copy that one line from the old file by hand, or every account whose password is hashed with Argon2id or scrypt is refused on the new machine.
The database password does not travel with the file. With
ProtectStoredSecretsWithDPAPI=1, which is the default, the[Database] Passwordvalue inhMailServer.INIis protected with machine-scoped DPAPI (CRYPTPROTECT_LOCAL_MACHINE) andPasswordEncryptionrecords that. Copied to a different machine it cannot be decrypted, and the server will not connect. Re-enter it there - throughDBSetup.exe, or by writing the plain value and letting the server re-protect it - rather than expecting a copied ini to work. The same is true of every other secret the server stores in that envelope.
Upgrading silently - see the unattended install
notes. Until 6.2.23 a silent upgrade of an installation with an administrator password
set hung: DBSetupQuick.exe forwarded only /SilentIfOk and /silent to
DBUpdater.exe, not the password, and DBUpdater then opened a modal password dialog
nobody was there to answer. That is fixed. DBSetupQuick forwards the password on the
upgrade path as well as the create path, the installer accepts it as
/adminpassword=<password> or, from 6.3.4, in the environment variable
HM_ADMIN_PASSWORD, and under /silent the shared authenticator fails with an exit
code instead of prompting. From 6.3.4 the installer hands the password to
DBSetupQuick.exe, and DBSetupQuick to DBUpdater.exe, in HM_ADMIN_PASSWORD; until
then it went on their command lines unquoted, so a password with a space, a double
quote or backslashes before one reached them changed and the database step failed with
exit code 3. So:
- administrator password empty -> a silent upgrade works;
- administrator password set -> pass
/adminpassword=<password>or setHM_ADMIN_PASSWORD; without it the database upgrade fails and the installer reports the non-zero exit code rather than waiting. A password with a double quote goes inHM_ADMIN_PASSWORD: Inno Setup drops every double quote from its command line. Set the variable in an elevated prompt: setup started without administrator rights elevates itself through the UAC prompt, and the variable does not cross it.
(New in 6.2.28: the live update's helper passes a single-use /upgradetoken=<hex> in
place of the password, so an unattended update never handles the administrator password
at all.)
Coming from before hMailServer 5, hMailServer.ini lived in the Windows
directory. The installer copies it to Bin\hMailServer.ini, leaves a .old copy
where it was and deletes the original; the server reads only the one in Bin.
The .NET 10 runtime. From the first release after 6.2.18, the Control Panel and
the setup tools require the .NET 10 Desktop Runtime, which the installer bundles and
installs when missing; 6.2.18 and earlier used .NET 8. Nothing about the server
itself changed - it is native code with no .NET dependency, so an upgrade that
fails to install the runtime still leaves you with a working mail server, just
without the Control Panel. The runtime is bundled as
windowsdesktop-runtime-10.0-win-x64.exe and installed /quiet /norestart, only
when it is missing, and the setup tools (DBSetup, DBSetupQuick, DBUpdater)
need it as well as the Control Panel does.
For contributors, because two of these have shipped as defects and both were invisible locally.
-
Statements are split on a blank line.
SQLScriptParserbreaks a script into commands at a blank line (;then a blank line, for PostgreSQL). Two statements written on consecutive lines are sent as one command, which SQL Server Compact rejects outright - and a create script that fails leaves a fresh install with no database, whose symptom is not an error anybody sees: the service starts and simply does not listen. Leave a blank line between statements. -
Five hand-maintained lists describe the chain and nothing but
build\check-schema-versions.ps1was checking that they agree:REQUIRED_DB_VERSIONinConstants.h; the step table inDBUpdater'sformMain.LoadScripts; the version names inGetDatabaseVersionName; the fourUpgrade<from>to<to><dialect>.sqlfiles per step; and the probes inSchemaVerification.cs. Run that script after adding a step and before stamping a release;-SelfTestchecks the checks. -
build\check-db-scripts.ps1executes them. It builds a throwaway SQL CE database from the create script and applies every upgrade script to a second one, using exactly the parser's splitting rules, and runs every probe against a freshly created database with a negative control. This is what would have caught the 6.2.22-pre4 create-script defect, and what now catches a probe a backend cannot run - the SQL CEcase when exists (...)crash behind GitHub issue 114.
MySQL and MariaDB: schema 6102 rebuilds every table. The step converts every
table to utf8mb4 under utf8mb4_general_ci, InnoDB and ROW_FORMAT=DYNAMIC, so
that a character outside the Basic Multilingual Plane - an emoji - is stored instead
of refused; MigratingDatabaseBackend.md
says why each of the three. What it means for the upgrade:
-
It takes as long as copying the database. Each table is rebuilt, one after the other, and the largest -
hm_messages,hm_message_metadata,hm_messageindexterms,hm_messagerecipients,hm_audit- take the time. With the schema empty it is one to five seconds; on a large installation plan minutes or more, and have free disk space of the order of the largest table, since InnoDB writes the new copy before it drops the old. -
It needs MySQL 5.7.9 or MariaDB 10.2.2. So do steps 6041 and 6101 before it, whose new tables have keys of the same size. Each of the three opens with a statement that needs such a key - 6041 and 6101 make and drop a table
hm_upgradeprobewith one, 6102 convertshm_accounts- so on an older server the upgrade from 6.3.3 stops at the first, 6040 to 6041, with error 1071, and nothing has been changed; upgrade the database server, then the schema. Until 26 September 2026 steps 6041 and 6101 made their first tables before the index that failed, and MySQL commits a CREATE or ALTER TABLE at once, so they stopped half done and every later run failed on a table already there. -
Repairing a database an earlier build left half done. No upgrade path repairs it -
UpgradeOnStart,hmailserver --upgrade-databaseand DBUpdater all run the same step again and stop on the same table - so it is put back by hand, once the database server can hold the keys (MySQL 5.7.9 or MariaDB 10.2.2 or later, InnoDB as the default engine,innodb_large_prefixon where the server still has it), or the probe stops the step again. Stop the hMailServer service and take a backup of the database first.At 6040, from 6.3.3 -
select value from hm_dbversionanswers6040andshow tables like 'hm_calendar%'listshm_calendars. The old step 6041 made that table and stopped at its index; nothing at schema 6040 uses it, so it is empty (select count(*) from hm_calendarsanswers 0). Drop what the step made and run the upgrade again:DROP TABLE IF EXISTS hm_calendarobjects; DROP TABLE IF EXISTS hm_calendars;
At 6100 -
select value from hm_dbversionanswers6100andshow tables like 'hm_booking%'listshm_bookingpoliciesorhm_bookinganswers. A server that took the keys of step 6041 takes these as well, so this needs a database server changed between the two steps; if it happened, the old step 6101 had added a column tohm_accounts, madehm_bookingpolicies, added two columns tohm_messagesand madehm_bookinganswers. Drop those - each column only ifshow columns from <table> like '<column>'lists it, since MySQL has noDROP COLUMN IF EXISTS- and run the upgrade again:DROP TABLE IF EXISTS hm_bookingdecisions; DROP TABLE IF EXISTS hm_bookingqueue; DROP TABLE IF EXISTS hm_bookinganswers; DROP TABLE IF EXISTS hm_bookingpolicies; ALTER TABLE hm_messages DROP COLUMN messagesubmitterid, DROP COLUMN messageauthdomains; ALTER TABLE hm_accounts DROP COLUMN accountkind;
Dropping the two
hm_messagescolumns rebuilds that table, which takes as long as copying it. Neither repair touches a row the server had before the step. -
It needs the ALTER privilege on the database, not only on its tables, because it also changes the database's default character set. An account granted
ALL PRIVILEGES ON <database>.*has it. -
It can stop at a duplicate. Most tables compared under
utf8_general_ci, of whichutf8mb4_general_ciis the twin, and a unique index over them cannot meet a duplicate it did not meet before. Some compared under a collation that tells apart a few thingsutf8mb4_general_cidoes not: everyutf8table on MariaDB 11.5 and later (utf8mb3_uca1400_ai_ci, which keepsstraßeapart fromstrase), the tables that wereutf8mb4already - the calendar tables, and the IMAP metadata table of a database upgraded through 6014 - under the server's ownutf8mb4collation (utf8mb4_0900_ai_cion MySQL 8,utf8mb4_uca1400_ai_cion MariaDB 11.4 and 11.8, both of which keep apart two names that differ only in an emoji), and, in a database an administrator made, the fifteen tables that named no character set and took the server's default (latin1_swedish_ci, for one, keepsÄapart fromA). Two such rows under one unique index stop the step with error 1062, which names the index and the value. Rename or remove one and run the upgrade again: every statement of the step can run twice, a table already converted is converted again at no harm, and the step finishes. -
Afterwards a
textcolumn may readmediumtext, and amediumtextonelongtext: MySQL widens a column it converts to four bytes a character so that it holds as many characters as it did. A fresh database keeps the declared types; the server reads both alike.
Scripts that change the configuration over COM. Since 6.3.4, a
COM change to a route (and a route's addresses), an incoming relay, a DNS black
list, a SURBL server, a remote domain policy, a server message, a blocked
attachment, an SSL certificate or a group takes effect when the object's Save() is
called, and not before. Setting a property writes the object's own copy of the item;
Save() writes the copy to the database and the server reads the list again. Two
things a script written for an earlier release may have relied on no longer happen:
a property set and never saved used to change the running server until the next
restart, and now changes nothing; and another object for the same item, read before
the Save(), used to show the new value, and now keeps the value it read - read the
item again from its collection after the save. The Control Panel already reads,
changes and saves each item in turn, and is not affected. The reason is in
ARCHITECTURE.md ("A COM change to an item of a shared
collection is written onto a copy"): the old behaviour wrote the fields of the very
object the server's sessions were reading.
Re-checked 8 September 2026 against the 6.2.28 tree, and two statements on this page were wrong and have been corrected:
- The page said a failed database upgrade showed a dialog and then let the installer
finish, so "the signal is the dialog and the log, not a failed installation".
CurStepChangednow raises on a false result fromRunPostInstallTasks, so setup finishes with a non-zero exit code. - The page said step
5004 -> 5005dropshm_adsynchronizationon MS SQL, MySQL and SQL CE "but not on PostgreSQL", and offered that as an example of an upgraded database differing from a fresh one.Upgrade5004to5005PGSQL.sqldrops it too, viahm_drop_table, so the example was not real. What is true is recorded above.
The repository text was re-copied from master 540347500 on 24 September 2026, which brought
An upgrade without a window, from master 92b8f9f63 on 26 September 2026, which brought the
service account, the stored-secrets key, the COM Save() rule and MySQL's utf8mb4 step, and
from 8adce850a the same day, the commit the tag v6.3.4 was first made on - the document is the same at the tag, 51e2dbda0 - which brought the installer's HM_ADMIN_PASSWORD,
the named lock on MySQL and MariaDB and the probe at step 6041; this page keeps its correction of the hm_adsynchronization
example, which the repository document does not yet have.
The earlier note stands and is worth keeping: the step count and target version had gone stale a fourth time - the page said 73 steps to 6022 while the tree had moved on - after a previous revision had already written down that deriving them beats transcribing them, and then transcribed them anyway. They are gone from the prose; the sources below are the answer.
Against: formMain.LoadScripts, CreateUpgradePath, DoUpgrade and
VerifyUpgradedSchema in DBUpdater (the registered steps, the path walk, the
transaction, the probes and the read-back), DBUpdater/Program.cs (the exit codes),
Constants.h's REQUIRED_DB_VERSION, hmailserver/source/DBScripts (which dialects
ship which steps, and the hm_adsynchronization history),
DatabaseConnectionManager::GetCurrentDatabaseVersion (select ... from hm_dbversion), Application::OnDatabaseConnected (the three refusal messages and
their error codes, verbatim), hMailServerInnoExtension.iss (StopHMailServerService,
InstallDotNetRuntime, MoveIni, the exit-code check and the RaiseException that
follows it), DBSetupQuick's UpgradeDatabase (which arguments are forwarded),
Authenticator.AuthenticateUser (the silent-upgrade password dialog),
IniFileSettings::SetPassword and DataProtector (the machine-scoped DPAPI
envelope), and build\check-schema-versions.ps1 and build\check-db-scripts.ps1. The notes on
hMailServer.ini marked since 6.3.4 were checked on 15 September 2026 against
commit 3233b0cc4: Upgrade6041to6042*.sql, IniSettingStore::Synchronize and XMLLoad, and
IniSettingStore::IsFileOnlyName for the pepper; the 6.3.3 behaviour on a new machine is the
second pass of Synchronize in v6.3.3.
hMailServer 6.3.7, the latest release (17 October 2026, schema 6108) · AGPL-3.0-or-later · Repository · Mirror on GitHub · Report a documentation error (a GitLab issue naming the page)
Home — full index
Start here
1. Install and run
- Before You Install
- Installing hMailServer
- Installing on Linux
- Running in a Container
- Profiles and Environment
- The Control Panel
- The Control Deck
- Your First Domain and Mailbox
- Connecting a Mail Client
- The Webmail
- DNS for Your Domain
- Migration
2. Secure it
- Encryption and Certificates
- Database TLS
- Encryption for People
- Stopping Spam
- Stopping Viruses
- Security Hardening
- Secure Defaults
- The Service Account
- Secrets at Rest
- Sign-in and Identity
- Security Policy
3. Operate it
- Monitoring and Health
- The SNMP Agent
- Backup and Restore
- Message Storage
- Compliance and eDiscovery
- Troubleshooting
- Diagnosing Stalled Mail
- Relocating an Installation
- Upgrading hMailServer
- Upgrading Guide
- The Update Feed
- Migrating the Database Backend
- High Availability Runbook
- Warm Standby
- Runbooks Digest
4. Extend it
- Rules and Sieve
- Aliases Lists and Public Folders
- Rooms and Equipment
- Mailing Lists
- Routes and Relays
- Mail In to a Webhook
- Outbound Shaping
- The COM API and Scripting
- Event Scripts
- The REST API
- Command Line
- APIs Reference
5. Contribute to it
- Project Handbook
- Architecture
- Contributing
- Release Process
- Governance
- Assurance Case
- Regression Test Environment
- Fuzzing
- Regulatory Scope
- Third-Party Binaries
Look it up — from any journey
- Settings Reference
- Settings Overview
- Capabilities and Configuration
- Control Panel Reference
- Control Panel Pages
- Ports Reference
- Changes-Since-6210
- Version-History-to-6210
- Glossary
About this wiki
- hMailServer 6.3.7 is the latest release (17 October 2026, schema 6108)
- Pages say since 6.3.4 where a feature is new in it
- AGPL-3.0-or-later
- Repository, the one place the project is worked on
- Its mirror on GitHub, since 18 October 2026, with no Issues
- Questions: a GitLab issue with the Question template, GitHub Discussions Q&A, or the forum
- Report a documentation error: a GitLab issue naming the page