Skip to content

Installing ihasmail

This page gets ihasmail running and gets you signed in. With Docker it takes about ten minutes.

ihasmail is one small program that sits between your users' browsers and your Stalwart mail server. It keeps no mail and has no database, so installing it mostly means telling it where Stalwart is.

Before you start

You need three things.

  1. A Stalwart mail server, version 0.16 or newer. ihasmail refuses to sign in to anything older.
  2. A machine with Docker. A small server with 1 CPU and 1 GB of memory is plenty for most sites. See System requirements for bigger ones.
  3. A web address for your webmail, such as mail.example.com, so you can give it HTTPS.

The machine running ihasmail has to be able to reach Stalwart. Your users' browsers never talk to Stalwart directly — only ihasmail does. Network and ports has every connection and the firewall rules for them.

Why 0.16, and what upgrading from 0.15 involves

Stalwart 0.16 replaced its old management API with JMAP, and changed how files and their permissions work. ihasmail supports one generation only, because supporting both meant a wrong guess failed quietly instead of loudly.

Upgrading migrates Stalwart's data in place with no way back, and Stalwart's own converter drops some settings without saying so. stalwart-migrator checkpoints each step, refuses to start on problems that cannot be fixed halfway, and checks the server afterwards. Take a snapshot first either way.

Quick start

1. Write a settings file

Create a file called .env with two lines:

.env
STALWART_URL=https://mail.example.com
APP_SECRET=paste-a-long-random-value-here
  • STALWART_URL is the address of your Stalwart server: just the start, with no path after it.
  • APP_SECRET protects your users' sign-ins. Make one with openssl rand -base64 48 and paste the result in. Keep it private.

ihasmail will not start if APP_SECRET is missing or still says change-me. Changing it later signs everyone out.

2. Start ihasmail

docker run -d --name ihasmail --restart unless-stopped \
  --read-only --tmpfs /tmp \
  -e IMMUTABLE=1 -e SESSION_FILE= \
  --env-file .env -p 127.0.0.1:8080:8080 \
  ghcr.io/coffey-labs/ihasmail:latest

This downloads the published image and starts it. It runs read-only: it saves nothing to disk, so replacing it never loses anything. The one thing to know is that restarting it signs everyone out. If you would rather people stayed signed in, see Keeping sessions across restarts.

Why read-only?

Everything worth keeping — mail, calendars, contacts, even each person's settings — already lives on Stalwart. So ihasmail can run with no disk to write to and no volume to back up: an upgrade is swapping one container for another. IMMUTABLE=1 makes ihasmail check that it really is read-only when it starts, and refuse to run if it is not. Running immutably explains the whole idea.

3. Check it is running

$ curl -s http://127.0.0.1:8080/api/health
{"ok":true,"name":"ihasmail","version":"2026.9.15+pr366", …}

"ok":true means it is up. The container checks this itself every 30 seconds, so within a minute docker ps should also show it as healthy.

4. Put HTTPS in front

ihasmail is listening only on this machine, at port 8080. A reverse proxy — a web server such as nginx or Caddy — gives it a proper address with a certificate. Sign-in cookies, installing it as an app, and notifications all need HTTPS.

Caddy gets the certificate for you.

mail.example.com {
    encode zstd gzip
    reverse_proxy 127.0.0.1:8080 {
        # Keep live updates flowing
        flush_interval -1
    }
}
server {
    listen 443 ssl http2;
    server_name mail.example.com;
    # ssl_certificate ...; ssl_certificate_key ...;

    client_max_body_size 60m;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        # Keep live updates flowing
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }
}

Whichever you use, keep two things from the example: don't buffer responses, or new mail stops appearing live, and allow uploads a little over 50 MB (ihasmail's own limit, MAX_UPLOAD_BYTES), or large attachments fail. Both examples are in the repo as Caddyfile.example and nginx.example.conf.

How ihasmail trusts the proxy

