Skip to content

Running in a Container

chrisholloway5 edited this page Sep 11, 2026 · 2 revisions

Running in a Container

In the tree since 11 September 2026; the first image is published by the 6.3.2 release's own workflow, and the page describes that image. Until then there is nothing at the registry address below.

The Linux server (Installing on Linux) as a container image: ghcr.io/progressiverobot/hmailserver:<version>, 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. 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

The entrypoint writes hMailServer.ini from these on every start, so the environment is the truth and a stale file cannot disagree with it. Each is optional; what is not set is left as the packaged file has it.

Variable Writes Notes
HM_DB_TYPE [Database] Type PostgreSQL or MySQL (MariaDB is MySQL). 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_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
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
HM_INI which file all of this is written to Default /etc/hmailserver/hMailServer.ini

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.

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

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

  mail:
    image: ghcr.io/progressiverobot/hmailserver:6.3.2
    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.

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.

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 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

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. The upgrade is the same one the Linux package runs (Upgrading hMailServer); take the backup first.

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. 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.

Clone this wiki locally