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?

ComponentValue
PlatformDokploy (self-hosted PaaS)
Imagestalwartlabs/stalwart:v0.16
Webmailghcr.io/bulwarkmail/webmail
Cert syncghcr.io/kereis/traefik-certs-dumper
Edge proxyTraefik (managed by Dokploy)
ProtocolsSMTP, IMAP, JMAP, POP3, ManageSieve, CalDAV, CardDAV
Data storeRocksDB (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, 8080 and 4190 will 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:

TypeNameValue
Amail.example.comyour VPS IPv4
MXexample.com10 mail.example.com.
PTRreverse zonemail.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:yaml

Notes:

  • Stalwart runs as UID/GID 2000, which is why OVERRIDE_UID and OVERRIDE_GID in cert-sync are set to 2000, the dumped files must be readable by the mail process.
  • The cert-sync container mounts Traefik's acme.json and 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-network and get whatever address Docker assigns. This works because every reference to Stalwart from Traefik or cert-sync uses the Docker DNS name stalwart-mail, and the mail listeners in Stalwart trust the whole 10.0.1.0/24 subnet for Proxy Protocol (see the next section). Pinning an IP in the compose is an unnecessary hard dependency.
  • The healthcheck is the standard curl + nc combination. 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 to 127.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_ADMIN and STALWART_API_TOKEN are read from the environment panel. Define them as Key = Value pairs there; compose variables are substituted automatically.

Environment variables

In the Dokploy environment panel define:

KeyValue
STALWART_RECOVERY_ADMINadmin:your-strong-password (bootstrap/recovery credential)
STALWART_API_TOKENan API token generated in Stalwart used by cert-sync to reload certificates
BULWARK_SESSION_SECRETa random string for the webmail session signing

Generate the random values on the host:

openssl rand -hex 32bash

BULWARK_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):

  1. Go to Management › API Keys (or Access Control › API Keys, depending on the version).
  2. Click Create and generate a key (or paste one you generated).
  3. Enable the permission actionReloadTlsCertificates (that is the method the reload hook invokes; without it the call returns a permission error and certificates never refresh).
  4. Copy the key value and set it as STALWART_API_TOKEN in the Dokploy environment, then redeploy cert-sync.

Ordering note: the token is only consumed by cert-sync, so you can create it after deploying — but cert-sync will 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 add STALWART_API_TOKEN to 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: 2yaml

Notes:

  • 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-proxy serversTransport, 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 with Host() 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.ymlbash

Add 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: :8484yaml

Save the file and restart the Traefik container so the new entrypoints bind their ports:

sudo docker restart dokploy-traefikbash

Publish 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 PortContainer PortProtocol
2525TCP
110110TCP
143143TCP
465465TCP
587587TCP
993993TCP
995995TCP
41904190TCP
84848484TCP

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.ymlbash

Create 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: trueyaml

Notes:

  • accessControlAllowOriginList must contain exactly the webmail origin (https://webmail.example.com). Traefik then emits the correct Access-Control-Allow-Origin header and, because addVaryHeader: true, also sends Vary: Origin so 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 Authorization and X-JMAP-Request-Id headers).
  • The file provider watches middlewares.yml too, so no Traefik restart is needed after saving it.
  • This middleware is what the stalwart-web HTTP 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 || truebash

Make it executable, otherwise the post-hook silently does nothing:

sudo chmod +x /etc/dokploy/traefik/reload-stalwart.shbash

Notes:

  • The image running cert-sync is minimal, so the hook uses wget (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.sh and reads STALWART_API_TOKEN from the environment passed through the compose project.
  • STALWART_API_TOKEN must be the Stalwart API key you created (with the actionReloadTlsCertificates permission) — 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:

  1. SPF/DMARC sender authentication runs against the proxy's IP, which makes the results meaningless.
  2. 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 show security.ip-blocked events 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 › General also 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 to 127.0.0.1 without a PROXY header; if that address were trusted, the health probes would fail.
  • HTTP listener stays on X-Forwarded-For. In Settings › Network › HTTP › General, enable useXForwarded = true. Traefik already sends X-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/admintext

Accept 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:

  1. Server identity: set mail.example.com as the hostname and example.com as 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.
  2. Storage: keep the defaults (RocksDB) for a single-node VPS.
  3. Account directory: keep Internal Directory so accounts are managed through the WebUI.
  4. Logging: choose Console so Docker's log driver captures the logs. That is also what the STALWART_LOG_TARGETS variable in the compose file does; pick one approach and keep them consistent.
  5. 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.combash

The 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:

ProtocolHostPortEncryption
IMAPmail.example.com993SSL/TLS
SMTP (submission)mail.example.com587STARTTLS
JMAPhttps://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-databash

Or 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 startbash

Store 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:

  1. 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

  1. Choose the schedule (e.g. daily at 0 3 * * *).
  2. 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.

  1. 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 tar snapshots, 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_ADMIN from 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 -dbash

Back up before upgrading and check the release notes for any breaking changes or required database migrations.

References

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.