Skip to content

Running in a Container

Christopher Holloway edited this page Oct 26, 2026 · 23 revisions

Running in a Container

Where an image comes from now. The image has been in the tree since 11 September 2026, and the release workflow on GitHub was written to push it to ghcr.io/progressiverobot/hmailserver:<version> on every release tag from 6.3.2. That name is the project's former GitHub account's, Progressiverobot, which GitHub suspended on 18 September 2026, and it has answered 404 since. On 18 October 2026 the image was copied to ghcr.io/rainbowwizard-coder/hmailserver, for the project's repository on GitHub, GitLab's mirror, with its cosign signatures and attestations: the tags 6.3.7, 6.3, latest and 6.3.6, each with the digest GitLab's registry gives it. The owner made that copy public on 20 October 2026, so it can be pulled without signing in. On 21 October 2026 the image was copied by hand to Docker Hub, as docker.io/progressiverobot/hmailserver (docker pull progressiverobot/hmailserver:<version>), by its digest: the tags 6.3.7, 6.3 and latest, with both platforms' images and their signatures and SBOM attestations; the registry-mirror job is what copies a release there. Nothing here pulls from either copy (ContainerImage.md). The Compose file and the chart in the tree now name registry.gitlab.com/hmailserver/hmailserver, the project's GitLab container registry, to which a release's own pipeline pushes its image once the release is published, since 6.3.6: 6.3.7's is registry.gitlab.com/hmailserver/hmailserver:6.3.7, also tagged 6.3 and latest, for linux/amd64 and linux/arm64 (below). To build it yourself instead, from the x86-64 .deb on every GitLab release (latest; https://updates.progressiverobot.com/download/hmailserver_amd64.deb, which always leads to the newest stable release's), see Building the image yourself, and put your own tag wherever this page, the Compose file and the chart say registry.gitlab.com/... or ghcr.io/.... The image's tags and its pull were brought up on 18 October 2026 to 6.3.7, the latest release (17 October 2026, schema 6108); what else 6.3.7 changes is not yet reflected here.

The Linux server (Installing on Linux) as a container image: registry.gitlab.com/hmailserver/hmailserver:<version> as the Compose file and the chart name it (ghcr.io/progressiverobot/hmailserver:<version> as the release workflow on GitHub named it), and :latest for a stable release only, so latest never points at an alpha. It is the x86-64 .deb the release's workflow built, installed on Ubuntu 24.04 (the distribution release the package was built on; since Boost is linked statically the package also installs on later releases, but the image stays on the release it was built for), run as the hmailserver user the package creates, with one capability on the binary so that user can bind the mail ports. About 40 MB. Everything the Linux page says about what the server is and is not applies unchanged: it is the server, there is no Control Panel and no COM, and the REST API is the administration.

Contents

What is in the image, and what is not

In: /usr/bin/hmailserver, the packaged hMailServer.ini at /etc/hmailserver/, the database scripts, the Control Deck, the fail2ban filter and jail the package carries, ca-certificates for the TLS the server does as a client, and an entrypoint. The process runs as hmailserver; ports below 1024 are bound through cap_net_bind_service on the binary, not through root.

