First of all, what is Stalwart?
Stalwart is an open-source, all-in-one mail and collaboration server written in Rust. It speaks IMAP4, JMAP, POP3, SMTP, CalDAV, CardDAV and WebDAV, and ships with built-in spam and phishing filtering, full-text search, Sieve scripting, DKIM/SPF/DMARC/ARC support and a web-based administration console.
Running your own mail server used to mean stitching together Postfix (MTA), Dovecot (IMAP/POP3), Rspamd (spam), OpenDKIM, Let's Encrypt scripts and a webmail on top. Stalwart replaces that whole stack with a single binary that handles every protocol and every authentication concern in one place. It is secure, memory-safe (thanks to Rust) and scales from a small VPS to a cluster.
This guide covers the three things you need to go live on Dokploy: a compose project that runs Stalwart, a webmail (Bulwark) so your users can read and send mail from the browser, and automatic TLS certificate syncing so IMAP/SMTP/Submission keep a valid certificate without manual work. Migrating existing mailboxes from another server will be covered in a separate article.
This was supposed to be simple... ¡Hope this post helps someone!
What are we using in this guide?
| Component | Value |
|---|---|
| Platform | Dokploy (self-hosted PaaS) |
| Image | stalwartlabs/stalwart:v0.16 |
| Webmail | ghcr.io/bulwarkmail/webmail |
| Cert sync | ghcr.io/kereis/traefik-certs-dumper |
| Edge proxy | Traefik (managed by Dokploy) |
| Protocols | SMTP, IMAP, JMAP, POP3, ManageSieve, CalDAV, CardDAV |
| Data store | RocksDB (default, local) |
The Stalwart image bundles the server binary with a minimal Alpine runtime. It runs as the unprivileged user stalwart (UID 2000) and is published to both Docker Hub and the GitHub Container Registry. Pin a v<major>.<minor> tag like v0.16 for production instead of chasing latest.
Considerations
- A VPS with at least 2 GB of RAM and a public, static IPv4 address. Mail delivery relies heavily on IP reputation, so a provider that lets you configure reverse DNS (PTR) is strongly recommended.
- A working Dokploy installation. The official documentation covers the install script and the first login; this guide assumes you already have a project and an environment ready.
- A domain you control (e.g.
example.com) with DNS managed by your provider or an API-enabled provider (Cloudflare, Route 53, Google Cloud DNS, ...) if you want automatic DNS management. - Full control of the firewall: ports
25,587,465,143,993,110,995,443,8080and4190will be exposed through Traefik. - You are aware that email deliverability depends on your IP reputation, check the IP against DNS blocklists before going live and keep the SPF/DKIM/DMARC records correct.
How the pieces fit together
Dokploy ships with a Traefik instance that terminates HTTPS for every service. That works great for HTTP, but mail is different: SMTP, IMAP, POP3 and Submission need raw TCP passthrough with SNI-based routing, and they do not fit the "Host + path" model Dokploy uses to generate routers.
For HTTP services (webmail, the JMAP endpoint) you can use Dokploy's domain management as usual. For the mail ports you need three things: the mail entrypoints declared in Traefik's static config (so Traefik listens on ports 25/110/143/465/587/993/995/4190), an Additional Port Mapping in Dokploy so the host forwards those ports to the Traefik container, and a small file provider that Traefik watches, declaring one TCP router per port, each one forwarding to the Stalwart container over the Dokploy network. This is the part most Dokploy-based guides skip, and it is exactly what makes mail work.
The final stack looks like this:
- Stalwart (
stalwart-mail): the mail server itself. - Bulwark (
bulwark): the webmail, connected to Stalwart over JMAP. - cert-sync: watches Traefik's
acme.json, dumps the current certificate for your mail host, and tells Stalwart to reload it whenever it is renewed.
Step 1: Prepare DNS before deploying
DNS is the part that breaks most self-hosted mail deployments. Configure these records before you cut over:
| Type | Name | Value |
|---|---|---|
| A | mail.example.com | your VPS IPv4 |
| MX | example.com | 10 mail.example.com. |
| PTR | reverse zone | mail.example.com (set at your hosting provider) |
The PTR record is especially important, many receiving servers reject mail from IPs without a proper reverse DNS entry. Do not skip it.
You will add SPF, DKIM and DMARC after the setup wizard, because Stalwart generates the DKIM keys for you.
Step 2: Create the compose project in Dokploy
In Dokploy, create a new Compose service inside a project and an environment. Give it a name, keep the source as Raw or paste a docker-compose.yml, and set Create .env File to on, you will define secrets through the environment panel.
The core of the project is the compose file. It defines three services: Stalwart, the Bulwark webmail, and the certificate sync helper.
services:
stalwart-mail:
image: stalwartlabs/stalwart:v0.16
restart: unless-stopped
volumes:
- stalwart-etc:/etc/stalwart
- stalwart-data:/var/lib/stalwart
- /etc/dokploy/traefik/certs/mail:/etc/stalwart/certs:ro
environment:
STALWART_RECOVERY_ADMIN: ${STALWART_RECOVERY_ADMIN}
STALWART_LOG_TARGETS: console:stdout=info
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/healthz/live >/dev/null 2>&1 && nc -z 127.0.0.1 25 && nc -z 127.0.0.1 587 && nc -z 127.0.0.1 993 || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
bulwark:
image: ghcr.io/bulwarkmail/webmail:latest
restart: unless-stopped
environment:
SESSION_SECRET: ${BULWARK_SESSION_SECRET}
cert-sync:
image: ghcr.io/kereis/traefik-certs-dumper:latest
restart: unless-stopped
environment:
DOMAIN: mail.example.com
CERTIFICATE_FILE_EXT: .crt
PRIVATE_KEY_FILE_EXT: .key
OVERRIDE_UID: "2000"
OVERRIDE_GID: "2000"
POST_HOOK_FILE_PATH: /hook/hook.sh
STALWART_API_TOKEN: ${STALWART_API_TOKEN}
volumes:
- /etc/dokploy/traefik/acme.json:/acme.json:ro
- /etc/dokploy/traefik/certs:/output:rw
- /etc/dokploy/traefik/reload-stalwart.sh:/hook/hook.sh:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
default:
name: dokploy-network
external: true
volumes:
stalwart-etc:
stalwart-data:yamlNotes:
- Stalwart runs as UID/GID 2000, which is why
OVERRIDE_UIDandOVERRIDE_GIDincert-syncare set to2000, the dumped files must be readable by the mail process. - The
cert-synccontainer mounts Traefik'sacme.jsonand writes the extracted certificate to/etc/dokploy/traefik/certs/mail/, which Stalwart mounts read-only at/etc/stalwart/certs. - No static IPs needed. The services join the shared
dokploy-networkand get whatever address Docker assigns. This works because every reference to Stalwart from Traefik orcert-syncuses the Docker DNS namestalwart-mail, and the mail listeners in Stalwart trust the whole10.0.1.0/24subnet for Proxy Protocol (see the next section). Pinning an IP in the compose is an unnecessary hard dependency. - The healthcheck is the standard
curl+nccombination. It verifies both the HTTP API and that the SMTP/Submission/IMAPS listeners are actually bound, so a container that lost its listeners is marked unhealthy instead of pretending everything is fine. It connects to127.0.0.1, which is deliberately not in Stalwart's trusted proxy networks, that way the health probes are processed as direct connections. STALWART_RECOVERY_ADMINandSTALWART_API_TOKENare read from the environment panel. Define them asKey = Valuepairs there; compose variables are substituted automatically.
Environment variables
In the Dokploy environment panel define:
| Key | Value |
|---|---|
STALWART_RECOVERY_ADMIN | admin:your-strong-password (bootstrap/recovery credential) |
STALWART_API_TOKEN | an API token generated in Stalwart used by cert-sync to reload certificates |
BULWARK_SESSION_SECRET | a random string for the webmail session signing |
Generate the random values on the host:
openssl rand -hex 32bashBULWARK_SESSION_SECRET can be any random string from the command above. STALWART_RECOVERY_ADMIN is your bootstrap admin. The STALWART_API_TOKEN is not a random string: it must be an API key created inside Stalwart that grants permission to reload the TLS certificates, because cert-sync uses it to call the Stalwart API over JMAP.
Create it in the Stalwart WebUI after the first sign-in (once the WebUI is reachable, see Step 6):
- Go to Management › API Keys (or Access Control › API Keys, depending on the version).
- Click Create and generate a key (or paste one you generated).
- Enable the permission
actionReloadTlsCertificates(that is the method the reload hook invokes; without it the call returns a permission error and certificates never refresh). - Copy the key value and set it as
STALWART_API_TOKENin the Dokploy environment, then redeploycert-sync.
Ordering note: the token is only consumed by
cert-sync, so you can create it after deploying — butcert-syncwill log a failed reload until the token exists and matches. If you prefer to deploy everything in one shot, run the wizard, create the API key, then addSTALWART_API_TOKENto the environment and redeploy the compose.
Step 3: Add the TCP routers (file provider)
Dokploy's Traefik reads extra configuration from /etc/dokploy/traefik/dynamic/ on the host. Any YAML file dropped there is watched and applied automatically. Create a stalwart.yml that declares one TCP router per mail port, all using a HostSNI(*) rule so routing works regardless of the SNI the client sends:
tcp:
routers:
stalwart-smtp:
entryPoints:
- smtp
rule: "HostSNI(`*`)"
service: stalwart-smtp
stalwart-smtps:
entryPoints:
- smtps
rule: "HostSNI(`*`)"
service: stalwart-smtps
stalwart-submission:
entryPoints:
- submission
rule: "HostSNI(`*`)"
service: stalwart-submission
stalwart-imap:
entryPoints:
- imap
rule: "HostSNI(`*`)"
service: stalwart-imap
stalwart-imaps:
entryPoints:
- imaps
rule: "HostSNI(`*`)"
service: stalwart-imaps
stalwart-pop3:
entryPoints:
- pop3
rule: "HostSNI(`*`)"
service: stalwart-pop3
stalwart-pop3s:
entryPoints:
- pop3s
rule: "HostSNI(`*`)"
service: stalwart-pop3s
stalwart-sieve:
entryPoints:
- sieve
rule: "HostSNI(`*`)"
service: stalwart-sieve
services:
stalwart-smtp:
loadBalancer:
serversTransport: stalwart-proxy
servers:
- address: "stalwart-mail:25"
stalwart-smtps:
loadBalancer:
serversTransport: stalwart-proxy
servers:
- address: "stalwart-mail:465"
stalwart-submission:
loadBalancer:
serversTransport: stalwart-proxy
servers:
- address: "stalwart-mail:587"
stalwart-imap:
loadBalancer:
serversTransport: stalwart-proxy
servers:
- address: "stalwart-mail:143"
stalwart-imaps:
loadBalancer:
serversTransport: stalwart-proxy
servers:
- address: "stalwart-mail:993"
stalwart-pop3:
loadBalancer:
serversTransport: stalwart-proxy
servers:
- address: "stalwart-mail:110"
stalwart-pop3s:
loadBalancer:
serversTransport: stalwart-proxy
servers:
- address: "stalwart-mail:995"
stalwart-sieve:
loadBalancer:
serversTransport: stalwart-proxy
servers:
- address: "stalwart-mail:4190"
serversTransports:
stalwart-proxy:
proxyProtocol:
version: 2yamlNotes:
- Every service targets the Docker DNS name
stalwart-mail:<port>. Traefik resolves it on the Dokploy network, so the backend address survives redeploys even though the container IP changes. - Each service uses the
stalwart-proxyserversTransport, which makes Traefik prepend a Proxy Protocol v2 header to every connection. This is what lets Stalwart see the real client IP instead of Traefik's address, see the dedicated section below. - Why
HostSNI(*)instead of a Dokploy domain? Dokploy generates HTTP routers withHost()rules, which cannot route raw TCP for IMAP/SMTP/POP3. A file provider with TCP routers is the supported way to handle mail ports. - The file provider is watched, so editing the file reloads it without touching the Traefik container.
Register the mail and JMAP entrypoints in the static config
The routers above reference entrypoints (smtp, smtps, submission, imap, imaps, pop3, pop3s, sieve, and stalwart-web for the JMAP endpoint) that do not exist by default in Dokploy's Traefik static configuration — the stock traefik.yml only defines the HTTP entrypoints web and websecure. You must add them yourself so Traefik listens on those ports.
Edit the static config on the host at /etc/dokploy/traefik/traefik.yml (mounted into the container at /etc/traefik/traefik.yml):
sudo nano /etc/dokploy/traefik/traefik.ymlbashAdd the mail entrypoints plus stalwart-web under the existing entryPoints: section:
entryPoints:
smtp:
address: :25
smtps:
address: :465
submission:
address: :587
imap:
address: :143
imaps:
address: :993
pop3:
address: :110
pop3s:
address: :995
sieve:
address: :4190
stalwart-web:
address: :8484yamlSave the file and restart the Traefik container so the new entrypoints bind their ports:
sudo docker restart dokploy-traefikbashPublish the ports on the host (Additional Port Mapping)
Declaring the entrypoints in the static config makes Traefik bind those ports inside its container, but Dokploy does not automatically forward them from the host. You must add an Additional Port Mapping for every mail port so the host routes incoming traffic to the Traefik container.
In Dokploy, go to Settings › Web Server › Traefik › Additional Port Mapping and add one entry per port, mapping the host port to the same Traefik container port:
| Host Port | Container Port | Protocol |
|---|---|---|
25 | 25 | TCP |
110 | 110 | TCP |
143 | 143 | TCP |
465 | 465 | TCP |
587 | 587 | TCP |
993 | 993 | TCP |
995 | 995 | TCP |
4190 | 4190 | TCP |
8484 | 8484 | TCP |
If your hosting provider or firewall blocks low ports (especially 25 and 465), you can map a high host port instead and point the DNS records to it — but for mail to be reachable by other servers, the standard ports (25, 587, 993, ...) must be the ones exposed. Save and apply the mapping, then Dokploy recreates/restarts Traefik with the published ports.
Then create the dynamic routers file:
sudo mkdir -p /etc/dokploy/traefik/dynamic
sudo nano /etc/dokploy/traefik/dynamic/stalwart.ymlbashCreate the CORS middleware for the webmail
The Bulwark webmail runs on webmail.example.com and calls the JMAP API on mail.example.com:8484 from the browser. That is a cross-origin request, and Stalwart's HTTP listener only serves the API, it does not add the Access-Control-Allow-Origin headers for a different origin. You must create a CORS middleware in the dynamic directory and attach it to the JMAP domain.
Create /etc/dokploy/traefik/dynamic/middlewares.yml:
http:
middlewares:
stalwart-cors:
headers:
accessControlAllowOriginList:
- https://webmail.example.com
accessControlAllowMethods:
- GET
- POST
- OPTIONS
- PUT
- PATCH
- DELETE
accessControlAllowHeaders:
- Authorization
- Content-Type
- X-JMAP-Request-Id
addVaryHeader: trueyamlNotes:
accessControlAllowOriginListmust contain exactly the webmail origin (https://webmail.example.com). Traefik then emits the correctAccess-Control-Allow-Originheader and, becauseaddVaryHeader: true, also sendsVary: Originso browsers do not cache the wrong CORS response across origins.- The allowed methods and headers above cover the JMAP calls the webmail makes (including the
AuthorizationandX-JMAP-Request-Idheaders). - The file provider watches
middlewares.ymltoo, so no Traefik restart is needed after saving it. - This middleware is what the
stalwart-webHTTP router will reference in Step 6; without it the webmail's JMAP requests will be rejected by the browser with a CORS error even though the API itself works.
Step 4: Create the certificate reload hook
The cert-sync container dumps the current certificate whenever Traefik renews it, then runs a post-hook. The hook must tell Stalwart to reload its TLS material. The clean way is to call the Stalwart API over JMAP with the token from the environment:
Create /etc/dokploy/traefik/reload-stalwart.sh:
#!/bin/sh
set -eu
wget --quiet --post-data='{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["x:Action/set",{"type":"tls","action":"reload"},"reload"]]}' \
--header="Authorization: Bearer ${STALWART_API_TOKEN}" \
--header="Content-Type: application/json" \
-O - http://stalwart-mail:8080/api 2>/dev/null || truebashMake it executable, otherwise the post-hook silently does nothing:
sudo chmod +x /etc/dokploy/traefik/reload-stalwart.shbashNotes:
- The image running
cert-syncis minimal, so the hook useswget(present in the image) and deliberately fails open (|| true): a reload failure should not abort the dump or restart anything. - The hook is mounted read-only into the container at
/hook/hook.shand readsSTALWART_API_TOKENfrom the environment passed through the compose project. STALWART_API_TOKENmust be the Stalwart API key you created (with theactionReloadTlsCertificatespermission) — see the environment variables section in Step 2. A random string that is not a valid Stalwart API key makes the reload return an authentication error, which the hook silently swallows.- Because the hook triggers a reload through the API instead of restarting the container, no mail session is interrupted and Traefik keeps its established backend connections. Restarting the container after every renewal would drop active IMAP/SMTP sessions for no reason.
Step 5: PROXY protocol and the auto-ban trap
This is the most important, and least documented, part of running Stalwart behind a reverse proxy, and skipping it will cause a full mail outage.
Without Proxy Protocol, every connection from a mail client arrives at Stalwart from Traefik's IP (10.0.1.x), not from the client's real address. That breaks two things:
- SPF/DMARC sender authentication runs against the proxy's IP, which makes the results meaningless.
- Auto-banning / fail2ban counts every failed attempt against Traefik's address. Internet scanners constantly probe open mail ports (993, 465, 995, 25...), fail the TLS handshake or an auth, and Stalwart attributes each failure to the proxy IP. Once the threshold is reached, Stalwart blocks Traefik itself, and every port, IMAP, SMTP, and even the JMAP HTTP listener, starts returning
connection reset by peer/502 Bad Gateway. That is exactly the "random" outage you will hit within a day of going live, and the logs will showsecurity.ip-blockedevents for the proxy IP.
The fix is to preserve the real client IP end-to-end with Proxy Protocol v2 on the TCP mail listeners (and X-Forwarded-For on the HTTP listener):
Traefik side, done in the file provider above: every TCP service references the stalwart-proxy serversTransport, so Traefik prepends a PROXY header to each backend connection.
Stalwart side, configure Stalwart to trust the proxy. In the WebUI, Settings › Network › Listeners, and for each mail listener (SMTP, SMTPS/Submission, IMAP, IMAPS, POP3, POP3S, Sieve) set overrideProxyTrustedNetworks to the Dokploy network subnet:
overrideProxyTrustedNetworks = ["10.0.1.0/24"]Rules that matter:
- Set it per-listener, not globally. The global setting in
Settings › Network › Generalalso applies to the HTTP listener, which receives plain HTTP (not a PROXY header) from Traefik, Stalwart would try to parse the HTTP request line as a PROXY header and reset the connection, breaking the webmail and JMAP. - Do not include
127.0.0.1. The healthcheck connects directly to127.0.0.1without a PROXY header; if that address were trusted, the health probes would fail. - HTTP listener stays on
X-Forwarded-For. InSettings › Network › HTTP › General, enableuseXForwarded = true. Traefik already sendsX-Forwarded-For/X-Real-IP, and Stalwart then sees the real client on JMAP too. - Use the subnet (
10.0.1.0/24), not a single proxy IP. Traefik's container IP changes on every restart/redeploy; a hard-coded IP will break again the next time Dokploy recreates it.
How to recognise a misconfiguration: if a mail client fails to connect and Stalwart's logs show TLS handshake error ... reason = "received corrupt message of type InvalidContentType" with remoteIp equal to the proxy's address, Stalwart is receiving the PROXY header but not consuming it, the trusted networks are wrong. If you see security.ip-blocked for the proxy IP, the auto-ban already fired.
Step 6: Publish the WebUI / JMAP endpoint and run the setup wizard
Deploy the project from Dokploy. The Stalwart admin UI (WebUI) and the JMAP API are served by the same HTTP listener on port 8080. You cannot publish that port directly on the host — port 8080 is Dokploy's own API port — so the only way in is through the stalwart-web entrypoint you added in Step 3.
In Dokploy, add a single domain to the stalwart-mail service that serves both the admin UI and JMAP:
- Host:
mail.example.com - Port:
8080 - Entrypoint:
stalwart-web - Middlewares:
stalwart-cors@file - HTTPS: on (Let's Encrypt)
The stalwart-cors middleware (created in Step 3) is what lets the Bulwark webmail call the JMAP API cross-origin from the browser. It must be attached here, otherwise the webmail's requests will be rejected with a CORS error.
This one domain is the whole story for Stalwart's HTTP surface: the admin UI lives at https://mail.example.com:8484/admin and the JMAP API at https://mail.example.com:8484. Do not create a separate plain-HTTP domain for bootstrap — the WebUI and JMAP share the same route, and you cannot bypass Traefik to reach 8080.
If your DNS does not resolve yet, edit /etc/hosts on the machine you browse from (or use an IP-based host entry) pointing mail.example.com to the server, and open:
https://mail.example.com:8484/admintextAccept the temporary certificate warning if Let's Encrypt has not finished issuing yet. Sign in with admin and the password from STALWART_RECOVERY_ADMIN. The wizard has five screens:
- Server identity: set
mail.example.comas the hostname andexample.comas the default domain. Leave both toggles enabled: Stalwart will request a Let's Encrypt TLS certificate automatically (ACME) and generate DKIM signing keys for the domain. - Storage: keep the defaults (RocksDB) for a single-node VPS.
- Account directory: keep Internal Directory so accounts are managed through the WebUI.
- Logging: choose Console so Docker's log driver captures the logs. That is also what the
STALWART_LOG_TARGETSvariable in the compose file does; pick one approach and keep them consistent. - DNS management: choose Manual DNS Server Management for now, or select a provider if you want Stalwart to publish the records automatically.
The final screen prints the administrator email and a randomly generated password. Write both down, this is the only time the password is shown.
Step 7: Publish the DNS records
In the admin UI go to Management › Domains, open your domain's menu and select View DNS Zone file. Stalwart gives you the complete zone file for the domain, everything from MX, SPF, DKIM and DMARC to MTA-STS and autoconfig/autodiscover.
Add those records to your DNS provider. If you configured automatic DNS management in the wizard, Stalwart publishes them itself through the provider API and keeps them in sync.
After the records propagate (give it 5–30 minutes), verify the authentication chain:
# SPF
dig +short TXT example.com
# DKIM
dig +short TXT default._domainkey.example.com
# DMARC
dig +short TXT _dmarc.example.combashThe DKIM public key in DNS must match the one Stalwart generated for the domain. If you publish the records by hand, copy the value from the zone file exactly, a single character difference breaks signature validation and your mail will be marked as spam.
Step 8: Add the Bulwark webmail domain
In Dokploy, add a domain to the bulwark service:
- Host:
webmail.example.com - Port:
3000 - HTTPS: on (Let's Encrypt)
Open https://webmail.example.com and sign in with a Stalwart account. If the webmail does not find the JMAP endpoint automatically, point it to https://mail.example.com:8484. CORS is already handled by the stalwart-cors middleware you attached to the JMAP domain in Step 6; if you still see CORS errors in the browser, double-check that the middleware's accessControlAllowOriginList matches the exact webmail origin.
Step 9: Create accounts
Add users in Management › Accounts. Each account needs:
- The email address (e.g.
alice@example.com). - A password (or let Stalwart generate one).
- A mailbox quota if you want per-user limits.
Mail clients connect using:
| Protocol | Host | Port | Encryption |
|---|---|---|---|
| IMAP | mail.example.com | 993 | SSL/TLS |
| SMTP (submission) | mail.example.com | 587 | STARTTLS |
| JMAP | https://mail.example.com:8484 | - | HTTPS |
Step 10: Backups
Back up the named volumes. The safest approach is a scheduled job on the host that snapshots the data directory:
sudo tar czf /backup/stalwart-data-$(date +%F).tar.gz \
-C /var/lib/docker/volumes stalwart-databashOr stop the containers briefly and copy the volume:
sudo docker compose -f /etc/dokploy/compose/<project>/code/docker-compose.yml stop
sudo docker run --rm -v stalwart-etc:/etc -v "$PWD":/backup \
alpine tar czf /backup/stalwart-etc-$(date +%F).tar.gz -C /etc .
sudo docker compose -f /etc/dokploy/compose/<project>/code/docker-compose.yml startbashStore the archive off-server and test a restore before you need it.
Scheduled backups to an S3 endpoint with Dokploy
Instead of shell scripts, you can let Dokploy snapshot the compose volumes on a schedule and upload them to any S3-compatible endpoint (AWS S3, Cloudflare R2, MinIO, Backblaze B2, DigitalOcean Spaces...).
In Dokploy, open the stalwart compose service and go to the Backups tab:
- Create a Backup and pick the volumes to include. For Stalwart you need at least:
- stalwart-data (/var/lib/stalwart — the mail store, accounts, keys and queues) - stalwart-etc (/etc/stalwart — the configuration) - bulwark_data and bulwark_state if you want the webmail settings preserved
- Choose the schedule (e.g. daily at
0 3 * * *). - Under Storage, configure the S3 destination:
- Endpoint: the S3 endpoint URL of your provider (e.g. https://s3.eu-west-1.amazonaws.com or https://<account>.r2.cloudflarestorage.com for R2). - Bucket: the name of the bucket, e.g. mikelmc-stalwart-backups. - Region: the bucket region (us-east-1 if in doubt, or your provider's region). - Access Key / Secret Key: credentials with write permission to that bucket.
- Save and test with a manual backup run (the Backups tab has a run button) before trusting the schedule.
Notes:
- The backups are stored as compressed volume archives, one per run, in the bucket. Older entries accumulate, so enable lifecycle/retention rules on the bucket (or a Dokploy retention policy if available) to prune them.
- S3 is the off-server copy: keep the local volume intact and treat the bucket as the disaster-recovery copy. Verify a restore in a scratch environment before you need it.
- If you are already doing host-level
tarsnapshots, you do not need both; pick one strategy and stick to it (Dokploy S3 backups are the more automated option and give you off-site storage out of the box).
Troubleshooting
Mail clients get "connection reset" / Bad Gateway on every port
This is almost always Stalwart's auto-ban having blocked the proxy IP. Check the Stalwart logs for security.ip-blocked lines pointing at a 10.0.1.x address and follow the PROXY protocol section above: once Traefik sends PROXY headers and Stalwart trusts the Dokploy subnet, Stalwart sees real client IPs and the ban stops hitting the proxy. After fixing the config, remove the proxy IP from the blocked list in Settings › Security › Blocked IPs (or restart the container to clear the in-memory cache).
IMAP/Submission fail with "InvalidContentType" in the logs
Stalwart is receiving the PROXY header but not parsing it: overrideProxyTrustedNetworks is missing or set to the wrong address on the mail listeners. Verify the value is the Dokploy subnet (10.0.1.0/24) and that it is set per-listener (not globally, which would also break the HTTP listener). The remoteIp in the error will be Traefik's address instead of the client's real IP.
Ports work locally but not from the internet
Check the firewall. The mail ports must be open on the host for Traefik to receive them: 25, 110, 143, 465, 587, 993, 995, 4190.
Outbound mail lands in spam
- Verify SPF, DKIM and DMARC all validate with an online tester (e.g.
mail-tester.com). - Make sure the PTR (rDNS) matches the HELO hostname
mail.example.com. - Check the IP is not on any DNS blocklist.
Certificates do not update after renewal
Check the cert-sync logs and confirm the hook is executable and mounted at /hook/hook.sh. Confirm STALWART_API_TOKEN is set in the environment and that the Stalwart API is reachable from the cert-sync container. If everything looks right, trigger a manual dump by touching acme.json and watch the log.
The JMAP endpoint responds but the webmail shows CORS errors
Confirm the stalwart-cors middleware is attached to the JMAP domain in Dokploy and that its accessControlAllowOriginList contains exactly https://webmail.example.com (no trailing slash, no http). Check the browser's devtools: the response should include Access-Control-Allow-Origin: https://webmail.example.com and a Vary: Origin header. If the middleware is missing, create it as shown in Step 3 and attach it to the domain.
Security notes
- Never expose the admin console without HTTPS.
- Remove
STALWART_RECOVERY_ADMINfrom the environment once the permanent administrator works, it is a backdoor credential. - Limit who can reach the admin console. Because the WebUI is served through Traefik and shares the listener with JMAP, it is always reachable at
https://mail.example.com:8484/admin— do not try to "disable the HTTP listener on 8080", that would take the WebUI and JMAP offline together. Instead, restrict access to the admin console: serve it only over HTTPS (already the case), and gate it by IP allowlist (Traefik middleware or firewall) or by a VPN tunnel so only your administrators can reach it. - The Stalwart image runs as UID 2000 with minimal capabilities; keep it that way and don't add
--privileged. - Stalwart ships built-in spam/phishing filtering, greylisting, rate limiting and auto-banning. Review Settings › Spam & Phishing after setup and consider enabling greylisting.
- Keep the named volumes intact; they hold all configuration, accounts, keys and mail.
Upgrading
Because data lives in named volumes, upgrading Stalwart is mostly changing the image tag and redeploying the project in Dokploy:
sudo docker compose -f /etc/dokploy/compose/<project>/code/docker-compose.yml pull
sudo docker compose -f /etc/dokploy/compose/<project>/code/docker-compose.yml up -dbashBack up before upgrading and check the release notes for any breaking changes or required database migrations.
References
- Dokploy documentation
- Stalwart official documentation
- Stalwart Docker deployment guide
- Stalwart DNS records
- Stalwart Proxy Protocol
- Stalwart behind Traefik
- Stalwart auto-banning
- Bulwark webmail
- traefik-certs-dumper
- Stalwart source code
- Docker Compose documentation
AI-generated image disclosure
The cover image (/images/posts/stalwart-dokploy-header.webp) was generated with the assistance of an AI model (deepseek-v4-flash). In accordance with Regulation (EU) 2024/1689 (EU Artificial Intelligence Act, Article 50), this content is disclosed as AI-generated. It is an original illustration created for this article without third-party images, logos or external assets; product names (Stalwart, Dokploy, Traefik) appear descriptively and no affiliation or endorsement is implied.
