Skip to content
hMailServer 6.3.8 is the current release, signed 2 November 2026. Windows and Linux, and an in-place upgrade from 5.3 or later, but for the original hMailServer's 5.7.Download 6.3.8 Documentation FAQ
  • 0 Votes
    1 Posts
    121 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) changes three things this guide touches. On MySQL and MariaDB a lost database connection is reconnected and the statement run again, so the statement after a database restart, a failover or wait_timeout no longer fails, and an insert that forgives a duplicate key forgives that alone, so an error log that was quiet may now name failures that were hidden. On Windows a stop or a reinitialise no longer waits past a work queue's ten seconds for a thread held by a host or a database that has stopped answering, and setup, the live update's silent install and the uninstaller wait ShutdownDrainSeconds plus 90 seconds for the stop, where they waited plus 30. The ACME HTTP-01 challenge responder, which served one connection at a time, serves up to 64 at once. See the 6.3.7 announcement. This topic describes a running hMailServer 6.3.4 service: its parts and pools, the connection ceilings in front of them, the scheduled work behind them, what the machine needs, and what a folder of a hundred thousand messages costs the webmail. The one thing to know first: nothing in the server is unbounded. There is no thread per connection and no pool that grows. Every piece of work runs on a pool of a set size, and a dependency that stops answering uses up a pool long before it uses up the processor, the memory or the disk. What it cannot give you is a throughput figure: none has been measured yet. The parts of the running service Everything runs in one process: the Windows service hMailServer, or the systemd unit hmailserver on Linux. Part What it is Notes Mail listeners SMTP, POP3 and IMAP on IOService + TCPServer Boost.Asio HttpServer The engine under two listeners: REST (/api/v1, the webmail at /portal, the Control Deck) and web services (MTA-STS policy, autoconfig, ACME http-01) Its own io_context, so a request storm cannot starve SMTP accept. Since 6.2.28 Metrics listener /metrics and the health probes Raw sockets, one worker thread ManageSieve Sieve script management Raw sockets, a thread of its own SNMP agent Read-only, UDP 161 Since 6.3.4; off, and answering nobody until SnmpAllowedAddresses names a manager Background Scheduler, SMTPDeliveryManager, ExternalFetchManager Long-lived tasks on the main server queue State The database (accounts, domains, message metadata and settings, since 6.3.4 including the [Settings] keys), one file per message, and hMailServer.ini as the bootstrap The database stays small next to the store The handover that matters for capacity: an SMTP session never delivers anything. The final 250 is sent from a worker on the asynchronous task queue once the message is written, and delivery happens later on a delivery thread, so the accepting side and the delivering side run out separately. Diagnosing stalled mail takes each half in turn; How email works follows one message through both. The pools, their sizes, and what a full one looks like Seven work queues are created at startup, and the two HTTP listeners have workers of their own. The first column is the queue label of hmailserver_workqueue_depth{queue} on the metrics endpoint, which counts tasks accepted and not yet started; the last row is not a work queue and has no label. Queue Threads Sized by What runs on it When it is full IOCPQueue 15 tcpipthreads Every socket read, write and TLS handshake for SMTP, POP3 and IMAP New connections are accepted but nothing progresses; clients time out mid-command Asynchronous task queue 15 MaxNumberOfAsynchronousTasks Message finalization, which sends the final 250 Senders sit after CRLF.CRLF with no reply Name lookup queue 15 the same setting Blocking reverse-DNS lookups for live sessions Reverse names become "Unknown", so a dead reverse zone cannot stall acknowledgements SMTP delivery queue 10 maxdelivertythreads The delivery of one queued message The queue grows and mail is late; nothing is lost, since the sender already has its 250 External fetch queue 15 MaxNumberOfExternalFetchThreads One run per fetch account Fetch accounts fall behind their schedule Maintenance queue 5, fixed nothing One-shot scheduled tasks; a backup started over REST A slow sweep delays the other one-shot sweeps Server queue 4, fixed nothing Scheduler, IOService, SMTPDeliveryManager, ExternalFetchManager Cannot fill in normal operation: each task holds one slot for the life of the server HttpServer workers 4 per listener, fixed nothing One REST or web-services request each Requests queue behind the four; SMTP is unaffected Where the four sizes are set. On Windows, the Control Panel's Maintenance → Performance page, Threads tab, holds the COM Settings properties TCPIPThreads, MaxAsynchronousThreads and MaxDeliveryThreads, and the [Settings] key MaxNumberOfExternalFetchThreads. On either platform the first three are in the server settings group: the Control Deck's Settings view, PUT /api/v1/settings with tcpip_threads, max_asynchronous_threads and max_delivery_threads, or hmctl settings set. The fourth goes through /api/v1/settings/ini/{name} (administrator password only), hmctl ini set, or on Linux hmailserver --set-setting as root. Since 6.3.4 the database is the settings store, so do not edit the [Settings] line in hMailServer.ini: a line edited there is ignored in favour of the stored value. Settings: when a save takes effect covers the store, what becomes of the file's line (HM5804) and when each key applies. The tab also shows WorkerThreadPriority, read-only: the server stores that value and never reads it. When a new size takes effect. Since 6.3.4 a save applies most settings at once, but MaxNumberOfExternalFetchThreads waits for a reinitialise, because the queue's size is set when the fetch manager is built; GET /api/v1/server/settings/pending lists it until then. For the other three the documents leave a gap: the Control Panel reports a save of its COM settings as "applied immediately", while the Architecture page has the pools created when the servers start, and no document says when a changed count reaches a running pool. Until one does, treat all four alike: after saving, reinitialise (the Control Panel's Reinitialize or Apply now, POST /api/v1/server/reinitialize, or hmctl api POST /api/v1/server/reinitialize) or restart the service. A reinitialise stops and starts every service in place, ends the Control Deck's session, and ends a drain. # The REST listener on this machine: read the server settings group, # then give delivery 25 threads (or: hmctl settings set --set max_delivery_threads=25) curl -u Administrator:pw http://127.0.0.1:8443/api/v1/settings curl -u Administrator:pw -X PUT -H "Content-Type: application/json" \ -d '{"max_delivery_threads":25}' http://127.0.0.1:8443/api/v1/settings # The fetch pool is a [Settings] key; it waits for a reinitialise hmctl ini set MaxNumberOfExternalFetchThreads 20 hmctl api POST /api/v1/server/reinitialize Two asynchronous threads are kept back. Finalization runs the spam tests and event scripts, which wait on things outside the process, so AsyncQueueReservedThreads (2) keeps that many threads free of it: with the defaults at most 13 finalizations run at once. A task waiting for a slot holds no thread and is counted by hmailserver_workqueue_blocking_tasks_waiting{queue}; a count that stays above zero means something those tasks depend on is slow. On Windows the key is on Server limits & expert settings, card Timeouts and queue bounds; anywhere, hmctl ini set. Only two queues report a stall: the asynchronous and name lookup queues write the "every thread busy" report that Diagnosing stalled mail reads. The others are left out deliberately, since their tasks are long-lived by design, so a full IOCPQueue, SMTP delivery queue or External fetch queue shows only in hmailserver_workqueue_depth{queue} and in its symptoms. The database connections every pool shares The deliveries, the sign-ins and the rest of the server share one pool of database connections, NumberOfConnections in the [Database] section of hMailServer.ini, 5 as shipped. A wait for one longer than DBConnectionAcquireTimeout (60 seconds) fails. In hmailserver_db_connections{state}, available pinned at zero with busy at the pool size is saturation, and every mail path is then waiting out that timeout. Raise it with the delivery threads. The relay profile sets maxdelivertythreads to 25 and the sender profile to 50, and each tells you to raise NumberOfConnections to about the delivery thread count. The built-in database has one connection. On Windows, NumberOfConnections is forced to 1 for MSSQLCE. Before You Install calls the built-in engine adequate for anything up to a few dozen mailboxes, and its single connection the first thing to change if the server feels slow under concurrency. Linux has no built-in database; it uses PostgreSQL or MariaDB. Choosing a database covers the choice. It is a bootstrap key. [Database] stays in the file, never in the database, and its keys wait for a restart. On Windows the Performance page's Database tab edits it, on the server machine only. On Linux: sudoedit /etc/hmailserver/hMailServer.ini # [Database] NumberOfConnections=25 sudo systemctl restart hmailserver Connections and requests: the ceilings in front of the pools Where Limit As shipped Set on SMTP sessions MaxSMTPConnections 0, no cap Connections & protocols → Protocols, SMTP tab; max_smtp_connections IMAP sessions MaxIMAPConnections 0, no cap The IMAP tab; max_imap_connections POP3 sessions MaxPOP3Connections not given in the documents; GET /api/v1/settings shows it The POP3 tab; max_pop3_connections Recipients in one message 50,000 50,000 No setting is named; beyond it, 550 Too many recipients REST listener, with the webmail and the Deck 64 connections, closed at accept before any TLS handshake; 4 workers; 64 KB a request (16 MB, with 300 seconds, on the two attachment routes); 30 seconds a request; 300 seconds a connection fixed no key Web-services listener 128 connections; 4 workers; 64 KB a request; 15 seconds a request; 120 seconds a connection fixed no key The webmail shares the REST listener: its users, the Control Deck and every script that calls the API meet the same 64 connections and four workers. Its request rate is in the topic on the REST API. Outbound, since 6.3.4, MaxOutboundConnsPerDestination and MaxOutboundConnsPerTenant (both 0, off) cap sessions per destination and per sending domain; a tenant share larger than the delivery thread count is no share at all (Outbound shaping and IP warm-up). Scheduled work The Scheduler polls its recurring tasks once a minute; one-shot tasks go through the Maintenance queue at once. External fetch is not one of them: the ExternalFetchManager runs its own one-minute loop. Roadmap.md recorded in August 2026 that a due task runs on the scheduler's own thread, so one long task delays the others, and that the real period is the interval plus the run time. The tasks, grouped by cadence: Task Cadence Registered GreyListCleanerTask every GreylistingRecordExpirationInterval minutes (240 as shipped) when SMTP is on RemoveExpiredRecords, WorkQueueHealthTask every minute always; the second writes the stall report TlsRptReporterTask, DmarcRptReporterTask hourly always; each sends only when its from-address is set LogRetentionTask, MailboxRetentionTask start + 6 hours always ArchiveRetentionTask, IMAPExpungeRetentionTask start + 12 hours always MetricsHistoryTask start + every minute always UpdateCheckTask start + every 15 minutes always; does nothing until UpdateCheckEnabled=1 MessageStoreConsistencyTask, DiskSpaceMonitorTask start + hourly always BackupScheduleTask a one-minute tick against the wall clock only when a schedule is set DirectorySyncScheduleTask start + every [LDAP] SyncScheduleMinutes only when that is set AcmeRenewalTask start + hourly only when AcmeEnabled=1 Sizing the machine Memory. Before You Install asks for 2 GB as a minimum on Windows, more for a busy server. It is guidance; nothing enforces it, and memory per session has not been measured. Disk. Size it for how much mail you will keep, not how much you have today. The store is Data\ under the program folder on Windows and /var/lib/hmailserver on Linux, one file per message. For scale, the webmail bench's hundred thousand messages, 5 % of them with an attachment of 20 to 512 KB, are 1,323,987,827 bytes. Below MinimumFreeDiskSpaceMB (100) new mail is refused temporarily, 452 4.3.1 over SMTP and NO [UNAVAILABLE] to an IMAP APPEND, so senders retry; below DiskSpaceWarningThresholdMB (1024) the server logs a warning and keeps accepting. Antivirus. On Windows, exclude Data\, Database\ and Temp\ from real-time scanning. A scanner holding a message file open is a documented cause of delivery that silently does nothing; the server detects one shape of it and logs error 5760. Before You Install names no exclusions for Linux, where the store is /var/lib/hmailserver with its temp and database subdirectories. Durability. MessageStoreFsync (0 as shipped) flushes the spool file to disk before the 250, and a failed flush refuses the message. The published-numbers method runs with it on and labels the shipped default "not durable". Set it on Windows on Server limits & expert settings (card Message store durability), or anywhere with hmctl ini set. A folder of a hundred thousand messages in the webmail Every webmail path reads the folder's shared message collection, the one IMAP, POP3 and delivery use: one small object per message. Until 6.3.4 opening a folder copied the whole of it, under the lock IMAP sessions and delivery wait on, for every page of 200. Since 6.3.4: Operation What it does now What still grows with the folder Open, GET /api/v1/me/folders/{id}/messages One pass under the lock counts the folder and copies only the page of 200; each row's file head is read twice, not three times That pass; and the first open after a start, or after thirty idle minutes in the cache, reads every row of the folder from the database, as an IMAP SELECT does Scroll Older and Newer by before_uid; from three pages up, Oldest in one request (oldest_before_uid) Every page costs what the first costs, wherever it is in the folder: the one pass that counts the folder Search At most 2,000 message files and about two and a half seconds a request, then answers where to go on from; across every folder it walks the mailbox newest first and answers next_before_id; with IndexerFullText and message indexing on (off as shipped), a message the index rules out is passed without opening its file Performance.md does not say Select-all and one action The selection goes to POST /api/v1/me/folders/{id}/bulk: at most 1,000 messages a call for a flag, 500 for a move or a delete, nothing new taken on after about two and a half seconds A changed flag is a row written, a move is a file copied, and a selection narrowed to an inbox tab reads the head of every message Junk over a whole folder is a hundred thousand lessons. With SpamAssassin on, each message moved into or out of Junk queues one lesson, as the same move over IMAP does. In the Settings Reference the lesson on a move is SpamAssassinLearnOnMove (off as shipped), run as a task on the asynchronous work queue. A move holds the mail twice, briefly: original and copy, both counted against the quota, for no longer than one slice of the work. Not changed. The Storage, Subscriptions and Scheduled views still copy each folder they look at; emptying Junk or Trash copies the folder once per 500 messages removed; a quick step over a whole-folder selection still reads at most five thousand messages. For other callers of GET /api/v1/me/search, it now answers complete: false, more: true with next_before_id whenever it is not the end, and its hits come newest first across every folder, where they came folder by folder. Over IMAP, one SEARCH or SORT is bounded by IMAPSearchTimeout (60 seconds) and IMAPSearchMaxMegabytes (2048 MB examined). The webmail portal at /portal covers the rest of the client. What has not been measured The webmail at a hundred thousand. Performance.md has the method and a results table for the Windows bench (SQL Server Compact, 100,000 messages in the inbox, the full-text index off and on). Every cell reads to be measured on the bench; nothing was estimated. Its tools, build/perf/seed-folder.py and build/perf/webmail-timing.py, are in the repository and also run against a Linux server. Record the machine, database, message store, index state and build with any numbers. How much the server carries. PublishedNumbers.md defines messages in and out per second, latency percentiles, IMAP sessions per core with every operation's 95th percentile under 200 ms, memory per session and Dovecot's imaptest, measured against Postfix and Dovecot on the same machine under ten fairness rules. Its results say Not measured yet, and until they are filled the first line of Roadmap2.md is a goal, not a claim. The load generator, in the repository since 6.3.4, is build/perf/load/loadgen.py; a run in which the generator was busy for more than 85 per cent of it gives only a floor and is not published. The Windows bench gives hMailServer's own numbers only, not to be compared with the Linux table. python build/perf/load/loadgen.py --help python build/perf/load/loadgen.py self-test # proves the generator, says nothing about any server Size from the ceilings above, watch the queue depths and the database pool, and treat any throughput figure quoted elsewhere as unmeasured by the project. What it does not do No document gives a rule for sizing the pools to a machine; the only sized examples are the relay and sender profiles' 25 and 50 delivery threads. The main server queue, the maintenance queue and the HTTP listeners' workers, connection caps and deadlines are fixed; no key changes them. Where to read more Wiki: Architecture, Before You Install, Control Panel Reference section 7.8, Settings Reference, The REST API section 8, APIs Reference section 1 and Monitoring and Health. Documents: Performance.md, PublishedNumbers.md, SettingsApply.md, Profiles.md and OutboundShaping.md. The public documentation: Before you install, A database and Prometheus metrics and health probes. On this forum: Diagnosing stalled mail, Reading the logs, Monitoring and health, The webmail in depth, The Control Deck and hMailServer on Linux. Without a GitLab account, a bug or a question can go to the Service Desk address contact-project+hmailserver-hmailserver-86730559-issue-@incoming.gitlab.com, which GitLab files as a confidential issue; the replies arrive by email.
  • 0 Votes
    1 Posts
    146 Views
    ProgressiverobotP
    Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) adds no schema step, so a 6.3.6 node and a 6.3.7 node both require schema 6108 and the version check at start does not tell them apart: upgrade both nodes all the same. On MySQL and MariaDB a lost database connection is reconnected and the statement run again, so the statement after a database restart or a failover no longer fails; and the first units of the active-active work are in the tree, and it is still not built. See the 6.3.7 announcement. hMailServer 6.3.8 (2 November 2026), a security release, adds no schema step either, so a node of 6.3.6, 6.3.7 or 6.3.8 requires 6108 and the check at start does not tell them apart: upgrade both nodes all the same. See the 6.3.8 announcement. This topic covers what hMailServer 6.3.4 offers for a second machine. Two operator documents describe one topology from two angles: WarmStandby.md, on what has to reach the standby and the constraints that decide the design, and HighAvailabilityRunbook.md, the active/passive procedure with a virtual IP and a health check. The one thing to know before anything else: exactly one hMailServer service may run against the database at a time. There is no clustering code and no active/active mode. Failover is starting a service on the other machine, which takes minutes, and a second service started against the same database while the first still runs delivers mail twice, with no error anywhere. Why exactly one service may run The code makes two servers destructive to each other in three ways: Startup unlocks the whole queue. PersistentMessage::UnlockAll runs update hm_messages set messagelocked = 0 for every queued message when the service starts, because locks held by a crashed server would otherwise stall those messages for ever. A second server started against the same database releases every message the first is delivering, and both then deliver them. Delivery selection is not coordinated. The queue is driven by select ... where messagelocked = 0 and messagenexttrytime <= now, and the lock is taken after selection, so two servers race that window even after both have started cleanly. External fetch has the same shape. PersistentFetchAccount::UnlockAll runs at fetch-manager start, and a POP3 account fetched twice with delete-after-download is mail split between two stores. The rehearsal of 9 September 2026 measured the first. With the primary holding a queued message locked, the standby was started on a second machine: it bound the same ports, because the listeners are rows in hm_tcpipports and both servers read them, and the lock went to 0. Nothing warned, on either side. So the standby is warm: installed, configured, patched along with the primary, and its service set to Manual start and left stopped. WarmStandby.md says Manual rather than Disabled, because a disabled service cannot be started by the runbook without an extra step. The HA runbook accepts either in its section 4 and its check-list, though the wiki's copy of it lists Disabled among the ways the pair is got wrong, for the same reason. Use Manual. What lives where State Lives in Reaches the standby by Domains, accounts, aliases, lists, rules, routes, IP ranges The database Sharing the database Message metadata, folders, IMAP state The database Sharing the database The delivery queue hm_messages rows and spool files on disk The database and the data directory Greylisting triplets, auto-ban ranges, login-failure state The database Sharing the database Message files (.eml) The data directory Replication or shared storage, below The [Settings] section Since 6.3.4 the database (hm_inisettings, schema 6042); the INI section is a cache Sharing the database [Directories], [Database], [Security], [GUILanguages] Each machine's own hMailServer.ini Configured per machine, deliberately [SettingsOverride] Each machine's own INI Not shared, on purpose PasswordPepper Each machine's own INI; no database and no backup holds it Copied by hand TLS certificates and keys, DKIM keys Files on disk; the database stores their paths The same files at the same paths Route, external-fetch and per-domain-relay passwords The database, DPAPI-protected They do not travel An account's second factor and the [Settings] secrets The database, sealed under the stored-secrets key (schema 6103) Sharing the database, once the standby holds the key The settings store, since 6.3.4. A standby that shares the database inherits the primary's server settings at its next start, without anything copying INI files. Each setting the primary changed since the standby's file was written is named in the standby's error log as HM5804 and its line rewritten: that is the copy catching up, not a fault, and it appears once. A stale file can still do two things. A setting deleted on the primary whose line the standby's file still carries is adopted back into the shared store when the standby starts, so delete it from the standby's file as well. And a key the standby's file holds that the primary never had is adopted the same way. Do not copy the primary's INI over the standby's. On Windows [Database] Password is sealed with the machine's own DPAPI key, and the copy breaks the standby's database connection outright. Treat "no [SettingsOverride] section on either machine" as part of the check-list: a key in that section is applied over whatever the database holds. On 6.3.3 it was different. The wiki records that the [Settings] section was not carried by the shared database there, and had to be copied into the other machine's INI after every settings change. The wiki also notes that [LDAP], [SendingLimits] and [SendingLimitsOverrides] stay in each node's file in 6.3.4, to be kept the same by hand. Paths must resolve on both machines. If the primary's row says C:\certs\mail.pem, the standby needs that file at that path. Rotated DKIM keys are written into <DataDirectory>\DKIM; a standby promoted with an out-of-date copy of that folder signs with a key file it does not have. What does not travel Passwords the server presents to somebody else. Route smart-host credentials, external fetch accounts, the per-domain relay password and SSL private-key passphrases are stored protected by Windows DPAPI with CRYPTPROTECT_LOCAL_MACHINE, and a blob written by one machine cannot be decrypted by another. After a failover the server keeps running, with local delivery, IMAP, POP3 and unauthenticated relaying untouched, but each such credential fails until it is re-entered on the standby. Failback breaks them the other way. Keep the list of credentialed routes, fetch accounts and relay-configured domains, with the passwords in a password manager. It is between zero and five entries on a typical installation. The wiki has a PowerShell script over COM that lists them; none of its getters returns a password. On Linux the server seals these with a key file, <DataFolder>/.hmailserver-secret-key. In the rehearsal the route arrived without its password, the standby reported HM6412, and the delivery attempt went out unauthenticated. A replication that copies the data directory wholesale copies that file too, and the standby then reads every secret the primary could. Decide which you want; the rehearsal excluded it. The administrator password. It lives in hMailServer.ini, not in the database. Until it is set on the standby, the standby has no REST API and no COM authentication. On Linux the command is sudo hmailserver --set-admin-password. On Windows WarmStandby.md names the Control Panel, but a standby installed with 6.3.4 is a new installation, whose service runs as NT SERVICE\hMailServer by default, and under that account the Control Panel's password card is refused: use hMailServer.exe --set-admin-password from an elevated prompt, which is new in 6.3.4. The rehearsal found this when the standby came up mute. PasswordPepper. With a different value on the standby, or none, no account whose password is an Argon2id or scrypt hash signs in there. The stored-secrets key, since 6.3.4. It is kept in each server's INI as [Security] StoredSecretsKey, never in the database, so the standby needs it carried: export it on the primary, elevated, with hMailServer.exe --export-stored-secrets-key FILE (sudo hmailserver ... on Linux), and import it on the standby with --import-stored-secrets-key FILE. If the standby's installer made a key of its own, remove its [Security] StoredSecretsKey line first, or the import is refused. Never copy the line itself; it is sealed under the primary's own DPAPI. Without the key every second factor refuses every code on the standby, and SRS, BATV, HS256 tokens and /metrics stop working there; the standby says so as HM7495 at each start. Hardening a public mail server has what the key seals, and what to do on an upgraded server, which has none until you make one. The message store: the two documents differ WarmStandby.md says replicate, and do not share. It argues against putting the data directory on an SMB share or a Cluster Shared Volume: The accept path flushes the spool file to disk before answering 250. Over SMB that guarantee depends on the file server and on the network between the machines, which is the failure the topology exists for. Local delivery copies the spool file once per local recipient, and the delivery loop rewrites message files; each becomes network I/O. Handles and oplocks held by a crashed primary take time to expire, so the standby can find its files locked by a machine that no longer exists. Cluster Shared Volumes are built for large block-oriented I/O, not millions of small files. It recommends a one-way scheduled robocopy /MIR of the data directory every few minutes, or DFS-R or storage-level replication, stopped as part of failover, so that a stale mirror pass from a half-dead primary cannot delete newly accepted mail. Messages accepted after the last sync are rows whose files are missing on the standby. The server treats a missing file as an error on access, not a crash, so a five-minute interval bounds the loss to five minutes of message bodies; metadata, folders and flags lose nothing. The document calls the SMB and CSV reasoning operational judgement built on code facts, not itself a code fact. HighAvailabilityRunbook.md says share. Both nodes must see the same [Directories] DataFolder on shared storage (SAN, NAS, a clustered file system or a cloud file share), with identical read and write access for both service accounts, and the database and the store on the same failover boundary. Its validation check-list begins with that. Neither document withdraws the other. Choose knowing what each claims. The database Use a server backend, SQL Server, MySQL or MariaDB, or PostgreSQL, with its own availability. SQL Server Compact is excluded: it is a single local file. Both machines must run the same build: REQUIRED_DB_VERSION is checked at every start and refuses a mismatch in both directions, so a half-upgraded pair fails safe at startup where the two builds require different schemas; 6.3.6, 6.3.7 and 6.3.8 all require 6108, so that check does not catch a pair split between them. Health probes and the load balancer The probes are on the metrics listener, enabled on both nodes. Since 6.3.4 these are stored settings: set them in the Control Panel (Windows), over /api/v1/settings/ini, with hmctl, or on a Linux node whose REST listener is not yet on with hmailserver --set-setting NAME=VALUE. Then reinitialise (Apply now, or POST /api/v1/server/reinitialize) or restart. [Settings] MetricsServerPort=8080 MetricsServerBindAddress=0.0.0.0 MetricsServerAuthToken=<32+ random characters> ShutdownDrainSeconds=30 Point the health check at /readyz. It answers 200 only when the server is Running and the database is connected, and 503 while stopping, while draining, or when the database is lost. The passive node's service is stopped, so its connection is refused and it is never routed to. /metrics answers 503 on a non-loopback bind until MetricsServerAuthToken is set; the probes need no credential. ShutdownDrainSeconds defaults to 0, which waits for nothing. Set it. The drain is /api/v1/server/drain, on the REST listener: GET its state, POST to enter, DELETE to leave. Monitoring and health has the three probes and the listener's rules. Two matter here: MetricsServerBindAddress takes an IP literal and nothing else, since a host name keeps the listener, and so the probes, from starting; and the listener serves one connection at a time, so no sub-second health check. Building the standby Install the same build as the primary, with the same drive letter and directory layout. Point its [Database] section at the same database server, verify that it connects, then stop the service and set it to Manual. Set up the store: replication from primary to standby, or the shared directory. Record the credential list somewhere that survives the primary. Set the administrator password, copy PasswordPepper, and import the stored-secrets key. Rehearse the failover at least once before it is real. Planned failover The drain as a state of its own and UpgradeOnStart are new in 6.3.4. On 6.3.3, step 1 is a plain stop with ShutdownDrainSeconds set. Drain A: POST /api/v1/server/drain (manage_server), or the Drain button in the Deck's update view. A closes its SMTP, IMAP, POP3 and ManageSieve listeners, pauses its delivery queue, and reports /readyz 503 draining. A new connection is refused at the socket, which every MTA retries. REST, metrics and web services stay up. Watch GET /api/v1/server/drain until drained is true. Stop A. Verify A is down: /readyz on A is refused. Stop the replication job, in whichever direction it runs, where the store is replicated rather than shared. Do it before B is started. The runbook has no such step, because it assumes a shared store; WarmStandby.md's failover has it. In a planned failover A's machine stays up, so a scheduled mirror pass from it would delete the mail B has just accepted. Move the VIP to B. Start B and wait for /readyz 200. If B's binaries are newer than the shared schema, B refuses to start (HM5011) unless its INI says [Database] UpgradeOnStart=1. Take the database backup before this step. One node at a time. Check the key on B: no HM7495, HM7492 or HM7493 in its error log. /readyz is 200 either way, so this is the check that finds a missing key. Confirm: a test message through the VIP, and /healthz reporting database: up and state: running. Then re-enter the route, fetch and relay passwords on B, as after an unplanned failover. Reverse the steps to fail back, the check of the key included; with a replicated store, follow Failback below, which resynchronises the store first. To take A off the air without a failover, do step 1 and then DELETE /api/v1/server/drain, which reopens the same ports. Unplanned failover The health check on /readyz fails; stop routing to A. Fence A: power it off, or isolate it from the storage and database network. Unreachable is not the same as dead. Stop the replication job, where the store is replicated. Move the VIP, or the DNS record or port forward, to B. Start the service on B. Its startup unlock now works for you: messages the dead primary held mid-delivery are released and retried. A message the primary had delivered but not yet deleted from the queue may be delivered again, which is the known, bounded cost. Re-enter the route, fetch and relay passwords on B. When A is repaired, bring it back stopped, as the new passive node. The two documents order the middle differently: the runbook moves the VIP and then starts B, and WarmStandby.md starts the standby and then moves the endpoint. Both put the fence first. Keep MX TTLs modest, 300 to 3600 s, as a standing decision. Sending servers retry for days, so mail queued remotely during the gap is delayed, not lost. Failback is the same with the roles swapped, plus one step first where the store is replicated: resynchronise the data directory backwards, because the standby has been accepting mail and the primary's store is now the stale one. Then stop the standby's service, replicate from standby to primary once more for the final delta, start the primary, move the endpoint back, re-enter the route, fetch and relay passwords on the primary, and set up the replication from primary to standby again. Verify, after either direction GET /readyz answers 200: the service is running and the database has answered a real round trip within 20 s. A message sent in from outside, read back over IMAP or POP3. A message sent out through each credentialed route. No ERROR entries in the application log since the failover. TLS on 25, 143, 110, 587, 993 and 995 presents the right certificate. What it does not do, and what has not been rehearsed No active/active, no sharding, no clustering code. The runbook puts them out of scope. Not seconds. Failover is minutes, and the timings on the wiki pages are the shape of a rehearsed failover, not a promise. No rolling restart on one node. A single node refuses connections for the length of an install. The drain's state is per process, so a pair drains one node at a time. The rehearsal was a Linux pair: an Ubuntu 26.04 standby, one PostgreSQL 18 database, an SSH tunnel standing in for a LAN route. Not rehearsed: a Windows-to-Windows pair, DPAPI itself, a file-replication product rather than a copy at the moment of failure, DNS or IP takeover, and failback. Where to read more The wiki pages: Warm Standby, with the worked commands and the recovery-window table, and High Availability Runbook, with the probe-lifecycle table and load-balancer configurations The documents: WarmStandby.md and HighAvailabilityRunbook.md The documentation page: A warm standby, active/passive high availability and Prometheus metrics and health probes On this forum: Upgrading to 6.3.4, Moving to new hardware, and Settings: what is not stock and when a save takes effect, for the settings store and [SettingsOverride]