ihasmail marks cookies Secure and sends HSTS when the proxy tells it the request came in over HTTPS. It believes that only from a proxy on the same machine or a private network, which covers the examples above. A request from anywhere else is judged by its real address, whatever its headers claim — otherwise anyone could dodge the sign-in rate limit. TRUSTED_PROXIES in Configuring changes the list.

5. Sign in

Open your web address and sign in with a Stalwart email address and password. There are no separate ihasmail accounts to create: ihasmail checks the password with Stalwart, and holds it encrypted on the server for the session only — never in the browser.

Using two-factor sign-in? Use an app password

If an account has two-factor authentication turned on, create an app password in Stalwart's own settings and sign in to ihasmail with that. A normal password is refused with Invalid username or password.

Why there is no code box

Stalwart accepts a two-factor code only through its own sign-in page (OAuth), not from an app holding a username and password. So ihasmail has nowhere to send a code, and app passwords are Stalwart's answer for apps like this one.

Sign-in says the server is too old

ihasmail checks for Stalwart's urn:stalwart:jmap capability. Stalwart lists it per account, never at the top level of the session. So a server that really is 0.16 can still be refused if something between ihasmail and Stalwart rewrites the session response. Check what <STALWART_URL>/.well-known/jmap returns for the account.

What next

The rest of this page is detail for when you need it: other ways to run ihasmail, choosing the connection to Stalwart, and sizing.


Choosing how to run it

The quick start is the recommended setup. This section covers the alternatives, and helps you pick an image version.

Which image and version to use

The quick start uses the image published to GitHub's container registry, ghcr.io/coffey-labs/ihasmail. It is built for linux/amd64 and linux/arm64 on every release.

Tag What it is
latest The newest release. Prereleases never move it
2026.9.2-pr243 One specific build: its version with + written as -

Pin a dated tag for anything you care about. latest changes by design, and rolling back to a named tag is one docker run rather than a rebuild.

Releases are weekly, so the image is usually behind the code

A release is made every Monday at 09:00 UTC, in any week that had changes. GitHub runs scheduled jobs on a best-effort basis, so treat the hour as approximate. A fix merged on a Tuesday reaches the image the following Monday.

When a closed issue says a fix is live, that means the project's own test server, which deploys straight from the code. Build from main if you need the fix sooner.

The published images are built to be served from the root of a web address. Serving ihasmail from a subpath, such as /mail, needs a build of your own.

Keeping sessions across restarts

Choose this if people should stay signed in when you restart or upgrade ihasmail. The container gets one small volume for its session file.

From a clone of the repo, docker compose sets that up:

docker compose up --build -d

It publishes port 8080 on 127.0.0.1 only, for the reverse proxy in front of it, and mounts the named volume ihasmail-data at /data, where SESSION_FILE defaults to /data/sessions.json. It also sets TRUST_PROXY=1 and IMAGE_PROXY=1, and runs the container with a read-only root filesystem, no capabilities and no-new-privileges.

The trade: the instance now has saved state of its own, which the quick start setup does not. Running immutably weighs the two.

Building the image yourself

Build your own image instead of pulling the published one:

git clone https://github.com/Coffey-Labs/ihasmail.git
cd ihasmail
V="$(node scripts/version.mjs)"                  # 2026.8.30+pr129
docker build --build-arg IHASMAIL_VERSION="$V" -t "ihasmail:${V/+/-}" .

Then run it as in the quick start, with your tag in place of ghcr.io/coffey-labs/ihasmail:latest.

Pass the version in, as above. A build cannot work it out by itself: .dockerignore leaves out .git on purpose, and git is not installed in the build stage. Leave it out and the build reports 0.0.0 rather than failing. That is meant to look wrong: a version with no +pr or +g means whoever built the image did not pass one.

A Docker tag may not contain +, so the tag uses - instead — ihasmail:2026.8.30-pr129. The build itself is given the real form, which is what About and /api/health show.

How to read a version number

