Stalwart Mail Server is an open-source mail server solution with JMAP, IMAP4, POP3, and SMTP support and a wide range of modern features. It is written in Rust and designed to be secure, fast, robust and scalable.
| Port | 25 |
| Registry | ghcr.io/daemonless/stalwart |
| Source | https://stalw.art/ |
| Website | https://stalw.art/ |
| Tag | Description | Best For |
|---|---|---|
latest |
Upstream Binary. Built from official release. | Most users. Matches Linux Docker behavior. |
pkg |
FreeBSD Quarterly. Uses stable, tested packages. | Production stability. |
pkg-latest |
FreeBSD Latest. Rolling package updates. | Newest FreeBSD packages. |
Before deploying, ensure your host environment is ready. See the Quick Start Guide for host setup instructions.
services:
stalwart:
image: "ghcr.io/daemonless/stalwart:latest"
container_name: stalwart
environment:
- TZ=UTC # Timezone for the container
- ADMIN_SECRET=changeme # Password for the fallback 'admin' web-admin account
- ENABLE_V4PROXY=true # Enable internal socat IPv4->IPv6 proxy on port 8080 for initial setup UI and healthchecks on FreeBSD (default true). Set false to disable.
- SKIP_CHOWN=true # Skip the startup recursive chown of /config once ownership is recorded in /config/.chown_done (default true). Set false to force a chown on every start. The marker lives in /config, so /config must be a persistent volume for the skip to take effect across restarts.
volumes:
- "/path/to/containers/stalwart:/config"
ports:
- "25:25"
- "465:465"
- "587:587"
- "143:143"
- "993:993"
- "110:110"
- "995:995"
- "4190:4190"
- "443:443"
- "8080:8080"
restart: unless-stopped.env:
# .env
DIRECTOR_PROJECT=stalwart
TZ=UTC
ADMIN_SECRET=changeme
ENABLE_V4PROXY=true
SKIP_CHOWN=true
appjail-director.yml:
# appjail-director.yml
options:
- virtualnet: ':<random> default'
- nat:
services:
stalwart:
name: stalwart
options:
- container: 'boot args:--pull'
- expose: '25:25 proto:tcp' \
- expose: '465:465 proto:tcp' \
- expose: '587:587 proto:tcp' \
- expose: '143:143 proto:tcp' \
- expose: '993:993 proto:tcp' \
- expose: '110:110 proto:tcp' \
- expose: '995:995 proto:tcp' \
- expose: '4190:4190 proto:tcp' \
- expose: '443:443 proto:tcp' \
- expose: '8080:8080 proto:tcp' \
oci:
user: root
environment:
- TZ: !ENV '${TZ}'
- ADMIN_SECRET: !ENV '${ADMIN_SECRET}'
- ENABLE_V4PROXY: !ENV '${ENABLE_V4PROXY}'
- SKIP_CHOWN: !ENV '${SKIP_CHOWN}'
volumes:
- stalwart: /config
volumes:
stalwart:
device: '/path/to/containers/stalwart'Makejail:
# Makejail
ARG tag=latest
OPTION overwrite=force
OPTION from=ghcr.io/daemonless/stalwart:${tag}
Note: Exposing ports in AppJail means that your service can be reached from remote hosts. If that is not your intention, do not expose the ports and communicate with the service using the IPv4 address assigned by the virtual network.
podman run -d --name stalwart \
-p 25:25 \
-p 465:465 \
-p 587:587 \
-p 143:143 \
-p 993:993 \
-p 110:110 \
-p 995:995 \
-p 4190:4190 \
-p 443:443 \
-p 8080:8080 \
-e TZ=UTC \
-e ADMIN_SECRET=changeme \
-e ENABLE_V4PROXY=true \
-e SKIP_CHOWN=true \
-v /path/to/containers/stalwart:/config \
ghcr.io/daemonless/stalwart:latestappjail oci run -Pd \
-o overwrite=force \
-o container="args:--pull" \
-o virtualnet=":<random> default" \
-o nat \
-o expose="25:25 proto:tcp" \
-o expose="465:465 proto:tcp" \
-o expose="587:587 proto:tcp" \
-o expose="143:143 proto:tcp" \
-o expose="993:993 proto:tcp" \
-o expose="110:110 proto:tcp" \
-o expose="995:995 proto:tcp" \
-o expose="4190:4190 proto:tcp" \
-o expose="443:443 proto:tcp" \
-o expose="8080:8080 proto:tcp" \
-e TZ=UTC \
-e ADMIN_SECRET=changeme \
-e ENABLE_V4PROXY=true \
-e SKIP_CHOWN=true \
-o fstab="/path/to/containers/stalwart /config <pseudofs>" \
ghcr.io/daemonless/stalwart:latest stalwartNote: Exposing ports in AppJail means that your service can be reached from remote hosts. If that is not your intention, do not expose the ports and communicate with the service using the IPv4 address assigned by the virtual network.
- name: Deploy stalwart
containers.podman.podman_container:
name: stalwart
image: "ghcr.io/daemonless/stalwart:latest"
state: started
restart_policy: always
env:
TZ: "UTC"
ADMIN_SECRET: "changeme"
ENABLE_V4PROXY: "true"
SKIP_CHOWN: "true"
ports:
- "25:25"
- "465:465"
- "587:587"
- "143:143"
- "993:993"
- "110:110"
- "995:995"
- "4190:4190"
- "443:443"
- "8080:8080"
volumes:
- "/path/to/containers/stalwart:/config"Access at: http://localhost:25
| Variable | Default | Description |
|---|---|---|
TZ |
UTC |
Timezone for the container |
ADMIN_SECRET |
changeme |
Password for the fallback 'admin' web-admin account |
ENABLE_V4PROXY |
true |
Enable internal socat IPv4->IPv6 proxy on port 8080 for initial setup UI and healthchecks on FreeBSD (default true). Set false to disable. |
SKIP_CHOWN |
true |
Skip the startup recursive chown of /config once ownership is recorded in /config/.chown_done (default true). Set false to force a chown on every start. The marker lives in /config, so /config must be a persistent volume for the skip to take effect across restarts. |
| Path | Description |
|---|---|
/config |
Config (config.toml) + rocksdb data store |
| Port | Protocol | Description |
|---|---|---|
25 |
TCP | SMTP |
465 |
TCP | SMTP submission (implicit TLS) |
587 |
TCP | SMTP submission (STARTTLS) |
143 |
TCP | IMAP4 |
993 |
TCP | IMAP4 (implicit TLS) |
110 |
TCP | POP3 |
995 |
TCP | POP3 (implicit TLS) |
4190 |
TCP | ManageSieve |
443 |
TCP | HTTPS / JMAP / web admin |
8080 |
TCP | HTTP / JMAP / web admin |
- Internal IPv4 Proxy (
ENABLE_V4PROXY): Stalwartv0.16.xbootstrap mode binds to[::]:8080(IPv6). On FreeBSD (wherenet.inet6.ip6.v6only=1by default), accessinghttp://127.0.0.1:8080via IPv4 is refused. The container includes an internalsocathelper (stalwart-v4proxy) enabled by default (ENABLE_V4PROXY=true) that bridges IPv4 port 8080 to IPv6[::1]:8080. You can disable the internal proxy by settingENABLE_V4PROXY=false, or enable IPv4-mapping host-wide on FreeBSD by settingsysctl net.inet6.ip6.v6only=0. - External IPv4 Reverse Proxy / PROXY Protocol: If placing Stalwart behind an external IPv4 reverse proxy (Nginx, Traefik, HAProxy) or using PROXY protocol headers, add your proxy's IPv4 address/subnet (e.g.
127.0.0.1/32) toproxyTrustedNetworksunder Settings → Network → Services in Stalwart to prevent header parsing failures and proxy auto-bans.
By default config.toml stores everything (data, full-text search, blobs,
and lookups) in RocksDB under /config/data. To swap in an external
PostgreSQL database instead, replace the [store."rocksdb"] section and
point [storage] at the new store:
[storage]
data = "postgresql"
fts = "postgresql"
blob = "postgresql"
lookup = "postgresql"
directory = "internal"
[store."postgresql"]
type = "postgresql"
host = "postgres-host"
port = 5432
database = "stalwart"
user = "stalwart"
password = "changeme"
pool.max-connections = 10
[directory."internal"]
type = "internal"
store = "postgresql"For SQLite (a single-file database, good for small single-node setups):
[storage]
data = "sqlite"
fts = "sqlite"
blob = "sqlite"
lookup = "sqlite"
directory = "internal"
[store."sqlite"]
type = "sqlite"
path = "/config/stalwart.db"
pool.max-connections = 10
[directory."internal"]
type = "internal"
store = "sqlite"Edit /config/config.toml after the first run seeds it, then restart the
container. Blob storage can also be split onto a separate store (e.g. keep
blob = "rocksdb" while data/fts/lookup move to PostgreSQL) — see
stalw.art/docs/storage for the full
per-store breakdown.
Architectures: amd64
User: stalwart (UID/GID via PUID/PGID, defaults to 1000:1000)
Base: FreeBSD 15.1
Need help? Join our Discord community.