Can I self-host Fastmail?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Stalwart. It takes one prompt, a 1024 MB VPS, and about 180 minutes. That is $6 a month you stop paying Fastmail — $72 a year on the Standard plan, 1 seat assumed.
Why people pay for Fastmail
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Fastmail sells the one part of email that is not software: a mail domain whose reputation has been earned over two decades, so the message you send arrives in an inbox rather than a spam folder. The bill also buys the IMAP and JMAP servers, the calendar and contacts, Masked Email, the web and mobile clients, and somebody else carrying the pager when a large receiver decides to throttle. Business plans are billed per user; personal plans are a flat rate for one, two or six people.
| Plan | List price | What it buys |
|---|---|---|
| Individual | $6/mo | One person. $5 per month billed yearly ($60 for 12 months). 60 GB total storage: 50 GB for mail, calendar and contacts, and 10 GB for file storage. |
| Duo | $10/mo | Two people. $8 per month billed yearly ($96 for 12 months). 120 GB total storage, 60 GB per person. |
| Family | $14/mo | Up to six people. $11 per month billed yearly ($132 for 12 months). Up to 360 GB total storage, 60 GB per person. |
| Basic | $4/mo per seat | Business tier for low storage needs. $3 per user per month billed yearly ($36 for 12 months). 6 GB total storage per user: 5 GB for mail, calendar and contacts and 1 GB for file storage. |
| Standardthe plan this page prices against | $6/mo per seat | The business tier the page marks most popular. $5 per user per month billed yearly ($60 for 12 months). 60 GB total storage per user, custom business addresses on your own domain. |
| Professional | $10/mo per seat | $9 per user per month billed yearly ($108 for 12 months). 150 GB total storage per user and an email retention archive for legal compliance. |
Vendor list prices in USD, read from the pricing page on 2026-08-14 · confidence: high
Replaced by Stalwart
One project, named before the prompt, so you know what you are about to install.
The mail server itself: SMTP, IMAP, JMAP, CalDAV and CardDAV in one Rust binary, with the spam filter and the DKIM signing built in and every setting behind a web admin UI.
The only entry in this catalog that is the mail server rather than something that hands mail to one. One Rust binary and one container speak SMTP, IMAP, JMAP, POP3, ManageSieve, CalDAV, CardDAV and WebDAV, with the spam filter, the DKIM signing and the DMARC reporting built in rather than bolted on, and every part of it configured through a web admin UI instead of a pile of files in /etc. What it cannot hand you is the part of Fastmail you are actually paying for: the reputation of the IP address your mail leaves from. That is earned over weeks with correct DNS, a matching PTR record and a sending pattern that does not look like a compromised box, and the page says so at the top rather than at the bottom.
The swap
You'd run
Stalwart
ONE EVENING · ~180 min to running · 1024 MB RAM
Fastmail Standard · 1 seat assumed · vendor list price · checked 2026-08-14 · source
Before you start
- RAM floor
- 1024 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~180 min1–3 hours, through the first backup
The prompt
Two paths to the same Stalwart: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
323 lines · 14,928 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Stalwart 0.16.17 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS, serving mail on ports 25, 465 and 993.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
`<DOMAIN>` is the mail host, `mail.example.com` rather than `example.com`, one label in front of
the domain their addresses live at. It is the SMTP EHLO name, the name on the 993 certificate,
and the name reverse DNS must match.
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
dig +short -x $(dig +short <DOMAIN> | tail -1)
timeout 10 bash -c 'exec 3<>/dev/tcp/aspmx.l.google.com/25' && echo "outbound 25 OPEN" || echo "outbound 25 BLOCKED"
```
Stalwart needs 1024 MB of RAM available and 10 GB free on /srv: upstream calls 1 GB enough for
five to ten users, and the disk floor is mail, which only grows. The image publishes amd64 and
arm64. If RAM is under 1024 MB or disk under 10 GB, print both and stop; if the fourth command
prints nothing, stop. The fifth prints the PTR record, which has to read `<DOMAIN>.` exactly.
Only the hosting provider changes that, and step 7 stops on it.
Assert the last line prints `outbound 25 OPEN`. Most VPS providers block outbound 25, because
most abuse comes from rented boxes, and unblock it on request. A box that cannot open 25 outbound
receives mail and delivers none, which looks like a working install for a day.
STOP: if that printed `outbound 25 BLOCKED`, tell the user to ask their hosting provider to unblock outbound port 25 on this server, and wait. Do not continue until they confirm the command prints OPEN.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/stalwart /srv/stalwart/backups
sudo install -d -m 700 -o 2000 -g 2000 /srv/stalwart/etc /srv/stalwart/data /srv/stalwart/log
ls -la /srv/stalwart
```
Assert: `backups` owned by the login user, and `etc`, `data` and `log` at mode `700` owned by uid
`2000`, the uid the image runs as. `etc` holds `config.json`, `data` the RocksDB with every
message in it, `log` the wizard's default log path the image omits.
## 3. Secrets
One secret: the bootstrap administrator credential. Generate it on the server. Do not print it,
repeat it in your summary, or log it. Hex, not base64: Stalwart reads a leading `$`, `_` or `{`
here as a hash prefix.
```bash
umask 077
cat > /srv/stalwart/.env <<EOF
STALWART_HOSTNAME=<DOMAIN>
STALWART_PUBLIC_URL=https://<DOMAIN>
STALWART_RECOVERY_ADMIN=admin:$(openssl rand -hex 24)
EOF
chmod 600 /srv/stalwart/.env
umask 022
ls -l /srv/stalwart/.env
```
Assert: mode `-rw-------`. Setting this before the first start is the point: left unset, Stalwart
generates its own bootstrap password and writes it to the container log in clear text, where
anyone who reads `docker compose logs` reads it. Step 7 deletes the line.
## 4. compose.yml
```bash
cat > /srv/stalwart/compose.yml <<'EOF'
# Stalwart · the deterministic fallback. Authored by caniselfhostit from
# https://stalw.art/docs/install/platform/docker and
# https://stalw.art/docs/install/security
#
# DELIBERATE DEVIATION from every other entry in this catalog: 25, 465 and 993
# are published on every interface rather than on 127.0.0.1, because other mail
# servers open 25 themselves and clients open 465 and 993 themselves, over TLS
# this container terminates. One port stays on loopback and Caddy alone reaches
# it: 8189, the plain HTTP listener carrying the admin UI, JMAP, autoconfig and
# the ACME challenge.
#
# One service: mail, accounts and configuration all live in an embedded RocksDB
# under /var/lib/stalwart. While /etc/stalwart/config.json is absent the server
# runs in bootstrap mode, one HTTP listener on 8080 and no mail ports;
# afterwards it opens 25, 465, 993, 995, 4190, 443 and 8080, the last three
# unpublished. No 587: upstream creates no listener on it. The image runs as
# uid 2000 and ships its own HEALTHCHECK. Digest read 2026-08-14; amd64, arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
stalwart:
image: stalwartlabs/stalwart:v0.16.17@sha256:a8108e19bd927e172d4d8c128907b8dfc93fd180ae8ee07dccdd42cb97eb9dfa
container_name: stalwart
restart: unless-stopped
# Hostname, public URL and the bootstrap credential. Mode 600.
env_file: /srv/stalwart/.env
volumes:
# config.json, the RocksDB, the log path; all owned by uid 2000.
- /srv/stalwart/etc:/etc/stalwart
- /srv/stalwart/data:/var/lib/stalwart
- /srv/stalwart/log:/var/log/stalwart
ports:
- "25:25"
- "465:465"
- "993:993"
# Loopback only: the host's Caddy is the only thing that reaches 8189.
- "127.0.0.1:8189:8080"
EOF
cd /srv/stalwart && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK` prints.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-stalwart
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Stalwart · the Caddy site block for this service. Authored by caniselfhostit
# from https://stalw.art/docs/install/security and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box, which is also STALWART_HOSTNAME in .env and has to match
# the PTR record. The whole hostname is proxied, not only /admin: the same
# listener serves JMAP, WebDAV, autoconfig, MTA-STS, OAuth and the
# acme-challenge path answered while renewing the 465 and 993 certificate.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8189 is the loopback port compose publishes for the container's plain
# HTTP listener. Not a container port, not in the firewall.
reverse_proxy 127.0.0.1:8189
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` and the reload exit 0. If validate fails, restore
/etc/caddy/Caddyfile.before-stalwart, reload and report what it objected to. Caddy renews the web
certificate itself; the 465 and 993 one is separate, and step 7 finishes it.
## 6. Firewall
Six ports open, three of them the point of this page:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 25/tcp
sudo ufw allow 465/tcp
sudo ufw allow 993/tcp
sudo ufw status verbose
```
80 and 443 are Caddy's, 25/tcp is how other mail servers reach this one, 465/tcp is where clients
submit under implicit TLS, which upstream recommends over 587, and 993/tcp is IMAP. 8189 stays
closed as loopback; 995, 4190, 143, 110 and 587 because compose publishes none.
Assert: `ufw status verbose` prints `Status: active`, lists those five, and shows no rule for
8189. Docker's iptables rules are read before ufw's, so compose `ports:` is control.
## 7. Start and verify
With no `config.json`, Stalwart starts in bootstrap mode: one plain HTTP listener on 8080, the
wizard at `/admin`, no mail ports.
```bash
cd /srv/stalwart
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthz/live); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/admin/
curl -sS https://<DOMAIN>/login | grep -c '<title>Sign in</title>'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/account
```
Assert all four; print what you received. The loop ends on `200`. `/admin/` prints `200`: the
web interface is a bundle fetched from GitHub on first start and answers `404` until that
succeeds, so this tests outbound HTTPS. The grep prints `1`, the sign-in page from a
template compiled into the binary. The last prints `401`, the admin API refusing an unauthorised
request. On any miss, stop and run `docker compose logs --tail 40 stalwart`: `502` is step 4;
`404` on `/admin/` with a healthy `/healthz/live` is egress.
STOP: tell the user to open https://<DOMAIN>/admin, sign in as `admin` with the value after the colon in `sudo grep STALWART_RECOVERY_ADMIN /srv/stalwart/.env`, and finish the wizard, leaving the hostname and default domain as filled in and DKIM generation on. The wizard shows the permanent administrator password once and never again. Do not continue until they confirm they saved it.
Restart to bring the mail listeners up, then shut the bootstrap door: that credential works on
every sign-in while it sits in the environment.
```bash
sudo test -s /srv/stalwart/etc/config.json && echo "config written"
docker compose restart
sleep 20
ss -ltn | grep -E ':(25|465|993) '
RECOVERY=$(grep '^STALWART_RECOVERY_ADMIN=' /srv/stalwart/.env | cut -d= -f2-)
sed -i '/^STALWART_RECOVERY_ADMIN=/d' /srv/stalwart/.env
docker compose up -d --force-recreate
sleep 20
printf 'user = "%s"\n' "$RECOVERY" | curl -sS -K - -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/account
unset RECOVERY
```
Assert: `config written` prints, `ss` lists all three ports, and the curl prints `401`. A missing
`config.json` means the wizard did not finish. That curl replays the credential that worked ten
minutes ago and is refused, which is the evidence the door is shut; it rides in curl's config on
stdin, so it never reaches the process table. A `200` means the recreate did not read the file.
A running container is not success.
Two things are left. DNS is what makes the mail authenticate, and DKIM is the record the user
cannot invent, because it carries a key that exists only here.
STOP: tell the user to set reverse DNS for this server's IP address to `<DOMAIN>` at their hosting provider, and to publish the records the admin UI lists on the domain's page: MX pointing at `<DOMAIN>`, SPF, DKIM and DMARC. Do not continue until they confirm the PTR record and all four DNS records.
Then the certificate on 465 and 993, which is Stalwart's own: Caddy owns 443, so the wizard's
TLS-ALPN-01 challenge cannot work, and its default order reaches for names like `autoconfig.`
that do not exist.
STOP: tell the user to set, in the admin UI, the ACME provider's challenge type to HTTP-01, the domain certificate's subject alternative names to `<DOMAIN>` alone, and `useXForwarded` on in the HTTP settings. Do not continue until they confirm all three are saved.
```bash
DOM=$(echo "<DOMAIN>" | cut -d. -f2-)
dig +short MX "$DOM"
dig +short TXT "$DOM"
dig +short TXT "_dmarc.$DOM"
dig +short -x $(dig +short <DOMAIN> | tail -1)
sleep 120
openssl s_client -connect <DOMAIN>:465 -servername <DOMAIN> -verify_return_error </dev/null 2>&1 | grep -E 'Verify return code|issuer='
```
Assert, printing every line: MX names `<DOMAIN>`, the TXT answer contains `v=spf1`, the DMARC
answer contains `v=DMARC1`, the PTR answer reads `<DOMAIN>.`, and the last reads
`Verify return code: 0 (ok)`. DKIM sits under a selector the wizard chose, so check it by the
name the admin UI printed. Propagation runs in hours: re-run before calling an empty answer a
failure. Anything but `0 (ok)` means the self-signed fallback is still there, every mail client
will refuse it, and the reason is in `docker compose logs --tail 60 stalwart`.
## 8. First backup and restore
One archive: the configuration, the RocksDB, `.env`, the live Caddyfile.
```bash
cd /srv/stalwart
docker compose stop
sudo tar -czf /srv/stalwart/backups/stalwart-$(date +%F).tar.gz -C /srv/stalwart compose.yml .env etc data -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/stalwart/backups/
```
Assert: the archive exists and is non-empty. Print its size. The container is stopped on purpose:
RocksDB is a set of files under active write, and one tarred mid-write restores as a corrupt
database, not a mailbox. Downtime is a minute, and senders retry.
A backup on the same disk is not a backup, so run this on the user's machine:
```bash
mkdir -p ~/backups/stalwart
scp vps:/srv/stalwart/backups/*.tar.gz ~/backups/stalwart/
```
To restore on a fresh box: run steps 1 to 6, do not start the container, then
`sudo tar -xzf <archive> -C /srv/stalwart compose.yml .env etc data`,
`sudo chown -R 2000:2000 /srv/stalwart/etc /srv/stalwart/data`, `docker compose up -d`. `.env`
must be back before the first start, and `etc/config.json` too or the server returns to bootstrap
mode and hands a stranger the wizard.
## 9. Updating later
New versions are at https://github.com/stalwartlabs/stalwart/releases and reach Docker Hub under
the same tag; https://github.com/stalwartlabs/stalwart/tree/main/UPGRADING has a note per version.
Back up, then edit the image line in /srv/stalwart/compose.yml:
```bash
cd /srv/stalwart
docker compose pull
docker compose up -d
docker compose logs --tail 40 stalwart
```
Stalwart migrates its own database on the way up and refuses to start rather than run against a
schema it does not recognise, so watch that log until it settles, then re-run step 7's four
checks. The web interface updates itself, being a GitHub bundle.
## 10. What will probably go wrong
Your mail will land in spam folders, and nothing in this prompt fixes that on the day you run
it. I had MX, SPF, DKIM, DMARC and a matching PTR record all correct, and the first message I
sent to a large provider went to the junk folder anyway. That is not a misconfiguration, it is
the system working: this IP address has no sending history, and that history is the product the
provider you are leaving actually sells. It is earned over weeks, by sending small volumes of
mail people open and reply to from an address whose DNS has stopped changing. The failure mode is
impatience: ten years of archives imported and a newsletter sent in week one is how a fresh
address gets blocklisted.
## 11. Out of scope
- Do not configure an external directory, LDAP or OIDC in the wizard. The internal directory is
what creates the administrator account this prompt depends on.
- Do not enable the POP3, ManageSieve, plain IMAP or 587 listeners or publish their ports.
Upstream's advice is to run the ports you use and no more.
- Do not install a webmail client; that is a separate application on its own hostname.
- Do not turn on the enterprise features; they need a key from Stalwart Labs.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.
You are installing Stalwart 0.16.17 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the mail hostname whose A record
already points at the box.
Read this before step 1. This is a mail server, which means two things no other install on
this site involves. Ports 25, 465 and 993 are published on every interface rather than on
loopback, because other mail servers open 25 themselves and your phone opens 465 and 993
itself, and no reverse proxy can stand in for that. And the software is only half the job:
whether your mail reaches an inbox or a spam folder is decided by DNS records, by the reverse
DNS on your IP address, and by weeks of sending history you do not have yet. Pick a hostname
you intend to keep: `<DOMAIN>` is `mail.example.com`, one label in front of the domain your
addresses will live at, and it becomes the name in SMTP EHLO, the name on the certificate your
phone checks on port 993, and the name reverse DNS has to match.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
dig +short -x $(dig +short <DOMAIN> | tail -1)
timeout 10 bash -c 'exec 3<>/dev/tcp/aspmx.l.google.com/25' && echo "outbound 25 OPEN" || echo "outbound 25 BLOCKED"
```
You should see: at least `1024` MB available, at least `10` G free, `amd64` or `arm64`, your
server's IP, a PTR record reading `<DOMAIN>.`, and `outbound 25 OPEN`.
If you do not: an empty fourth line means the A record does not exist yet, so add it, wait a
minute and re-run, because Caddy cannot get a certificate for a name that does not resolve. An
empty or wrong fifth line means reverse DNS is not set. Only your hosting provider can set it,
usually in a field called rDNS or PTR next to the IP address in their control panel, and until
it matches, receiving servers read your mail as more likely forged. On resources, upstream
measures an idle Stalwart near 100 MB and calls 1 GB enough for five to ten users; the 10 GB
floor is about mail, which only grows.
`outbound 25 BLOCKED` is the one that stops this install dead. Most VPS providers block
outbound port 25 by default, because most spam comes from rented boxes, and most of them
unblock it on request after a short conversation about what the machine is for. Open that
ticket now and wait for the answer before going further. A server that cannot open 25 outbound
receives mail perfectly well and delivers none of it, and it will look like a working install
for about a day.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/stalwart /srv/stalwart/backups
sudo install -d -m 700 -o 2000 -g 2000 /srv/stalwart/etc /srv/stalwart/data /srv/stalwart/log
ls -la /srv/stalwart
```
You should see: `backups` owned by you, and `etc`, `data` and `log` at mode `drwx------` owned
by `2000`.
If you do not: the uid matters. The image creates a `stalwart` user with uid 2000 and runs as
it, so a directory owned by you is a directory the container cannot write. `etc` will hold
`config.json`, the one file whose presence means setup is finished; `data` will hold the
RocksDB with every message and every setting in it; `log` exists because the setup wizard's
default log destination is a path under `/var/log/stalwart` the image does not create, and
without the mount you get startup errors about a directory that is not there.
## 3. Secrets
One secret: the bootstrap administrator credential, generated here on the server. Replace
`<DOMAIN>` on the first two lines with your real hostname before you paste.
```bash
umask 077
cat > /srv/stalwart/.env <<EOF
STALWART_HOSTNAME=<DOMAIN>
STALWART_PUBLIC_URL=https://<DOMAIN>
STALWART_RECOVERY_ADMIN=admin:$(openssl rand -hex 24)
EOF
chmod 600 /srv/stalwart/.env
umask 022
ls -l /srv/stalwart/.env
```
You should see: mode `-rw-------` and your own username twice. Your bootstrap username is
`admin` and the password is the part after the colon; read it once with
`sudo grep STALWART_RECOVERY_ADMIN /srv/stalwart/.env` when step 7 asks for it.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/stalwart/.env` and
carry on. Setting this value before the first start is the whole point of the step: left
unset, Stalwart generates its own bootstrap password and writes it to the container log in
clear text, once, where it stays for anyone who can read `docker compose logs`. Hex rather
than base64 because Stalwart reads a leading `$`, `_` or `{` in this value as a hash prefix.
Do not paste that file, the credential, or any command output containing it into this chat
window. The agent path never sees those values; this window will hand them to a third party
unless you keep them out of it.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/stalwart/compose.yml <<'EOF'
# Stalwart · the deterministic fallback. Authored by caniselfhostit from
# https://stalw.art/docs/install/platform/docker and
# https://stalw.art/docs/install/security
#
# DELIBERATE DEVIATION from every other entry in this catalog: 25, 465 and 993
# are published on every interface rather than on 127.0.0.1, because other mail
# servers open 25 themselves and clients open 465 and 993 themselves, over TLS
# this container terminates. One port stays on loopback and Caddy alone reaches
# it: 8189, the plain HTTP listener carrying the admin UI, JMAP, autoconfig and
# the ACME challenge.
#
# One service: mail, accounts and configuration all live in an embedded RocksDB
# under /var/lib/stalwart. While /etc/stalwart/config.json is absent the server
# runs in bootstrap mode, one HTTP listener on 8080 and no mail ports;
# afterwards it opens 25, 465, 993, 995, 4190, 443 and 8080, the last three
# unpublished. No 587: upstream creates no listener on it. The image runs as
# uid 2000 and ships its own HEALTHCHECK. Digest read 2026-08-14; amd64, arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
stalwart:
image: stalwartlabs/stalwart:v0.16.17@sha256:a8108e19bd927e172d4d8c128907b8dfc93fd180ae8ee07dccdd42cb97eb9dfa
container_name: stalwart
restart: unless-stopped
# Hostname, public URL and the bootstrap credential. Mode 600.
env_file: /srv/stalwart/.env
volumes:
# config.json, the RocksDB, the log path; all owned by uid 2000.
- /srv/stalwart/etc:/etc/stalwart
- /srv/stalwart/data:/var/lib/stalwart
- /srv/stalwart/log:/var/log/stalwart
ports:
- "25:25"
- "465:465"
- "993:993"
# Loopback only: the host's Caddy is the only thing that reaches 8189.
- "127.0.0.1:8189:8080"
EOF
cd /srv/stalwart && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `docker compose config` prints the line and column it choked on. The usual cause
is a heredoc that was pasted in two pieces, which leaves a stray `EOF` in the middle of the
file. Delete /srv/stalwart/compose.yml and paste the whole block again in one go.
## 5. Caddy and TLS
Copy the Caddyfile first: a syntax error here takes down every other site on the box. Replace
`<DOMAIN>` with your real hostname in the block below before you paste it.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-stalwart
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Stalwart · the Caddy site block for this service. Authored by caniselfhostit
# from https://stalw.art/docs/install/security and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box, which is also STALWART_HOSTNAME in .env and has to match
# the PTR record. The whole hostname is proxied, not only /admin: the same
# listener serves JMAP, WebDAV, autoconfig, MTA-STS, OAuth and the
# acme-challenge path answered while renewing the 465 and 993 certificate.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8189 is the loopback port compose publishes for the container's plain
# HTTP listener. Not a container port, not in the firewall.
reverse_proxy 127.0.0.1:8189
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from the reload.
If you do not: restore the copy with
`sudo cp /etc/caddy/Caddyfile.before-stalwart /etc/caddy/Caddyfile`, reload, and read what
validate objected to before trying again. The most common cause is a `<DOMAIN>` left literal.
Caddy issues and renews the certificate for the web side by itself. The certificate on 25, 465
and 993 is a different one that Stalwart gets for itself, and step 7 finishes that.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 25/tcp
sudo ufw allow 465/tcp
sudo ufw allow 993/tcp
sudo ufw status verbose
```
You should see: `Status: active`, and rules for 25, 80, 443 and 465 and 993, and no rule for
8189.
If you do not: 80/tcp answers the ACME challenge and 443 is Caddy. 25/tcp is how every other
mail server on the internet reaches yours, 465/tcp is where your mail client submits under
implicit TLS, which upstream recommends over 587, and 993/tcp is IMAP. 8189 is bound to
127.0.0.1 so it must not appear, and 995, 4190, 143, 110 and 587 stay closed because the
compose file publishes none of them. One honest note about that output: Docker writes its own
iptables rules for published ports and they are read before ufw's, so 25, 465 and 993 are
reachable whether or not ufw lists them. The `ports:` list in compose.yml is the real control
here; these rules record the intent and cover the host itself.
## 7. Start and verify
With no `config.json` on disk, Stalwart starts in bootstrap mode: one plain HTTP listener on
8080, the setup wizard at `/admin`, and no mail ports at all. They appear after the wizard.
```bash
cd /srv/stalwart
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthz/live); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/admin/
curl -sS https://<DOMAIN>/login | grep -c '<title>Sign in</title>'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/account
```
You should see: the loop ending on `200`, then `200`, then `1`, then `401`.
If you do not: a `502` from the loop means Caddy is reaching nothing, so check step 4 and
`docker compose logs --tail 40 stalwart`. A `404` on `/admin/` while `/healthz/live` answers
`200` is a different problem entirely: the web interface is a bundle the server downloads from
GitHub the first time it starts, and both `/admin` and `/account` answer `404` until that
download succeeds, so the fix is outbound HTTPS from the box, not anything in this prompt. The
`1` is the sign-in page rendering from a template compiled into the binary. The `401` is the
admin API refusing a request that carries no credentials, and it is the one line here with
security meaning: if it prints `200`, stop and work out why before going further.
Now open https://<DOMAIN>/admin in a browser and sign in as `admin`, with the password from
`sudo grep STALWART_RECOVERY_ADMIN /srv/stalwart/.env` (the part after the colon). Finish the
setup wizard, leaving the hostname and the default domain as they are filled in and leaving
DKIM key generation on. The wizard shows you the password for the permanent administrator
account exactly once. Put it in your password manager before you click away, because there is
no second chance to read it.
Back in the shell, restart so the mail listeners come up, then shut the bootstrap door behind
you: that recovery credential is honoured on every sign-in for as long as it sits in the
environment, so it does not belong there now that a real administrator exists.
```bash
sudo test -s /srv/stalwart/etc/config.json && echo "config written"
docker compose restart
sleep 20
ss -ltn | grep -E ':(25|465|993) '
RECOVERY=$(grep '^STALWART_RECOVERY_ADMIN=' /srv/stalwart/.env | cut -d= -f2-)
sed -i '/^STALWART_RECOVERY_ADMIN=/d' /srv/stalwart/.env
docker compose up -d --force-recreate
sleep 20
printf 'user = "%s"\n' "$RECOVERY" | curl -sS -K - -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/account
unset RECOVERY
```
You should see: `config written`, three listening lines for 25, 465 and 993, and `401` on the
last line.
If you do not: no `config written` means the wizard did not finish, and nothing below this will
work until it does. Missing listeners mean the restart came too early, so wait and re-run the
`ss` line. That last `401` is the assert that matters: it replays the exact credential that
worked ten minutes ago and is refused, which is the evidence the bootstrap door is shut. The
credential travels in curl's configuration on stdin rather than on the command line, so it
never appears in the process table. If it prints `200`, the recreate did not pick up the edited
file: run `docker compose up -d --force-recreate` again and repeat the curl.
Two things are left, and both are done in the admin UI. First, DNS. Open the domain the wizard
configured and copy the record set the page lists: the MX record pointing at `<DOMAIN>`, the
SPF TXT record, the DKIM TXT record and a DMARC TXT record. DKIM is the one you cannot write
yourself, because it carries a public key that exists only on this server. Paste them into your
DNS provider's control panel. Second, the certificate on the mail ports. Caddy owns port 443 on
this box, so the TLS-ALPN-01 challenge the wizard picked cannot work, and its default
certificate order also reaches for names like `autoconfig.` and `mta-sts.` that do not exist
yet. Open the ACME provider the wizard created, change its challenge type to HTTP-01, then open
the domain and set the certificate's subject alternative names to `<DOMAIN>` and nothing else.
While you are in there, turn on `useXForwarded` in the HTTP settings, so a failed login is
counted against the address it came from rather than against Caddy.
```bash
DOM=$(echo "<DOMAIN>" | cut -d. -f2-)
dig +short MX "$DOM"
dig +short TXT "$DOM"
dig +short TXT "_dmarc.$DOM"
dig +short -x $(dig +short <DOMAIN> | tail -1)
sleep 120
openssl s_client -connect <DOMAIN>:465 -servername <DOMAIN> -verify_return_error </dev/null 2>&1 | grep -E 'Verify return code|issuer='
```
You should see: an MX answer naming `<DOMAIN>`, a TXT answer containing `v=spf1`, a DMARC
answer containing `v=DMARC1`, a PTR answer reading `<DOMAIN>.`, and a last line reading
`Verify return code: 0 (ok)`.
If you do not: DNS propagation runs in hours rather than minutes, so an empty answer a few
minutes after publishing is normal and the fix is to wait and re-run rather than to change
anything. DKIM sits under a selector the wizard chose, so check that one with the exact name
the admin UI printed rather than guessing. Anything other than `0 (ok)` on the last line means
Stalwart is still presenting the self-signed certificate it falls back to when it has none, and
every mail client will refuse to connect: the reason is in
`docker compose logs --tail 60 stalwart` under the ACME events, and it is usually a subject
alternative name that still lists a hostname with no DNS behind it.
## 8. First backup and restore
```bash
cd /srv/stalwart
docker compose stop
sudo tar -czf /srv/stalwart/backups/stalwart-$(date +%F).tar.gz -C /srv/stalwart compose.yml .env etc data -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/stalwart/backups/
```
You should see: one `.tar.gz` with a non-zero size.
If you do not: the container is stopped on purpose and it matters. RocksDB is a set of files
under constant write, and one tarred mid-write restores as a corrupt database rather than as a
mailbox. Downtime is under a minute, and mail that arrives during it is retried by the sending
server, which is what SMTP does. Now copy the archive off the box, from your own machine rather
than from the server:
```bash
mkdir -p ~/backups/stalwart
scp vps:/srv/stalwart/backups/*.tar.gz ~/backups/stalwart/
```
A backup on the same disk as the data is not a backup. To restore onto a fresh box: run steps 1
to 6 so the directories, the Caddy block and the firewall exist, do not start the container,
then `sudo tar -xzf <archive> -C /srv/stalwart compose.yml .env etc data`, then
`sudo chown -R 2000:2000 /srv/stalwart/etc /srv/stalwart/data`, then `docker compose up -d`.
The `.env` has to be back before the first start, and `etc/config.json` has to be back too, or
the server comes up in bootstrap mode and offers the setup wizard to whoever finds it first.
`data/` is every message anyone has ever sent you.
## 9. Updating later
New versions are listed at https://github.com/stalwartlabs/stalwart/releases and reach Docker
Hub under the same tag string, and https://github.com/stalwartlabs/stalwart/tree/main/UPGRADING
carries a note per version. Take a backup first, then edit the image line in
/srv/stalwart/compose.yml to the new tag and its digest:
```bash
cd /srv/stalwart
docker compose pull
docker compose up -d
docker compose logs --tail 40 stalwart
```
You should see: the new version starting, and no error about a database schema.
If you do not: Stalwart migrates its own database on the way up and refuses to start rather
than run against a schema it does not recognise, which is the correct behaviour and the reason
the backup comes first. Re-run the four checks from step 7 before calling the update done. The
web interface updates on its own schedule, separately from the server, because it is a bundle
the server re-downloads from GitHub.
## 10. What will probably go wrong
Your mail will land in spam folders, and nothing in this prompt fixes that on the day you run
it. I had MX, SPF, DKIM, DMARC and a matching PTR record all correct, and the first message I
sent to a large provider went to the junk folder anyway. That is not a misconfiguration, it is
the system working: this IP address has no sending history, and that history is the product the
provider you are leaving actually sells you. It is earned over weeks, by sending small volumes
of mail that people open and reply to, from an address whose DNS has stopped changing. The
failure mode to watch for is impatience: importing ten years of archives and sending a
newsletter in week one is how a fresh address gets itself blocklisted. Send to yourself, then
to a friend, then to a colleague, and read a DMARC aggregate report before you move anything
that matters.
## 11. Out of scope
- Do not configure an external directory, LDAP or OpenID Connect in the wizard. The internal
directory is what creates the administrator account this prompt depends on, and choosing
another skips that step entirely.
- Do not enable the POP3, ManageSieve, plain IMAP or 587 listeners, and do not publish their
ports. Upstream's own advice is to run the ports you use and no others.
- Do not install a webmail client. Stalwart serves JMAP, IMAP, CalDAV and CardDAV, and a client
is a separate application on its own hostname with its own install.
- Do not turn on the enterprise features in the admin UI. They need a license key from Stalwart
Labs, and this is the community build.298 lines · 14,989 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Stalwart 0.16.17 under ~/selfhost/stalwart, answering at http://localhost:8189.
## 1. Preflight
Say this to the user before step 2 runs, because it decides whether they want this install at
all. A mail server on a laptop is not a mail server. Nothing on the internet can open port 25 on
this machine, no phone can reach 993 on it, and a computer that sleeps stops being a mail
exchanger when the lid closes. This install publishes no mail port. What they get is the real
software and its administration interface, as a sandbox: click through the setup wizard, create
accounts and domains, read the DKIM and SPF records it generates. For mail that arrives, the
server path is on the other tab.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Stalwart needs 1024 MB of RAM available and
10 GB free on the home disk, and the image publishes amd64 and arm64. Every branch prints free
memory, so one floor covers all three; on macOS and Windows it is the host's, and Docker Desktop
takes its allocation from it. If RAM is under 1024 MB or disk under 10 GB, print both and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/stalwart/etc ~/selfhost/stalwart/data ~/selfhost/stalwart/log ~/selfhost/stalwart/backups
if [ "$(uname -s)" = "Linux" ]; then sudo chown -R 2000:2000 ~/selfhost/stalwart/etc ~/selfhost/stalwart/data ~/selfhost/stalwart/log; fi
ls -la ~/selfhost/stalwart
```
Assert: `ls -la` shows `etc`, `data`, `log` and `backups`. The image creates a `stalwart` user
with uid 2000 and runs as it, so on Linux those three are chowned to that uid or the container
cannot write them; on macOS and Windows the fence is a no-op and Docker Desktop handles
ownership. `etc` holds `config.json`, `data` the RocksDB, `log` the wizard's log path the
image does not create.
## 4. Secrets
One secret: the bootstrap administrator credential, generated here. Do not print it, repeat it in
your summary, or log it. Hex, not base64: Stalwart reads a leading `$`, `_` or `{` as a hash
prefix.
```bash
umask 077
cat > ~/selfhost/stalwart/.env <<EOF
STALWART_PUBLIC_URL=http://localhost:8189
STALWART_RECOVERY_ADMIN=admin:$(openssl rand -hex 24)
EOF
chmod 600 ~/selfhost/stalwart/.env
umask 022
ls -l ~/selfhost/stalwart/.env
```
Assert: mode `-rw-------`. Setting this before the first start is the point: left unset, Stalwart
generates its own bootstrap password and writes it to the container log in clear text. On Windows
the mode bits are advisory on NTFS and the real boundary is the user's own Windows account. Step
7 deletes the line.
## 5. compose.yml
```bash
cat > ~/selfhost/stalwart/compose.yml <<'EOF'
# Stalwart · the deterministic fallback for the local path. Authored by
# caniselfhostit from https://stalw.art/docs/install/platform/docker and
# https://stalw.art/docs/install/security
#
# One service, one published port: 8189, the plain HTTP listener, on loopback.
# No mail port is published, and that is the shape of this path. Nothing on the
# internet can open 25 on a laptop, no phone on the wifi can reach 993 on it,
# and a machine that sleeps is not a mail exchanger. What runs is the server and
# its admin interface, no domain at risk and no mail in flight.
#
# Paths are relative to ~/selfhost/stalwart/, so one file works on macOS, Linux
# and Windows, and all three stay bind mounts so you can open them in Finder or
# Explorer. The image runs as uid 2000, which step 3 chowns for on Linux. Same
# tag and digest as the VPS compose file.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
stalwart:
image: stalwartlabs/stalwart:v0.16.17@sha256:a8108e19bd927e172d4d8c128907b8dfc93fd180ae8ee07dccdd42cb97eb9dfa
container_name: stalwart
restart: unless-stopped
# Public URL and the generated bootstrap credential. Mode 600.
env_file: ./.env
volumes:
# config.json, the RocksDB, the log path; all owned by uid 2000.
- ./etc:/etc/stalwart
- ./data:/var/lib/stalwart
- ./log:/var/log/stalwart
ports:
# Loopback only, and the same host port the VPS compose file publishes.
- "127.0.0.1:8189:8080"
EOF
cd ~/selfhost/stalwart && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK` prints. One service, one port, three bind mounts.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
to resolve, a certificate attests a public name and nothing here has one, and browsers treat
http://localhost as a secure context.
8189 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, not anyone on the internet. For a mail server that is not a trade, it is the character of
this path, because mail arrives on port 25 from machines that must open it. Confirm it:
```bash
grep -c '"127.0.0.1:' ~/selfhost/stalwart/compose.yml
```
Assert: that prints `1`, and it is the line `- "127.0.0.1:8189:8080"`. Anything else means a mail
port has been published and this is no longer the sandbox it claims to be. Stalwart still reaches
the internet for the web interface download and for DNS: a loopback binding governs what arrives,
not what the container can call.
## 7. Start and verify
With no `config.json` on disk, Stalwart starts in bootstrap mode: one plain HTTP listener on
8080, the wizard at `/admin`, no mail listeners.
```bash
cd ~/selfhost/stalwart
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8189/healthz/live); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8189/admin/
curl -sS http://localhost:8189/login | grep -c '<title>Sign in</title>'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8189/api/account
```
Assert all four and print what you received. The loop ends on `200`. `/admin/` prints `200`: the
web interface is a bundle downloaded from GitHub on first start and `/admin` answers `404` until
that succeeds, so this tests whether this machine can reach GitHub. The grep prints `1`, the
sign-in page from a template compiled into the binary. The last prints `401`, the admin API
refusing a request with no credentials. On any miss, stop, run
`docker compose logs --tail 40 stalwart` and name the cause: a container that exits on its own
points at step 3, a data directory it cannot write. If `port is already allocated` came back, find
what holds 8189 (`lsof -nP -iTCP:8189 -sTCP:LISTEN`, `ss -ltnp | grep 8189` on Linux,
`netstat -ano | findstr :8189` on Windows) and stop until it is free. A running container is
not success.
STOP: tell the user to open http://localhost:8189/admin, sign in as `admin` with the value after the colon in `grep STALWART_RECOVERY_ADMIN ~/selfhost/stalwart/.env`, and finish the wizard: a hostname and domain they can imagine using, DKIM key generation on, and the TLS certificate request OFF, because there is no public name here for a certificate authority to check. The wizard shows the permanent administrator password once and never again. Do not continue until they confirm they saved it.
Then restart so the configuration takes effect and shut the bootstrap door: that credential works
on every sign-in while it is set.
```bash
test -s ~/selfhost/stalwart/etc/config.json && echo "config written"
docker compose restart
sleep 20
RECOVERY=$(grep '^STALWART_RECOVERY_ADMIN=' ~/selfhost/stalwart/.env | cut -d= -f2-)
sed -i.bak '/^STALWART_RECOVERY_ADMIN=/d' ~/selfhost/stalwart/.env && rm -f ~/selfhost/stalwart/.env.bak
docker compose up -d --force-recreate
sleep 20
printf 'user = "%s"\n' "$RECOVERY" | curl -sS -K - -o /dev/null -w '%{http_code}\n' http://localhost:8189/api/account
unset RECOVERY
```
Assert: `config written` prints and the curl prints `401`. That curl replays a credential that
worked ten minutes ago and is refused, which is the evidence the door is shut; it rides in curl's
configuration on stdin, so it never reaches the process table. A `200` means the recreate did not
read the edited file: run it again. A missing `config.json` means the wizard did not finish. Then
tell the user what the sandbox is for: the domain page lists the DNS record set their domain would
need, DKIM key and all, and that list is worth reading before they rent a server.
## 8. First backup and restore
One archive: the configuration, the RocksDB, `.env`, the compose file.
```bash
cd ~/selfhost/stalwart
docker compose stop
tar -C ~/selfhost/stalwart -czf ~/selfhost/stalwart/backups/stalwart-$(date +%F).tar.gz compose.yml .env etc data
docker compose start
ls -lh ~/selfhost/stalwart/backups/
```
Assert: the archive exists and is non-empty. Print its size. The container is stopped on purpose:
RocksDB is a set of files under active write, and one tarred mid-write restores as a corrupt
database, not a mailbox. Downtime is a minute.
That archive sits on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a folder
their sync service watches or a USB stick, and copy it there with `cp`. In Git Bash a Windows
drive is `/d/Backups`, not `D:\Backups`. Assert: the user confirms the file is there. If they have
nowhere, say plainly that this install has no backup.
To restore: `cd ~/selfhost/stalwart`, `docker compose down`, `rm -rf etc data`, untar the archive
there, re-run the Linux chown from step 3, `docker compose up -d`. `.env` must be back before the
first start, and `etc/config.json` too or the server returns to bootstrap mode.
## 9. Updating later
New versions are at https://github.com/stalwartlabs/stalwart/releases, and
https://github.com/stalwartlabs/stalwart/tree/main/UPGRADING has a note per version. Back up, then
edit the image line in the compose file:
```bash
cd ~/selfhost/stalwart
docker compose pull
docker compose up -d
docker compose logs --tail 40 stalwart
```
Stalwart migrates its own database on the way up and refuses to start rather than run against a
schema it does not recognise, so watch that log until it settles, then re-run step 7's checks.
## 10. What will probably go wrong
I left this running for a week and then wondered why the queue was empty. Nothing was wrong: no
mail can arrive at a machine no mail server can open a connection to, and nothing had been sent
because there was no domain behind any of it. That is the honest shape of a mail server on a
laptop, and worth saying twice because the software gives no sign of it: the dashboard is green,
the accounts exist, and the product does not do the one thing mail is for. The smaller annoyance
is that after a reboot Docker Desktop is not running until you open it, so http://localhost:8189
refuses the connection and looks broken. Turn on start-at-login, and after any reboot run
`cd ~/selfhost/stalwart && docker compose up -d`.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not publish ports 25, 465 or 993. A mail port open on a laptop that joins other networks is
a mail port open on those networks.
- Do not configure an external directory, LDAP or OIDC. The internal directory creates the
administrator account this prompt depends on.
- Do not turn on the enterprise features; they need a key from Stalwart Labs.compose.local.ymlthe services, pinned · local layout32 lines
# Stalwart · the deterministic fallback for the local path. Authored by
# caniselfhostit from https://stalw.art/docs/install/platform/docker and
# https://stalw.art/docs/install/security
#
# One service, one published port: 8189, the plain HTTP listener, on loopback.
# No mail port is published, and that is the shape of this path. Nothing on the
# internet can open 25 on a laptop, no phone on the wifi can reach 993 on it,
# and a machine that sleeps is not a mail exchanger. What runs is the server and
# its admin interface, no domain at risk and no mail in flight.
#
# Paths are relative to ~/selfhost/stalwart/, so one file works on macOS, Linux
# and Windows, and all three stay bind mounts so you can open them in Finder or
# Explorer. The image runs as uid 2000, which step 3 chowns for on Linux. Same
# tag and digest as the VPS compose file.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
stalwart:
image: stalwartlabs/stalwart:v0.16.17@sha256:a8108e19bd927e172d4d8c128907b8dfc93fd180ae8ee07dccdd42cb97eb9dfa
container_name: stalwart
restart: unless-stopped
# Public URL and the generated bootstrap credential. Mode 600.
env_file: ./.env
volumes:
# config.json, the RocksDB, the log path; all owned by uid 2000.
- ./etc:/etc/stalwart
- ./data:/var/lib/stalwart
- ./log:/var/log/stalwart
ports:
# Loopback only, and the same host port the VPS compose file publishes.
- "127.0.0.1:8189:8080"agent-readable mirror: /self-host/fastmail.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned38 lines
# Stalwart · the deterministic fallback. Authored by caniselfhostit from
# https://stalw.art/docs/install/platform/docker and
# https://stalw.art/docs/install/security
#
# DELIBERATE DEVIATION from every other entry in this catalog: 25, 465 and 993
# are published on every interface rather than on 127.0.0.1, because other mail
# servers open 25 themselves and clients open 465 and 993 themselves, over TLS
# this container terminates. One port stays on loopback and Caddy alone reaches
# it: 8189, the plain HTTP listener carrying the admin UI, JMAP, autoconfig and
# the ACME challenge.
#
# One service: mail, accounts and configuration all live in an embedded RocksDB
# under /var/lib/stalwart. While /etc/stalwart/config.json is absent the server
# runs in bootstrap mode, one HTTP listener on 8080 and no mail ports;
# afterwards it opens 25, 465, 993, 995, 4190, 443 and 8080, the last three
# unpublished. No 587: upstream creates no listener on it. The image runs as
# uid 2000 and ships its own HEALTHCHECK. Digest read 2026-08-14; amd64, arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
stalwart:
image: stalwartlabs/stalwart:v0.16.17@sha256:a8108e19bd927e172d4d8c128907b8dfc93fd180ae8ee07dccdd42cb97eb9dfa
container_name: stalwart
restart: unless-stopped
# Hostname, public URL and the bootstrap credential. Mode 600.
env_file: /srv/stalwart/.env
volumes:
# config.json, the RocksDB, the log path; all owned by uid 2000.
- /srv/stalwart/etc:/etc/stalwart
- /srv/stalwart/data:/var/lib/stalwart
- /srv/stalwart/log:/var/log/stalwart
ports:
- "25:25"
- "465:465"
- "993:993"
# Loopback only: the host's Caddy is the only thing that reaches 8189.
- "127.0.0.1:8189:8080"Caddyfilethe hostname and TLS25 lines
# Stalwart · the Caddy site block for this service. Authored by caniselfhostit
# from https://stalw.art/docs/install/security and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box, which is also STALWART_HOSTNAME in .env and has to match
# the PTR record. The whole hostname is proxied, not only /admin: the same
# listener serves JMAP, WebDAV, autoconfig, MTA-STS, OAuth and the
# acme-challenge path answered while renewing the 465 and 993 certificate.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8189 is the loopback port compose publishes for the container's plain
# HTTP listener. Not a container port, not in the firewall.
reverse_proxy 127.0.0.1:8189
}install.shthe same install, no agent214 lines
#!/usr/bin/env bash
# Stalwart · the agent-free install.
#
# Everything prompt.md tells an agent to do up to the setup wizard, as a script
# you can read first. Run it on the VPS, as a non-root user in the docker group:
#
# DOMAIN_HOST=mail.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation and from the
# source at the pinned tag:
# https://stalw.art/docs/install/platform/docker
# https://stalw.art/docs/install/security
# https://stalw.art/docs/install/requirements
# https://stalw.art/docs/install/dns
# https://github.com/stalwartlabs/stalwart/blob/v0.16.17/Dockerfile
#
# One secret is generated here, on this machine: the bootstrap administrator
# credential. It goes into /srv/stalwart/.env with mode 600 and is never
# printed. Setting it in advance is deliberate. Left unset, Stalwart generates
# its own and writes it to the container log in clear text, once, where anyone
# who can read `docker logs` can read it afterwards.
#
# DOMAIN_HOST is the mail host, for example mail.example.com. It becomes
# STALWART_HOSTNAME, the name this server gives other servers in SMTP EHLO, and
# it must match the PTR record of this box's IP address. Use one label in front
# of your domain rather than the bare domain: your addresses live at the domain
# underneath it.
#
# This script stops at the setup wizard, because only a human can complete it.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/stalwart}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the mail hostname you pointed at this server, e.g. mail.example.com"
case "$DOMAIN_HOST" in
*.*.*) : ;;
*) die "$DOMAIN_HOST looks like a bare domain. Use a host under it, e.g. mail.$DOMAIN_HOST" ;;
esac
MAIL_DOMAIN="$(printf '%s' "$DOMAIN_HOST" | cut -d. -f2-)"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
command -v dig >/dev/null 2>&1 || die "dig is not installed (apt-get install dnsutils)"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; this install wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; mail grows, this install wants 10 GB"
resolved="$(dig +short "$DOMAIN_HOST" | tail -1)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
ptr="$(dig +short -x "$resolved" | tail -1)" || ptr=""
echo "==> $DOMAIN_HOST resolves to $resolved, whose PTR record reads: ${ptr:-none}"
if [ "$ptr" != "${DOMAIN_HOST}." ]; then
echo "==> WARNING: that PTR record does not read ${DOMAIN_HOST}. Large receivers read a"
echo "==> mismatch as a forgery signal. Set reverse DNS for $resolved in your hosting"
echo "==> provider's control panel before you send mail to anyone who matters."
fi
echo "==> testing whether this provider lets you open port 25 outbound"
if timeout 10 bash -c 'exec 3<>/dev/tcp/aspmx.l.google.com/25' 2>/dev/null; then
echo "==> outbound 25 is OPEN"
else
die "outbound port 25 is blocked here. Most VPS providers block it by default and unblock it on request, and until they do, this server can receive mail and deliver none of it. Ask them first, then run this again."
fi
# --- 2. Lay the files out ----------------------------------------------------
#
# The image runs as uid 2000, so the three directories it writes are owned by
# 2000 rather than by you. /etc/stalwart holds config.json, /var/lib/stalwart
# holds the RocksDB with every message and every setting, /var/log/stalwart is
# where the setup wizard's default log destination points.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 -o 2000 -g 2000 "$APP_DIR/etc" "$APP_DIR/data" "$APP_DIR/log"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64: the value travels as the password half of a
# user:secret pair, and Stalwart reads a leading $, _ or { as a hash prefix,
# none of which hex can produce. Read it later with
# sudo grep STALWART_RECOVERY_ADMIN /srv/stalwart/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
STALWART_HOSTNAME=${DOMAIN_HOST}
STALWART_PUBLIC_URL=https://${DOMAIN_HOST}
STALWART_RECOVERY_ADMIN=admin:$(openssl rand -hex 24)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-stalwart"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: six open, and 8189 is not one of them -------------------------
#
# 80 and 443 are Caddy's. 25 is how other mail servers reach you, 465 is how
# your clients send, 993 is how they read. Docker publishes 25, 465 and 993 by
# writing its own iptables rules, which are consulted before ufw's, so those
# three are reachable whether or not ufw lists them: the compose ports list is
# the real control and these rules record the intent.
if command -v ufw >/dev/null 2>&1; then
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 25/tcp
sudo ufw allow 465/tcp
sudo ufw allow 993/tcp
sudo ufw status verbose
fi
# --- 6. Start it, in bootstrap mode ------------------------------------------
#
# With no config.json present Stalwart opens exactly one listener, plain HTTP
# on 8080, and offers the setup wizard at /admin. No mail port is listening
# yet: those appear after the wizard writes config.json and the container is
# restarted.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/healthz/live"
code=""
for _ in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/healthz/live" 2>/dev/null)" || code="000"
if [ "$code" = "200" ]; then break; fi
sleep 10
done
[ "$code" = "200" ] || die "/healthz/live answered ${code}. Check: docker compose logs --tail 40 stalwart"
admin_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/admin/" 2>/dev/null)" || admin_code="000"
[ "$admin_code" = "200" ] || die "/admin/ answered ${admin_code}, not 200. The web interface is downloaded from GitHub on first start, and both /admin and /account answer 404 until that download succeeds. Check outbound HTTPS, then: docker compose logs --tail 40 stalwart"
curl -sS "https://${DOMAIN_HOST}/login" | grep -q '<title>Sign in</title>' \
|| die "the sign-in page did not render. Check: docker compose logs --tail 40 stalwart"
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/account" 2>/dev/null)" || unauth="000"
[ "$unauth" = "401" ] || die "an unauthenticated admin API call returned ${unauth}, not 401. Stop and investigate."
if sudo test -e "$APP_DIR/etc/config.json"; then
die "$APP_DIR/etc/config.json already exists, so this server is not in bootstrap mode and the wizard will refuse. Stop and work out which earlier run created it."
fi
# --- 7. The first backup, before the wizard ----------------------------------
#
# The container is stopped for the copy. RocksDB is a set of files being
# written, and a tar of one taken mid-write is not a backup.
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/stalwart-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env etc data -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/stalwart-${STAMP}.tar.gz" ] || die "the backup archive is empty"
cat <<-DONE
Stalwart is in bootstrap mode at https://${DOMAIN_HOST}/admin
1. Sign in as the user admin. Read the credential once with
sudo grep STALWART_RECOVERY_ADMIN $APP_DIR/.env
The part after the colon is what you type. It was not printed here.
2. Complete the setup wizard. Leave the hostname as ${DOMAIN_HOST} and the
default domain as ${MAIL_DOMAIN}, leave DKIM key generation on, and leave
the TLS certificate request on. The wizard shows the password for the
permanent admin@${MAIL_DOMAIN} account exactly once: save it then.
3. Restart so the mail listeners come up:
cd $APP_DIR && docker compose restart
Then confirm 25, 465 and 993 answer:
ss -ltn | grep -E ':(25|465|993) '
4. Close the bootstrap door. Delete the STALWART_RECOVERY_ADMIN line from
$APP_DIR/.env, then
cd $APP_DIR && docker compose up -d --force-recreate
and confirm an unauthenticated admin call is still refused:
curl -sS -o /dev/null -w '%{http_code}\n' https://${DOMAIN_HOST}/api/account
That must print 401.
5. Publish DNS for ${MAIL_DOMAIN}: the MX record, the SPF record, the DKIM
record the wizard generated and a DMARC record. The admin UI lists the
whole set on the domain's page. Nothing you send authenticates until
they are live, and propagation is measured in hours.
6. In the admin UI change the ACME provider's challenge type to HTTP-01
and set the certificate subject alternative names to ${DOMAIN_HOST}
alone, then confirm the certificate on the mail ports is a real one:
openssl s_client -connect ${DOMAIN_HOST}:465 -servername ${DOMAIN_HOST} -verify_return_error </dev/null 2>&1 | grep 'Verify return code'
7. Run this backup again after the wizard. The archive written above holds
an empty server. It also sits on the same disk as the data, which is not
a backup: copy it off the box tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Fastmail.
- Deliverability is not a setting, and this is the bullet to read twice. Correct MX, SPF, DKIM, DMARC and a PTR record that matches your hostname are the entry fee, not the finish line: a fresh IP address has no sending history, and that history is most of what Fastmail is actually selling you. Expect your first messages to be spam-foldered by large providers even with everything configured correctly, and expect to earn your way out of it over weeks by sending small volumes of mail that people open and reply to. Check before you start that your hosting provider will unblock outbound port 25, because most block it by default and a server that cannot open 25 receives mail and delivers none of it.
- You are now the postmaster. Mail that bounces is your problem at 2am, a queue that stalls is your problem, and a mailbox lost to a bad restore is gone in a way a chat message never is. Ports 25, 465 and 993 are published on every interface rather than on loopback, because other mail servers and your own phone open them directly and no reverse proxy stands in for that, so this install has a genuinely public attack surface that the rest of this catalogue does not.
- Dual-licensed, and the split matters. The source is AGPL-3.0-only and this install is the community build, which is the whole mail and collaboration server. A separate proprietary Stalwart Enterprise License covers multi-tenancy, per-tenant branding, account archiving and un-deletion, live telemetry and metric alerts, AI-assisted spam filtering, masked email and sharded or replicated storage. Those features are visible in the admin UI and need a paid key from Stalwart Labs to switch on; nothing you install here stops working without one.
- The configuration lives in the database, not in files you can grep. One small config.json says which data store to open, and everything else, listeners, domains, DKIM keys, spam rules, TLS certificates, is a record in the embedded RocksDB edited through the web admin UI or the JMAP API. That makes the wizard genuinely load-bearing, it makes the backup a stop-the-container tar rather than a config copy, and it means the recovery path when you lock yourself out is an environment variable rather than an editor.
- No webmail in the box, no migration wizard, no calendar UI. Stalwart serves JMAP, IMAP, CalDAV, CardDAV and WebDAV; the client that draws them is a separate application you install and host yourself. Moving an existing mailbox in is a separate tool and a separate afternoon.
Where this came from
“Correctly configured DNS records are essential for email delivery, client compatibility, and transport security.”
- The first time Stalwart starts without a config.json it enters bootstrap mode, prints a one-time random administrator password to the console and opens the setup wizard, and the recovery credential can be pinned in advance with the STALWART_RECOVERY_ADMIN environment variable instead, which is what this install does so no credential ever lands in the container log. source
- Upstream recommends keeping 25, 465 and 993 open and calls 587, 143, 110, 995 and 4190 non-essential, and says port 8080 is provided mainly for initial setup and should be disabled afterwards to prevent unauthenticated, unencrypted access. source
- The web administration interface is not compiled into the server: it is a bundle downloaded from GitHub on first start, and both /admin and /account answer 404 until that download succeeds. source
- Upstream measures an idle server at roughly 100 MB of memory and calls 1 GB of RAM generally sufficient for a deployment serving around five to ten users. source
- The project is dual-licensed: the source is AGPL-3.0-only, and a separate proprietary Stalwart Enterprise License v2 covers the enterprise feature set, with 22 source files at the v0.16.17 tag carrying the SEL-only notice. source
- The default listener set created after setup is SMTP on 25, submissions on 465, IMAPS on 993, POP3S on 995, ManageSieve on 4190 and HTTP on 443 and 8080, with no listener on 587 at all. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Fastmail?
Not Fastmail itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Stalwart. The mail server itself: SMTP, IMAP, JMAP, CalDAV and CardDAV in one Rust binary, with the spam filter and the DKIM signing built in and every setting behind a web admin UI. The install is one evening: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 180 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Fastmail?
Stalwart. The mail server itself: SMTP, IMAP, JMAP, CalDAV and CardDAV in one Rust binary, with the spam filter and the DKIM signing built in and every setting behind a web admin UI. The only entry in this catalog that is the mail server rather than something that hands mail to one. One Rust binary and one container speak SMTP, IMAP, JMAP, POP3, ManageSieve, CalDAV, CardDAV and WebDAV, with the spam filter, the DKIM signing and the DMARC reporting built in rather than bolted on, and every part of it configured through a web admin UI instead of a pile of files in /etc. What it cannot hand you is the part of Fastmail you are actually paying for: the reputation of the IP address your mail leaves from. That is earned over weeks with correct DNS, a matching PTR record and a sending pattern that does not look like a compromised box, and the page says so at the top rather than at the bottom. Stalwart is AGPL-3.0-only-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Fastmail?
1024 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Stalwart itself is free and AGPL-3.0-only-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Fastmail Standard, $6/mo — $72 a year, 1 seat assumed.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Stalwart install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Stalwart on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Stalwart on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: A mail server on a laptop is not a mail server: nothing on the internet can open port 25 on it, no phone can reach port 993 on it, and a machine that sleeps stops being a mail exchanger when the lid closes. The local path publishes no mail port at all, and is honestly a sandbox for learning the admin interface and reading the DNS records it generates before you rent a server. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.