The version is the date of the commit the build came from, then where that commit came from. 2026.8.30+pr129 was built from a commit dated 30 August 2026 that arrived through pull request 129. A commit that did not come through a pull request carries its short SHA instead: 2026.8.30+g1fa6578.

The date is the commit's own, not the day you built it. So rebuilding an old commit gives the same version it had the first time.

It says nothing about Stalwart, on purpose. It used to: 2.16.x had 16 for the 0.16 generation, which leaves nowhere to go once Stalwart reaches 1.0. What a build needs from the server is stated where it can be exact — Before you start above, and the project's KNOWN-ISSUES.md.

Installing from source

Choose this for development, or if you would rather run the code directly than an image.

npm install
npm run build          # web/dist + server/dist
npm start              # serves the production build on :8080

You need Node ≥ 20.19 and npm ≥ 10. For how much each Node version can hold, see What a source install can hold. npm start reads the same .env file.

Outside NODE_ENV=production, a missing APP_SECRET is replaced with a temporary one and a warning, so sessions do not survive a restart.

A source install is not immutable

Immutability comes from how you run ihasmail — the container's read-only filesystem — not from the code. npm start is an ordinary Node process on a writable disk. That is the right setup for development, and for anyone who wants the source in front of them.

Setting IMMUTABLE=1 does not change that. ihasmail refuses to start and says why, because the setting is a claim the server checks, not a switch. To run immutably, run the container.

Give it a memory limit. A source install has none unless you set one. Without a limit, the process can grow until the operating system runs out of memory and kills a process to recover — which need not be ihasmail. To get the container's behavior, where only ihasmail stops and everything else stays up, run it under a cgroup (a Linux memory budget for one process):

systemd-run --user --scope -p MemoryMax=512M -p MemorySwapMax=0 npm start

For development there are two modes:

npm run dev            # against a real Stalwart (STALWART_URL in .env)
npm run dev:mock       # against the built-in mock — no real mailbox needed

npm run dev puts the server on :8080 and the development web server (Vite) on :5173; open :5173. The mock signs in as [email protected] / demo.

About the mock

npm run mock is a fake Stalwart 0.16 that runs in memory — enough JMAP to develop and demo against. On purpose, it copies things a real server does that a simple fake would not, each of which cost a live debugging session to find: where urn:stalwart:jmap is advertised, the 2047-byte limit on identity signatures, and CalendarEvent/set refusing RFC 8984's spelling in favor of Stalwart's own.

Connecting to Stalwart

The route to Stalwart

This helps you choose what to put in STALWART_URL. It is a security decision first, and a sizing one second.

Use https://mail.example.com unless you are sure the network between ihasmail and Stalwart is yours. It is the default and the safe answer. It costs about 133 KiB of memory per signed-in tab.

Use http://stalwart:8080 only when ihasmail and Stalwart are on the same host or the same Docker network, so the plain-HTTP link never leaves a network you control. It costs about 48 KiB per tab — nearly three times the capacity of the same container — and keeps Stalwart's HTTPS listener out of the path.

Never use plain HTTP across a network you do not control

The link from ihasmail to Stalwart carries each user's credential on every request, and HTTPS is what protects it on the way.

Plain HTTP means a private network, not a private-looking name

A .internal name that resolves across the open internet is still the open internet. The test is whether someone else could see the traffic between the two containers, not what the address looks like.

Which address ihasmail actually connects to

ihasmail sits between the browser and Stalwart: the browser talks to ihasmail, and ihasmail talks to Stalwart. It always uses the route STALWART_URL names.

Stalwart puts its own public addresses in every session it hands out. ihasmail keeps the path and query from those, but the scheme, host and port from STALWART_URL — so a private route stays private. STALWART_FOLLOW_ADVERTISED_URLS=1 restores the old behavior, for a setup that must reach Stalwart at a different address than the one it was given.

Three more settings affect this path. All are on by default, and most sites never change them:

