deploy stands up Stalwart 0.16, ihasmail and (for a mail host) Caddy as a compose project: completes Stalwart's bootstrap over x:Bootstrap, links ihasmail over the private network, requests certificates for both Caddy (TLS-ALPN-01) and Stalwart (HTTP-01 through Caddy), makes the auto-ban safe behind the proxy, and proves the link by signing in through the webmail. --local gives a loopback-only pair. certs retries Stalwart's certificate; destroy removes a deployment. e2e/public.sh runs the whole mail-host path against Pebble with no internet involved.
11 KiB
ihasmail-oneshot
One command that stands up a fresh Stalwart mail server and a fresh ihasmail webmail on a Docker host, already linked to each other:
ihasmail-oneshot deploy --domain example.com --user alice
It writes a deployment directory, starts Stalwart, completes Stalwart's setup
wizard over its API, brings up ihasmail and Caddy, wires them together, requests
certificates, signs in through the webmail to prove the link, and hands you the
administrator password and the DNS records to publish. Afterwards it is an
ordinary docker compose project you manage with the usual commands.
It only ever deploys fresh: it refuses a directory with files in it, and a compose project that already has containers or volumes.
Two shapes
deploy --domain example.com |
deploy --local |
|
|---|---|---|
| For | A mail host on the internet | Trying ihasmail against a real Stalwart |
| Containers | Stalwart, ihasmail, Caddy | Stalwart, ihasmail |
| Published | 25, 465, 993, 995, 4190, 80, 443 on every interface | Nothing but two loopback ports |
| Certificates | Let's Encrypt, for Caddy and for Stalwart | None |
| Webmail | https://webmail.example.com |
http://127.0.0.1:8080 |
| Stalwart admin | https://mail.example.com/admin |
http://127.0.0.1:8081/admin |
Both also bind ihasmail to 127.0.0.1:8080 and Stalwart's plain-HTTP port to
127.0.0.1:8081, for looking at either directly from the host.
Requirements
- Linux with Docker Engine and the compose plugin, as a user allowed to run
docker. - Go 1.26 or newer to build it (there are no release binaries yet).
For a mail host, also:
- A domain whose DNS you control.
- Ports 25, 80, 443, 465, 993, 995 and 4190 free on the host and open in any firewall in front of it.
- Outbound port 25. Many hosting providers block it until you ask.
- Reverse DNS (a PTR record, set at the hosting provider) for the host's address, naming the mail host.
Install
git clone https://github.com/Coffey-Labs/ihasmail-oneshot.git
cd ihasmail-oneshot
go build -o ihasmail-oneshot ./cmd/ihasmail-oneshot
Copy the binary to the Docker host and run it there. It needs no other files.
Deploying a mail host
Point DNS at the host first, so certificates can be issued on the first run:
mail.example.com. A <the host's IPv4 address>
webmail.example.com. A <the host's IPv4 address>
(AAAA records too, if the host has IPv6.) Then:
ihasmail-oneshot deploy --domain example.com --email [email protected] --user alice --user bob
It lists what it will do, checks the host, and asks before changing anything;
--yes skips the question, and is required when there is no terminal to ask
on. At the end:
credentials.txtholds the Stalwart administrator ([email protected]) and a generated password for each--user. Readable only by you. The administrator can sign in to the webmail, where it gets the Administration menu, and to Stalwart's own admin UI.dns-records.zoneholds every record Stalwart wants published: MX, SPF, DKIM, DMARC, the SRV records, MTA-STS, and the autoconfig names. Publish all of them.
If DNS did not point at the host yet, the webmail still comes up and Caddy keeps retrying its certificates by itself. Stalwart does not retry a failed order, so once DNS is right:
ihasmail-oneshot certs --dir ihasmail-example-com
Trying it locally
ihasmail-oneshot deploy --local --user alice
Open http://127.0.0.1:8080 and sign in with a mailbox from
ihasmail-example-test/credentials.txt. Nothing outside the host can reach
it, and no mail can be delivered to it.
What it sets up, and why
┌──────────── private network (172.31.253.0/24) ────────────┐
443, 80 ──► Caddy .10 ─┼─► ihasmail .11 ──http://stalwart:8080──► Stalwart .12 │
│ ▲ │
└─── Stalwart's web names ─────────────────────┘ │
25, 465, 993, 995, 4190 ────────────────────────────────────────────► Stalwart │
└───────────────────────────────────────────────────────────┘
ihasmail reaches Stalwart over plain HTTP on the private network. The leg never leaves the host, and the private route is about a third of the memory per signed-in tab of going back out through HTTPS. Stalwart's HTTP port is published on loopback only.
ihasmail runs immutable: read-only root filesystem, no volume, sessions in memory. A restart signs everyone out and loses nothing else, because everything durable, including each user's settings, lives in Stalwart.
Stalwart is set up through its bootstrap API, not a template. Stalwart 0.16
keeps its configuration in its data store, and a server with an empty
configuration starts in bootstrap mode. The tool starts it with a one-off
recovery administrator, completes setup with x:Bootstrap/set, and restarts it
without that credential, so no fixed administrator password outlives the
setup. Logging goes to stdout rather than the default log directory, which does
not exist in the container.
Caddy and Stalwart share ports 80 and 443 without competing. Both need
certificates for Stalwart's names: Caddy to serve its web side over HTTPS,
Stalwart for IMAP and SMTP. They are separated by challenge type. Caddy uses
TLS-ALPN-01 on 443 for those names and never port 80; Stalwart uses HTTP-01,
and Caddy forwards /.well-known/acme-challenge/ on port 80 to it untouched.
Stalwart's certificate covers the mail host plus autoconfig, autodiscover,
mta-sts and ua-auto-config under the domain, and Caddy fronts all five.
Stalwart's auto-ban is made safe for a proxy in front of it. Stalwart bans
an address that probes scanner paths such as /wp-admin.php, and counts other
abuse per address too. Behind a proxy, that address is the proxy's. So:
- Stalwart takes client addresses from Caddy's
X-Forwarded-For. Without it, one scanner bans Caddy, and with it every autoconfig lookup, calendar client and certificate renewal. With it, the scanner is banned and nobody else is. This is safe only because nothing untrusted can reach Stalwart's HTTP port. - Every request the webmail makes arrives from ihasmail's fixed address, which is added to Stalwart's allowed addresses, so no ban can take the webmail down for everybody. ihasmail rate-limits sign-ins per real client itself.
Neither setting applies to a running Stalwart, so the tool restarts it after making them.
Push by subscription. ihasmail is given PUSH_URL, so Stalwart posts
changes to it instead of holding a connection open per browser tab. If Stalwart
cannot reach that URL, every tab falls back to the relay and nothing breaks;
/api/health shows which is in use.
Afterwards
The deployment directory is a compose project:
cd ihasmail-example-com
docker compose ps
docker compose logs -f stalwart
docker compose restart ihasmail
To upgrade, change an image tag in compose.yaml and run docker compose up -d.
Read ihasmail's release notes for the Stalwart version it supports before
moving Stalwart.
Data lives in the named volumes stalwart-etc and stalwart-data (mail,
accounts, configuration) and caddy-data (certificates and the ACME account).
Back those up. APP_SECRET in .env seals ihasmail's sessions; changing it
signs everyone out.
To remove a deployment and everything in it, mail included:
ihasmail-oneshot destroy --dir ihasmail-example-com
It removes the containers, network and volumes, and the files the tool wrote. A file you added to the directory yourself is left, and so is the directory.
Flags
ihasmail-oneshot deploy -h lists them all.
| Flag | Default | |
|---|---|---|
--domain |
required; example.test with --local |
The mail domain |
--mail-host |
mail.DOMAIN |
Stalwart's hostname: one label under the domain |
--webmail-host |
webmail.DOMAIN |
The webmail's hostname |
--email |
postmaster@DOMAIN |
ACME contact address. Use one that does not depend on this server |
--user |
none | Create a mailbox with a generated password. Repeat for more |
--local |
off | The loopback-only shape |
--dir |
./PROJECT |
Deployment directory: new or empty |
--project |
ihasmail-DOMAIN |
Compose project name, dots as dashes |
--stalwart-image |
stalwartlabs/stalwart:v0.16.22 |
|
--ihasmail-image |
ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328 |
|
--caddy-image |
caddy:2.11.4 |
|
--webmail-bind |
127.0.0.1:8080 |
Host address for ihasmail's own port |
--stalwart-bind |
127.0.0.1:8081 |
Host address for Stalwart's plain-HTTP port |
--subnet |
172.31.253.0/24 |
Private network. Change it if it overlaps one you have |
--acme-directory |
Let's Encrypt | ACME directory of a private CA |
--acme-ca-root |
none | PEM root the private CA's own HTTPS is signed by |
--yes |
off | Do not ask for confirmation |
Known limits
- One domain. More can be added afterwards in Stalwart or in ihasmail's Administration; their certificates and DNS are then yours to arrange.
- IPv4 on the private network. Ports are published on IPv6 too wherever Docker does so on the host.
- Stalwart does not retry a certificate order that fails, including on a
transient error from the CA.
certsstarts a new one. - Push by subscription is not covered by the end-to-end test, which has no public DNS for Stalwart to resolve the webmail's name with. Without it the relay is used, which is the documented fallback.
Testing
go test ./...
e2e/public.sh
e2e/public.sh deploys a full mail host on the machine it runs on with no
internet involved: Pebble stands in for
Let's Encrypt and a DNS stub answers every name with the host's own address. It
checks that Stalwart's IMAPS and submissions ports and Caddy's HTTPS all present
verified certificates, that users sign in through the webmail over HTTPS, that
autoconfig is served, that a scan through Caddy bans the scanner while other
clients still get through, and that ihasmail is exempt from bans. It
publishes the mail ports while it runs and removes everything when it ends;
KEEP=1 leaves it up to look at.
License
GPL-3.0-or-later. See LICENSE.