Not in: the database. It never will be. Point the image at a PostgreSQL or MariaDB server, in another container or anywhere else; on first start the entrypoint creates the schema there, and on every later start upgrades it if it is behind. The image carries the client the server needs for each: libpq, which the server is linked against and which comes with the .deb, and, from the release after 6.3.7, MariaDB Connector/C (Ubuntu's libmariadb3), which the server opens at run time when HM_DB_TYPE is MySQL, the type a MariaDB server is given. 6.3.7's image and the ones before it carry libpq alone: they reach PostgreSQL only, and told HM_DB_TYPE=MySQL they stop at the entrypoint's --create-database with "The MySQL client library could not be loaded" (ContainerImage.md). Also not in: curl, an editor, or a shell of any consequence beyond bash; the image is the server and nothing else.

The environment is the configuration

There are two generations of this, and which one you have depends on the image.

In 6.3.3 and earlier, the entrypoint takes the sixteen variables in the table below and writes hMailServer.ini from them on every start, so the environment is the truth for those and a stale file cannot disagree with it. Each is optional; what is not set is left as the packaged file has it. Every other setting is the file in the /etc/hmailserver volume.

Since 6.3.4, every setting has a name in the environment (written on 23 September 2026; EnvironmentConfiguration.md is the full account). The database is the settings store from schema 6042 on, so the entrypoint no longer relies on a line in the file: after it has created or upgraded the database it runs the server's own verb, hmailserver --apply-environment, which stores what the environment says into the database, and it does so at every start, so the environment wins:

Variable Sets
HM_SETTING_<key> the [Settings] key <key> - any of the 241 the server reads. HM_SETTING_MaxSubmissionsPerIPPerMinute=30
HM_PROPERTY_<name> the database setting (hm_settings row) <name> - any of the 250 the server reads. HM_PROPERTY_maxmessagesize=20480
HM_SETTING_<key>_FILE, HM_PROPERTY_<name>_FILE the same, read from the file the variable names - a Docker or Compose secret, or a Kubernetes Secret mounted as a file. Trailing line breaks are stripped; at most 64 KB of UTF-8 text

The prefixes and _FILE are written in capitals; the name after the prefix is matched without regard to case. What follows from that:

  • Everything is checked before anything is stored. A name that is not a setting of the store its prefix names is refused with the nearest real name and the variable that sets it (HM_SETTING_MaxMessageSize is refused naming HM_PROPERTY_maxmessagesize); a misspelt prefix such as HM_SETTINGS_ is refused naming the right one; a value the setting would refuse over the REST API is refused in the REST API's words; a _FILE that names a directory, a missing file or more than 64 KB is refused naming the path. When anything is refused, nothing is stored and the container does not start; docker compose logs or kubectl logs says which variable and why.
  • A setting the environment names cannot be changed by an administrator. The next start would undo the change, so the REST API answers 409, the Control Deck shows the setting as Set by the environment with no control, and COM refuses it, each with a sentence naming the variable. Unset the variable and restart to manage the setting in the Deck again.
  • A value from a file stays secret: the verb prints the value is not shown, and no REST answer, log line or command line carries it.
  • The environment sets the server's value only. A domain's or an account's own value of one of the 27 settings that have levels is not an environment setting (Profiles and Environment).
  • Every setting but the two [SendingLimits] keys can come from the environment; those two are neither a [Settings] key nor a database setting, and stay in the file with [LDAP] and the bootstrap sections ([Database], which the HM_DB_* variables below write, [Directories] and [Security]).
  • HM_PROFILE names one of the four setting profiles - office, gateway, relay or sender - which the entrypoint applies once, after the environment's settings and before the server starts; HM_PROFILE=none records that none is wanted. A profile that closes a listener, trusts an address range or weakens a check needs HM_PROFILE_CONFIRM=yes, and without it the container does not start. See Profiles and Environment.

Run in a container as a user runs it on 23 October 2026. At 6.3.4 the repository's own account said that the verb had been run as hMailServer.exe --apply-environment on Windows, that its Linux half had been compiled, and that the entrypoint as a whole, the chart and the Compose file had been read and not executed. Since 25 September 2026 GitLab CI has a linux-image job, written to build the image from the .deb and inspect it without running it; it passed in master's first full pipeline, 2886389460 on 27 September 2026, without running the image. On 23 October 2026 docker compose up with the tree's Compose file and helm install of the chart were run as a user runs them, against 6.3.7's published image (ContainerImage.md, What was run by hand): the Compose file as it is now started a server that offered STARTTLS on 25 and answered the REST API over TLS, the published charts of 6.3.6 and 6.3.7 started none, and the chart as it is now did (Kubernetes, with the chart, below). Try it on a scratch database first.

The sixteen variables the image has always taken keep their names and meanings in both generations. Since 6.3.4 the four HM_REST_* ones are aliases of HM_SETTING_RestApiPort and its three neighbours, stored like any other setting rather than written into the file, and two more write the [Database] section:

Variable Writes Notes
HM_DB_TYPE [Database] Type PostgreSQL or MySQL (MariaDB is MySQL, which an image reaches from the release after 6.3.7: 6.3.7's and earlier carry PostgreSQL's client alone). Setting this is what makes the section be written at all
HM_DB_HOST, HM_DB_PORT Server, Port Defaults db, and the type's port (5432 or 3306)
HM_DB_NAME, HM_DB_USER, HM_DB_PASSWORD Database, Username, Password Defaults hmailserver, hmailserver, empty. The user needs the right to create the database on first start
HM_DB_TLS_MODE, HM_DB_TLS_CA TlsMode, TlsCaFile Since 6.3.4. TLS to the database: disable, prefer (empty, the default: TLS whenever the database offers it, the certificate unchecked), require or verify-full, and the path of a PEM file of CA certificates mounted into the container (DatabaseTls.md)
HM_ADMIN_PASSWORD [Security] AdministratorPassword Through the server's own --set-admin-password: the file holds a PBKDF2 hash, never the password. Without one the REST API refuses to start, and the REST API is the only administration this build has
HM_REST_PORT [Settings] RestApiPort The packaged file has 0, which is off. 8045 is the conventional port. Since 6.3.4, an alias of HM_SETTING_RestApiPort, stored at every start
HM_REST_BIND_ADDRESS RestApiBindAddress The packaged file has 127.0.0.1, which inside a container is the container's own loopback - see the next section
HM_REST_CERTIFICATE_FILE, HM_REST_PRIVATE_KEY_FILE RestApiCertificateFile, RestApiPrivateKeyFile Paths inside the container, readable by the hmailserver user
HM_DB_PASSWORD_FILE, HM_ADMIN_PASSWORD_FILE the same two secrets Read from a file instead: a Docker or Compose secret. Setting both a variable and its _FILE is refused
HM_CREATE_DATABASE, HM_UPGRADE_DATABASE nothing 0 skips --create-database or --upgrade-database at start. Both are no-ops when there is nothing to do, so the default of 1 is right for almost everyone. Since 6.3.4 a --create-database that creates the schema also makes the installation's stored-secrets key, in the file on the /etc/hmailserver volume: export it at once (below, Backups)
HM_INI which file all of this is written to Default /etc/hmailserver/hMailServer.ini
HM_PROFILE, HM_PROFILE_CONFIRM nothing Since 6.3.4: the setting profile applied once at the first start, and its confirmation (above)

On 6.3.3, anything the table does not cover - listeners, directories, LDAP, logging - is the packaged hMailServer.ini in the /etc/hmailserver volume, edited as on any Linux installation (Settings Reference); the entrypoint rewrites only the keys above and leaves the rest of the file as it finds it. Since 6.3.4, a setting is either named in the environment or changed over the REST API, in the Control Deck or with hmctl, as on any Linux installation of that build (Installing on Linux), and a save takes effect at once for most keys - 211 of the 241, with 28 waiting for a reinitialise and 2 for a restart. [Directories], [LDAP] and the bootstrap sections stay in the file. The image's /etc/hmailserver is owned by the hmailserver user, so there the server does keep its copy of each stored value in the file.

Reaching the API

The server requires TLS on a REST listener that is not bound to loopback. Inside a container, 127.0.0.1 is the container's loopback, so a listener left at the packaged bind address answers only to processes inside the container - which is none, since the image carries no client. Two ways out, and one is enough:

  • Bind it and give it a certificate. HM_REST_BIND_ADDRESS=0.0.0.0, HM_REST_CERTIFICATE_FILE and HM_REST_PRIVATE_KEY_FILE pointing at a certificate and key mounted into the container (readable by the hmailserver user), and the port published. The example below does this. Publish it on the host's loopback (127.0.0.1:8045:8045) or a private network unless the certificate is one clients will trust.
  • Host networking (--network host). The container's 127.0.0.1 is the host's, the packaged bind address is right as it is, no certificate is needed, and the API is reachable from the host at http://127.0.0.1:8045 and from elsewhere through an SSH tunnel, exactly as on a package installation. The mail ports are then the host's ports directly, with no publishing.

First start, with Compose

Since 15 September 2026 this file ships in the repository, at hmailserver/source/Server/platform/packaging/docker/docker-compose.yml, and a workflow resolved it with docker compose config on every change until GitHub became unavailable on 18 September 2026, so it is a file that is known to parse rather than an example in a document. It differs from the one below in three deliberate ways: the two passwords are Compose secrets read from files rather than environment variables; its command runs hmailserver --apply-new-install-defaults --if-marked before the server, so that the first start secures the new installation (on master since 26 October 2026, from the release after 6.3.7; see The secure defaults of a new installation, below); and its REST listener has the self-signed pair that step makes, where the one below mounts a certificate and key of your own from ./certs, whose key the image's user, 999, must be able to read (sudo chown 999 certs/rest.key): a key openssl req -nodes writes is its owner's alone, 0600, and the server then logs HM5113 ("Permission denied") and the REST API does not listen. Use it; the version below is kept because it is the shortest thing that starts. Since 6.3.6 the packaging file's database is PostgreSQL 18, with the data volume mounted at /var/lib/postgresql (not /var/lib/postgresql/data), as below; PostgreSQL 18 does not open 16's data, so a deployment made from an earlier copy moves it once with a dump and a load (ContainerImage.md, PostgreSQL 16 to 18, in the Compose file); one that keeps its own copy of the file keeps PostgreSQL 16. The image is published at registry.gitlab.com/hmailserver/hmailserver:6.3.7 (also 6.3 and latest), one index over linux/amd64 and linux/arm64, signed with cosign as the tag's pipeline (cosign verify --certificate-identity https://gitlab.com/hmailserver/hmailserver//.gitlab-ci.yml@refs/tags/v6.3.7 --certificate-oidc-issuer https://gitlab.com ..., ContainerImage.md, Checking an image). In the container the update check names the image to pull and installs nothing (HM_INSTALL_KIND=container); HM_DB_WAIT_SECONDS (0 to 999999, default 0) waits for the database; the entrypoint refuses (exit 64) an HM_DB_* value with a control character, outer white space or quotation marks around it.

services:
  db:
    image: postgres:18
    environment:
      POSTGRES_USER: hmailserver
      POSTGRES_PASSWORD: change-me
      POSTGRES_DB: postgres
    volumes:
      - pgdata:/var/lib/postgresql
    restart: unless-stopped

  mail:
    image: registry.gitlab.com/hmailserver/hmailserver:6.3.7
    depends_on:
      - db
    environment:
      HM_DB_TYPE: PostgreSQL
      HM_DB_HOST: db
      HM_DB_NAME: hmailserver
      HM_DB_USER: hmailserver
      HM_DB_PASSWORD: change-me
      HM_ADMIN_PASSWORD: change-me-too
      HM_REST_PORT: 8045
      HM_REST_BIND_ADDRESS: 0.0.0.0
      HM_REST_CERTIFICATE_FILE: /certs/rest.pem
      HM_REST_PRIVATE_KEY_FILE: /certs/rest.key
    volumes:
      - ./certs:/certs:ro
      - hm-data:/var/lib/hmailserver
      - hm-logs:/var/log/hmailserver
      - hm-etc:/etc/hmailserver
    ports:
      - "25:25"
      - "587:587"
      - "993:993"
      - "127.0.0.1:8045:8045"
    restart: unless-stopped

volumes:
  pgdata:
  hm-data:
  hm-logs:
  hm-etc:

restart: unless-stopped matters on the first start: depends_on starts the database container first but does not wait for it to accept connections, and the entrypoint's --create-database fails, and the container exits, if the database is not there yet. The restart tries again a moment later and succeeds. What the first start logs, in order:

[Database] written from the environment: PostgreSQL on db:5432, database hmailserver
[Settings] REST API written from the environment: port 8045 bind 0.0.0.0 certificate /certs/rest.pem key /certs/rest.key
The administrator password was written to /etc/hmailserver/hMailServer.ini as a PBKDF2 hash.
Database hmailserver created on PostgreSQL at schema version 6031, from /usr/share/hmailserver/DBScripts/CreateTablesPGSQL.sql.
The database is at schema version 6031, which is what this build needs. Nothing to do.

The schema version in that excerpt is from a build at 6031 (6.3.0 to 6.3.1); a 6.3.3 build reports 6040, and a 6.3.4 build 6104. A 6.3.4 build also prints one line per HM_SETTING_/HM_PROPERTY_ variable it stored, the profile's lines when HM_PROFILE is set, and what --create-database did about the stored-secrets key.

The secure defaults of a new installation are not applied by the image alone. On a package installation the systemd unit's first start gives a database --create-database made a self-signed certificate, STARTTLS on 25, 587, 110 and 143 and TLS required for authentication on the Internet IP range; the image has no unit. From the release after 6.3.7 the Compose file and the Helm chart run that step, hmailserver --apply-new-install-defaults --if-marked, before the server, so that their first start secures the database the entrypoint has just created, and the Compose file gives the REST listener the self-signed pair it makes; the charts of 6.3.6 and 6.3.7 do not. For anything else - the example above, or a container started otherwise - the step is run by hand, as the service's user, with the server stopped: hmailserver --apply-new-install-defaults (SecureDefaults.md, By hand). Until then the setup checklist's Passwords only over TLS item says what is left.

Then the server is up, and the first domain is one request away, as on the Linux page:

curl -k -u Administrator https://127.0.0.1:8045/api/v1/status
curl -k -u Administrator -H 'Content-Type: application/json' \
     -d '{"name":"example.com"}' https://127.0.0.1:8045/api/v1/domains

-k because the example's certificate is self-signed; drop it when the certificate is a real one. Passwords in a Compose file are passwords in a file: for anything beyond a first look, use HM_DB_PASSWORD_FILE and HM_ADMIN_PASSWORD_FILE with Compose secrets.

Kubernetes, with the chart

Since 6.3.4 (in the repository since 15 September 2026): the chart is at hmailserver/source/Server/platform/packaging/helm/hmailserver, and was linted and rendered by a workflow on every change until GitHub became unavailable on 18 September 2026; since 25 September GitLab CI has a chart-and-compose job for the same, which passed in master's first full pipeline on 27 September 2026. Since 6.3.6 each release carries it as hmailserver-chart-<version>.tgz, with its Sigstore bundle, and the project's chart repository, https://updates.progressiverobot.com/helm/, serves it (helm repo add hmailserver https://updates.progressiverobot.com/helm/; from the release after 6.3.7, helm install mail hmailserver/hmailserver with the values below), which Artifact Hub lists, at 6.3.7 (ContainerImage.md, The Helm chart and Artifact Hub). The chart of 6.3.7 and earlier has no icon; the next release's names the fork's own, the Control Panel's, which Artifact Hub shows on the listing. The charts the Helm repository serves, 6.3.6's and 6.3.7's, start no server. Installed into a cluster, their pod stops at CreateContainerConfigError ("container has runAsNonRoot and image has non-numeric user (hmailserver), cannot verify user is non-root"), and, given the user by number, at "/etc/hmailserver/hMailServer.ini is not writable by hmailserver", since Kubernetes copies nothing of an image's into a new volume; nor do they secure a new installation. The chart in the tree, on master since 26 October 2026, runs the pod as the image's user and group, 999:999, fills the new volumes in an init container and runs hmailserver --apply-new-install-defaults --if-marked before the server. The release after 6.3.7 is the first to serve it; until then the chart of a checkout, installed with --set image.tag= the published release's version, is the one that starts the published image. Both were installed so on 23 October 2026, into a one-node k3s (ContainerImage.md, What was run by hand). A release installed from 6.3.6's or 6.3.7's chart is moved with helm upgrade --reset-then-reuse-values (Helm 3.14 or later) and then kubectl delete pod mail-hmailserver-0 (the pod of a release named mail): under podManagementPolicy: OrderedReady, Kubernetes' default, the StatefulSet replaces no pod while one is not Running and Ready, so it never replaces the stuck one itself. --reset-then-reuse-values is the update for every new chart; --reuse-values keeps the defaults of the chart the release was installed with, those charts' fsGroup 1000 and database.port 5432 among them. The example below installs the chart from a checkout.

A StatefulSet of one, because a message store is not something two pods may share: the three volumes the image declares become claim templates, and the database is pointed at, never bundled - the chart refuses to render without database.host, rather than starting a server with nowhere to put the mail.

helm install mail ./hmailserver \
  --set database.host=postgres.databases.svc.cluster.local \
  --set database.password=... \
  --set administrator.password=... \
  --set rest.tlsSecretName=mail-tls

What the values do, in the order they matter:

Value What it is
database.host, .type, .name, .user The PostgreSQL or MariaDB the server uses. Required; there is no bundled database. MariaDB (MySQL) needs an image that carries its client, from the release after 6.3.7 (above). The chart from the release after 6.3.7 passes database.port only when it is set, the type's own port (5432, or 3306 for MySQL) otherwise; the chart of 6.3.7 and earlier passes database.port, whose default is 5432, whatever database.type is, so that, told MySQL, it needs database.port: 3306. database.create and database.upgrade make the schema on first start and move it forward on an upgrade, both no-ops once current.
database.password, administrator.password Go into a Secret the chart makes. Point existingSecret at a Secret you made instead and the chart writes none; its two keys are named by existingSecretKeys. The administrator password is never stored as itself - the entrypoint hands it to the server's own --set-admin-password, which keeps a PBKDF2 hash.
rest.enabled, .port, .tlsSecretName The one listener that serves the REST API, the Control Deck and the webmail. Off loopback the server requires TLS, so without a kubernetes.io/tls Secret named here the listener will not start - the chart's notes say so on install rather than leaving it to be discovered. They reach the server as the HM_REST_* variables, which a 6.3.4 image stores at every start like any other environment setting.
settings, properties, settingsFrom Since 6.3.4: any [Settings] key (settings:, rendered as HM_SETTING_<key>), any database setting (properties:, as HM_PROPERTY_<name>), and a key of a Secret you made, mounted as a file and named through _FILE (settingsFrom:), so a secret value is in no environment variable, rendered manifest or log line. A wrong value keeps the pod from starting, and kubectl logs says why.
service.type, .ports, .externalTrafficPolicy A LoadBalancer with the mail ports, and the REST port when the listener is on. Local keeps the client's address, which auto-ban, greylisting and the Received header all need; with one replica it costs nothing.
persistence.* The message store, the logs and the configuration, one claim each.
Probes Port 25, as the image's own health check is - or the metrics listener's /livez and /readyz when that listener is on.

Neither Helm nor Docker is on the bench that gates this server's code, so the chart was checked in CI instead, linted and rendered but never installed: a workflow linted it, rendered it with the values a real install needs, checked that it refuses to render without a database host and that it writes no Secret of its own beside an existingSecret, and resolved the Compose file - on every change to either, until GitHub became unavailable on 18 September 2026. Since 25 September 2026 GitLab CI has a chart-and-compose job, on GitLab's shared runners, written to lint and render the chart and resolve the Compose file on a change to either; it passed in master's first full pipeline, 2886389460, on 27 September 2026 (ContinuousIntegration.md). Since 26 October 2026, on master, the job also reads the rendered StatefulSet and holds it to the pod's user and group, 999:999, to the init container and to the server's container securing the new installation first, and the resolved Compose file to its command and to the REST listener's pair (CHANGELOG.md). The first installs into a cluster, by hand on 23 October 2026, found that the charts of 6.3.6 and 6.3.7 start no server (the note above).

Volumes, ports and the health check

Path What lives there
/var/lib/hmailserver The mail store, and .hmailserver-secret-key, the key under which the server protects this machine's stored secrets. The key travels with this directory: a database restored beside a fresh data volume cannot open the secrets it holds
/var/log/hmailserver The logs. Bind-mount it to a host path if fail2ban on the host is to read it
/etc/hmailserver hMailServer.ini. Owned by the hmailserver user in the image, so the entrypoint can write it. Since 6.3.4, also the only home of PasswordPepper and of [Security] StoredSecretsKey, which no database and no backup holds

The image exposes 25, 110, 143, 465, 587, 993, 995 (mail), 4190 (ManageSieve) and 8045 (the REST API and the Control Deck); publish the ones you serve. The health check opens a connection to the container's own port 25 every 30 seconds after a 60-second start period, and three failures in a row mark the container unhealthy - which is what an orchestrator restarts on.

Upgrading

Pull the new tag and recreate the container. The entrypoint runs --upgrade-database on every start, so a schema that is behind is brought forward before the server opens a port, and one that is current is reported as such and left alone. Moving to 6.3.4, the start that follows makes the database the settings store: keys a 6.3.3 server already mirrored keep their stored values and keys the table never held are taken in from the file. The entrypoint then stores whatever the environment names, so the HM_REST_* variables and any HM_SETTING_/HM_PROPERTY_ you add are in effect from that first start. The upgrade is the same one the Linux package runs (Upgrading hMailServer); take the backup first. On MySQL and MariaDB the 6102 step rebuilds every table as utf8mb4, which takes time on a large database. An upgraded database gets no stored-secrets key until one is made with hmailserver --create-stored-secrets-key, and never the new installation's defaults.

Backups

Three things, together: the database (its own tools), the /var/lib/hmailserver volume (the mail store and the secret key), and /etc/hmailserver if you edited it beyond what the environment writes. Since 6.3.4, the [Settings] values are in the database and travel with it, but two things are only in /etc/hmailserver/hMailServer.ini: a PasswordPepper, if one is set - without it a restored database refuses the password of every account hashed with Argon2id or scrypt - and the stored-secrets key, without which a restored database opens no account's second factor and no [Settings] secret. Export the key once and keep the file with the database backups, apart from them:

docker exec <container> hmailserver --export-stored-secrets-key /var/lib/hmailserver/stored-secrets.key
docker cp <container>:/var/lib/hmailserver/stored-secrets.key ./stored-secrets.key

A replacement container takes it back with --import-stored-secrets-key FILE (SecretsAtRest.md). Backup and Restore applies; the server's own backup writes into the data volume.

Auto-ban in a container

The server's own auto-ban works as everywhere: an address that fails to log on repeatedly is refused by the listeners. What the Installing on Linux page describes below that - the hook that adds the address to the host's firewall - cannot run from inside a container, which has no authority over the host's tables. Run fail2ban on the host instead: bind-mount /var/log/hmailserver to a host path, copy the filter and jail the package carries out of the image (docker cp <container>:/etc/fail2ban/filter.d/hmailserver.conf … and the jail beside it), point the jail's logpath at the mounted directory, and enable it. The ban then lands in the host's firewall, ahead of the published ports.

How the image is built and proven

The image job of the Linux workflow runs on every v* tag after the build: it takes the amd64 .deb that run made, checks its version is the tag's, builds the image with Buildx from a base pinned by digest, pushes it, and then smoke-tests the pushed image the way the example above runs it: a bridge network with a PostgreSQL container beside it, a throwaway certificate, the database, listener and password from the environment. It passes only if the container reports healthy, both listeners are inside the container's own network namespace, the SMTP banner through the published port is the server's (220 <host> ESMTP), the API answers 200 to the administrator, 401 to a wrong password and 201 to a domain created through it, and the administrator password appears in no file in clear. A tag whose image fails any of that is a release without an image, not a release with a broken one.

That job was a GitHub Actions workflow (linux-build.yml, in the git history at v6.3.3), and nothing has run it since GitHub became unavailable to the project on 18 September 2026. Since 25 September 2026 GitLab CI has a linux-image job, written to build the image from the .deb with kaniko on the project's own runner and inspect it, for master, batch* branches and release tags; it passed in master's first full pipeline, 2886389460, on 27 September 2026. It neither pushes nor runs the image. The image jobs of a v* tag's pipeline do, started by a maintainer once the release is published: they made 6.3.6's and 6.3.7's images, and container-image-start and container-image-start-arm64 start each platform's pushed image by its digest and ask it for SMTP and the REST API - beside PostgreSQL alone up to 6.3.7, and from the next release's tag pipeline twice, once beside PostgreSQL and once beside MariaDB, so that an image without the MariaDB client fails the second start, and ask each also whether its update check knows it runs in a container: GET /api/v1/update must say installKind container, which the image's HM_INSTALL_KIND=container tells the server, liveUpdate false and a docker pull of the image as its command (ContainerImage.md, The start test).

Building the image yourself

The image is the release's .deb on Ubuntu 24.04 and an entrypoint, so a local build is short. Use the docker directory of the same tag as the package - 6.3.4's entrypoint runs verbs a 6.3.3 binary does not have:

git clone https://gitlab.com/hmailserver/hmailserver.git
cd hmailserver
git checkout v6.3.4
cd hmailserver/source/Server/platform/packaging/docker
mkdir packages
curl -L -o packages/hmailserver_6.3.4_amd64.deb \
  https://gitlab.com/hmailserver/hmailserver/-/releases/v6.3.4/downloads/hmailserver_amd64.deb
docker build -t hmailserver:6.3.4 .

The Dockerfile copies packages/hmailserver_*_amd64.deb (the HM_DEB build argument names another path) and pins its Ubuntu base by digest. A 6.3.3 package carries a Sigstore bundle to check it against before you build, as Installing on Linux describes; 6.3.4's carries none. Then use hmailserver:6.3.4 wherever this page, the Compose file or the chart's image.repository names registry.gitlab.com/hmailserver/hmailserver. The image is x86-64 only: the Dockerfile takes the amd64 package.

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