Variable Default What it does
RAW_PUSH_RELAY 1 Passes each live-update stream straight through between connections instead of through fetch(). About 20 KiB per tab less than the alternative; set 0 only to compare
API_RATE_LIMIT 1200 Requests a minute one session may make to the data path — JMAP, file downloads and uploads, the image and calendar proxies. One core is fully busy at around 2,000 operations a second, so without a limit one signed-in user can slow everyone else. 0 turns it off
COMPRESS_JMAP 1 gzip-compresses JMAP responses for browsers that ask for it — about a quarter of a millisecond per call, for three to five times fewer bytes. Clients that send no Accept-Encoding are never compressed. 0 leaves only the app's own files compressed

Push by subscription

This is an optional way to cut the memory each tab costs, and to take ihasmail's users off Stalwart's connection limit. Consider it for a large site.

Every open tab needs to hear about new mail. By default ihasmail holds two connections per tab: one from the browser, and one from itself to Stalwart. That second one is most of what a tab costs, and the only reason Stalwart's connection limit applies to ihasmail at all.

JMAP has a second way to deliver updates (RFC 8620 §7.2): a PushSubscription, where the mail server sends changes to an address the client registers. Stalwart 0.16 supports it. With PUSH_URL set:

  • ihasmail registers one subscription per account at sign-in;
  • when Stalwart sends a change, ihasmail passes it on to that account's open tabs over the connections it already holds;
  • no connection to Stalwart is held open per tab at all.

A tab that opens before its account is verified uses the ordinary relay, and is moved across as soon as verification completes.

Variable Default What it does
PUSH_URL unset The https address where Stalwart can reach ihasmail — https://mail.example.com. Unset, every tab uses the relay
PUSH_MODE subscribe relay turns subscriptions off entirely

What it saves, measured with a 256 MiB limit on a private route, with 6,144 accounts verifying during the test and no failures:

  • 33 KiB per tab on ihasmail, against 48 on the relay;
  • 5 KiB per tab on Stalwart, against 46.

Stalwart holds no connection for your users, so its maxConnections limit no longer counts them. Sizing has the full figures.

Stalwart has to trust the certificate

The standard requires an https address and Stalwart enforces it. Stalwart also checks the certificate, with no way to turn that off. Behind a public HTTPS front end this is already true. On a private network, either:

  • give the push address a certificate from an internal certificate authority that Stalwart trusts, or
  • put it on a public name, while STALWART_URL stays on the private route.

An account that cannot be verified simply stays on the relay. Nothing breaks; you just do not get the saving. /api/health shows how many accounts are on each path.

If accounts stay pending and never become verified, Stalwart is not reaching PUSH_URL at all. It is usually a firewall between the two — most often when Stalwart runs in a container and ihasmail runs on the host, where the host firewall drops traffic from the container network. The server log says so for each account: push: no verification for … within 180s; relay in use.

Redeploying later

deploy.example.sh in the repo is a deploy script for a single Docker host. It upgrades ihasmail safely:

  1. fetches the latest code, and refuses anything held back;
  2. shows what is about to be introduced, and asks;
  3. rebuilds the image with the right version built in;
  4. replaces the container, and waits for it to report healthy.
./deploy.sh                 # origin/main, asks before shipping new commits
./deploy.sh --dry-run       # run the checks and stop
./deploy.sh v2026.8.30 --yes  # a named version, no prompt (there is no terminal to answer over ssh)

It has two safety checks, because a deploy script is where a careless run does the most damage:

  • Holds. .deploy-hold lists commits that must not reach production yet, one per line. A target that includes one is refused outright, and --yes does not override it.
  • Confirmation. Anything that brings in new commits has to be confirmed, so a bare run over SSH cannot ship whatever main has picked up since.

Rolling back is quick. Each build is tagged with its own version as well as :current, so going back means running the previous tag, not rebuilding it. Old images are pruned down to IHASMAIL_KEEP_VERSIONS (three by default) — only after the health check passes, and never the one that is running.

The example script also runs ihasmail immutably by default; Running immutably shows how to switch that off.


Next: Configuring ihasmail — every environment variable, and the handful of Stalwart-side settings some features need.