I self-hosted company email on a Hostinger VPS with Stalwart and Dokploy — every step
A complete, copy-pasteable guide to running your own mail server with Stalwart, Dokploy and Traefik on a Hostinger VPS. It scored 9.2/10 on mail-tester on day one.
On this page
- The short version
- What you end up with
- Before you start
- The architecture
- Step 1 — Base DNS records
- Step 2 — Reverse DNS (PTR)
- Step 3 — Pre-flight checks on the VPS
- Step 4 — Firewall
- Step 5 — The compose file
- Step 6 — Deploy with Dokploy
- Step 7 — The setup wizard, through an SSH tunnel
- Step 8 — Give the mail ports a real certificate
- Step 9 — Add the port 587 listener
- Step 10 — Harden it
- Step 11 — Publish the full DNS zone
- Step 12 — Accounts and aliases
- Step 13 — Test deliverability
- Step 14 — Backups
- Step 15 — Connect mail apps
- Optional — External search and in-memory stores
- The eight things that went wrong
- Maintenance
- FAQ
- Can you self-host email on a Hostinger VPS?
- Is port 25 blocked on Hostinger VPS?
- How much RAM does Stalwart need?
- Why not let Stalwart get its own TLS certificate?
- Do I need Roundcube?
- Is self-hosted email worth it?
- Credits
The short version
I moved a company's email onto a mail server I run myself. It's one Hostinger VPS that was already running Dokploy, with Stalwart doing SMTP, IMAP, JMAP, calendars and contacts, and Roundcube for webmail.
On the first day it scored 9.2/10 on mail-tester.com. SPF, both DKIM signatures and DMARC all pass, the IP is on 0 of 20 blocklists, and all 18 of 18 of my verification checks pass. The only deduction was for the domain being less than two weeks old, and that goes away as the domain ages.
This post is the whole runbook: every DNS record, the full compose file, and every command I ran. It also covers the eight things that went wrong along the way. If you follow it you should end up where I did, and spend less time getting there.
Self-hosting email is no longer about wrestling with Postfix configs. It's about getting DNS exactly right and being patient with it.
What you end up with
- Your own mail server at
mail.example.com: SMTP for sending and receiving, IMAP for mail apps, JMAP, CalDAV and CardDAV - Webmail at
webmail.example.com, using Roundcube - A trusted Let's Encrypt certificate on every port, web and mail, that renews automatically
- DKIM signing with both Ed25519 and RSA keys, plus SPF, DMARC, MTA-STS and TLS reporting
- Auto-discovery so Thunderbird, Apple Mail and Outlook configure themselves from just an email address
- Git-driven deploys: push to
masterand Dokploy redeploys - Nightly backups of every mailbox to S3-compatible storage, with a tested restore
Throughout this post, example.com stands for your domain and 203.0.113.10 for your VPS's IPv4 address. Replace both everywhere.
Before you start
Be honest with yourself about three things first.
Email is unforgiving. If a web app is misconfigured, you get an error page. If a mail server is misconfigured, messages vanish into spam folders and nobody tells you. Every check in this guide exists because something can fail silently.
You need a VPS where outbound port 25 is open. Many cloud providers block it by default. On my Hostinger VPS it was open, and I checked before doing anything else (step 3 below).
You need control of reverse DNS. Big mail providers check that your IP's reverse DNS (PTR) record points back to your mail hostname. Hostinger lets you set it yourself in hPanel, so there's no support ticket.
My setup:
- VPS: Hostinger, Ubuntu, about 8 GB RAM, with Dokploy already running other apps
- DNS: Hostinger's nameservers (no Cloudflare, which matters later)
- Mail server: Stalwart v0.16, a single Rust binary that does every mail protocol
- Webmail: Roundcube
- Deploys: Dokploy Compose service, pulling from a private GitHub repo
- Backups: Dokploy Volume Backups to Cloudflare R2
Stalwart gets a 1 GB memory limit and Roundcube 256 MB. Together they're a small slice of the box.
The architecture
Dokploy's Traefik already owns ports 80 and 443 for everything else on the server, so the whole design is about fitting in next to it, not fighting it.
Internet
│
┌───────────────────┼─────────────────────────────┐
│ VPS │ │
│ ▼ │
│ ┌──────── Dokploy Traefik ────────┐ │
│ │ :80 / :443 (HTTPS, web UIs) │ │
│ │ Let's Encrypt → acme.json │ │
│ └───────┬──────────────┬──────────┘ │
│ │ │ │
│ mail.example.com webmail.example.com │
│ ▼ ▼ │
│ ┌──────────────┐ ┌────────────┐ │
│ │ Stalwart │ │ Roundcube │ │
│ │ :8080 HTTP │ └────────────┘ │
│ │ │ │
│ │ :25 SMTP │◄── published directly │
│ │ :465 SMTPS │ to the host │
│ │ :587 Submit │ (bypasses Traefik) │
│ │ :993 IMAPS │ │
│ │ :4190 Sieve │ ┌──────────────┐ │
│ │ /data/certs ◄├───┤ certs-dumper │◄── acme.json
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────┘
The decisions that shaped it:
- Web traffic goes through Traefik. Stalwart's admin UI, JMAP, CalDAV/CardDAV, autoconfig and MTA-STS are all served by Stalwart over plain HTTP on port 8080. Traefik terminates HTTPS in front of it.
- Mail ports skip Traefik. Ports 25, 465, 587, 993 and 4190 are published straight from the container. Stalwart sees real client IPs, which its spam filter and auto-ban need, and there's no Proxy Protocol to configure.
- One certificate, borrowed from Traefik. Stalwart can get its own Let's Encrypt certificate, but here it can't. DNS-01 needs a DNS API it supports, and my DNS is at Hostinger. TLS-ALPN-01 needs port 443, which Traefik holds. So Traefik gets the certificate, and a small container called traefik-certs-dumper copies it into a volume Stalwart reads.
- RocksDB in named volumes. It's a single node, so Stalwart's default embedded store is enough. Dokploy can only back up named volumes, so no bind mounts.
- The setup wizard is never public. Port 8080 is bound to
127.0.0.1, and I reach the wizard through an SSH tunnel. - IPv4 only for mail. The VPS has IPv6, but I only set a PTR for the IPv4 address. Without a matching IPv6 PTR, Gmail rejects mail sent over IPv6, so there's no AAAA record for
mail.
Step 1 — Base DNS records
In Hostinger: hPanel → Domains → your domain → DNS / Nameservers → DNS records.
First, delete the default MX record Hostinger creates, which points at its own mail service. Then add these:
mail A 203.0.113.10
webmail A 203.0.113.10
@ MX 10 mail.example.com.
@ TXT "v=spf1 mx ~all"
mail TXT "v=spf1 a -all"
_dmarc TXT "v=DMARC1; p=none; rua=mailto:postmaster@example.com"
autoconfig CNAME mail.example.com.
autodiscover CNAME mail.example.com.
mta-sts CNAME mail.example.com.
What each one does:
mailA: the hostname every other record and every mail app points at- MX: tells the world "mail for example.com goes to mail.example.com"
- SPF on
@: only the servers in my MX may send as@example.com. I start with~all(soft fail) and tighten it later. - SPF on
mail: the mail server's own hostname is used in SMTP greetings and bounces, so it gets a strict SPF of its own - DMARC: starts at
p=none(monitor only) with daily reports topostmaster@ autoconfig,autodiscover,mta-sts: Stalwart serves these over HTTPS for client auto-setup and the MTA-STS policy
Add all of them before deploying. mail, autoconfig, autodiscover and mta-sts share one certificate, and if even one name doesn't resolve, Let's Encrypt refuses to issue the whole thing.
Check from your laptop after 10–30 minutes:
dig +short mail.example.com # 203.0.113.10
dig +short MX example.com # 10 mail.example.com.
dig +short TXT example.com # "v=spf1 mx ~all"
dig +short TXT _dmarc.example.com # the full DMARC string
dig +short autoconfig.example.com # mail.example.com. 203.0.113.10
Read the DMARC output carefully. Mine saved in hPanel as just v=DMARC1, with the p= tag silently cut off. A DMARC record without p= is invalid.
Step 2 — Reverse DNS (PTR)
hPanel → VPS → Settings → IP address → Reverse DNS. Set the IPv4 PTR to mail.example.com. By default it's something like srv123456.hstgr.cloud.
dig +short -x 203.0.113.10 # mail.example.com.
This is one of the most important records here. Forward DNS (mail.example.com → IP) and reverse DNS (IP → mail.example.com) have to agree. Gmail and Outlook check that before they look at anything else.
Step 3 — Pre-flight checks on the VPS
SSH in and confirm the server is ready:
ssh root@203.0.113.10
free -h # want ~1.5 GB+ available
ss -tlnp | grep -E ':(25|465|587|993|4190|8080)\b' # must print nothing
nc -vz -w 5 gmail-smtp-in.l.google.com 25 # must succeed
docker network ls | grep dokploy-network # must exist
ls -l /etc/dokploy/traefik/dynamic/acme.json # must exist
grep -A2 letsencrypt /etc/dokploy/traefik/traefik.yml # resolver is named "letsencrypt"
What to do if one of these fails:
- Something already listens on port 25: usually a stock Postfix or Exim. Run
systemctl disable --now postfix exim4. - The port 25 test times out: your provider blocks outbound SMTP. Either ask them to unblock it, or plan to send through a relay (see "Maintenance" below).
- Check your IP isn't already blocklisted from a previous tenant: mxtoolbox.com/blacklists.aspx
Step 4 — Firewall
Hostinger has a firewall in front of the VPS, in hPanel → VPS → Security → Firewall. If it's enabled, add accept rules for TCP 25, 465, 587, 993 and 4190, plus 22, 80 and 443 if you're starting from scratch.
This firewall matters because Docker-published ports bypass UFW. If you also use UFW:
ufw status
# only if "Status: active":
ufw allow 25/tcp && ufw allow 465/tcp && ufw allow 587/tcp && ufw allow 993/tcp && ufw allow 4190/tcp
A Hostinger quirk that cost me time: a port with nothing listening doesn't answer "connection refused". It just times out, exactly like a firewall drop. If you scan your ports before Stalwart is running, they look blocked even when the rules are right. Test the mail ports after the server is up (step 7), not before.
Step 5 — The compose file
Create a private GitHub repo with three files: docker-compose.yml, .env.example and a .gitignore containing .env. Real secrets never go in git. They go in Dokploy's Environment tab.
Here's the complete docker-compose.yml:
services:
# Stalwart: SMTP, IMAP, JMAP, CalDAV/CardDAV, spam filter, admin UI
stalwart:
image: stalwartlabs/stalwart:v0.16
container_name: stalwart
hostname: mail.${MAIL_DOMAIN}
restart: unless-stopped
environment:
# URL clients use (Traefik terminates HTTPS in front of :8080)
- STALWART_PUBLIC_URL=https://mail.${MAIL_DOMAIN}
# Bootstrap admin: uncomment for first setup only
# - STALWART_RECOVERY_ADMIN=${STALWART_RECOVERY_ADMIN}
ports:
- "25:25" # SMTP inbound (server-to-server)
- "465:465" # SMTPS submission
- "587:587" # Submission (STARTTLS)
- "993:993" # IMAPS
- "4190:4190" # ManageSieve
- "127.0.0.1:8080:8080" # Setup wizard: local only, use an SSH tunnel
volumes:
- stalwart-etc:/etc/stalwart
- stalwart-data:/var/lib/stalwart
- mail-certs:/data/certs:ro # Traefik's certificate, via certs-dumper
mem_limit: 1g
networks:
- dokploy-network
labels:
- traefik.enable=true
- traefik.docker.network=dokploy-network
# HTTPS router: admin UI, JMAP, DAV, autoconfig, MTA-STS
- "traefik.http.routers.stalwart.rule=Host(`mail.${MAIL_DOMAIN}`) || Host(`autoconfig.${MAIL_DOMAIN}`) || Host(`autodiscover.${MAIL_DOMAIN}`) || Host(`mta-sts.${MAIL_DOMAIN}`)"
- traefik.http.routers.stalwart.entrypoints=websecure
- traefik.http.routers.stalwart.tls.certresolver=letsencrypt
# One certificate, keyed by mail.<domain>, so certs-dumper can find it
- traefik.http.routers.stalwart.tls.domains[0].main=mail.${MAIL_DOMAIN}
- traefik.http.routers.stalwart.tls.domains[0].sans=autoconfig.${MAIL_DOMAIN},autodiscover.${MAIL_DOMAIN},mta-sts.${MAIL_DOMAIN}
- traefik.http.routers.stalwart.service=stalwart
- traefik.http.services.stalwart.loadbalancer.server.port=8080
# HTTP → HTTPS redirect
- "traefik.http.routers.stalwart-http.rule=Host(`mail.${MAIL_DOMAIN}`) || Host(`autoconfig.${MAIL_DOMAIN}`) || Host(`autodiscover.${MAIL_DOMAIN}`) || Host(`mta-sts.${MAIL_DOMAIN}`)"
- traefik.http.routers.stalwart-http.entrypoints=web
- traefik.http.routers.stalwart-http.middlewares=stalwart-https-redirect
- traefik.http.middlewares.stalwart-https-redirect.redirectscheme.scheme=https
# Roundcube webmail (optional)
roundcube:
image: roundcube/roundcubemail:latest-apache
container_name: roundcube
restart: unless-stopped
depends_on:
- stalwart
environment:
- ROUNDCUBEMAIL_DEFAULT_HOST=ssl://mail.${MAIL_DOMAIN}
- ROUNDCUBEMAIL_DEFAULT_PORT=993
- ROUNDCUBEMAIL_SMTP_SERVER=ssl://mail.${MAIL_DOMAIN}
- ROUNDCUBEMAIL_SMTP_PORT=465
- ROUNDCUBEMAIL_DB_TYPE=sqlite
- ROUNDCUBEMAIL_DES_KEY=${ROUNDCUBE_DES_KEY}
- ROUNDCUBEMAIL_SKIN=elastic
- ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=25M
- ROUNDCUBEMAIL_PLUGINS=archive,zipdownload,managesieve
volumes:
- roundcube-db:/var/roundcube/db
mem_limit: 256m
networks:
- dokploy-network
labels:
- traefik.enable=true
- traefik.docker.network=dokploy-network
- "traefik.http.routers.webmail.rule=Host(`webmail.${MAIL_DOMAIN}`)"
- traefik.http.routers.webmail.entrypoints=websecure
- traefik.http.routers.webmail.tls.certresolver=letsencrypt
- traefik.http.routers.webmail.middlewares=webmail-hsts
- traefik.http.middlewares.webmail-hsts.headers.stsSeconds=31536000
- traefik.http.services.webmail.loadbalancer.server.port=80
- "traefik.http.routers.webmail-http.rule=Host(`webmail.${MAIL_DOMAIN}`)"
- traefik.http.routers.webmail-http.entrypoints=web
- traefik.http.routers.webmail-http.middlewares=stalwart-https-redirect
# Copies Traefik's certificate out of acme.json for Stalwart's mail ports
certs-dumper:
image: ghcr.io/kereis/traefik-certs-dumper:latest
container_name: certs-dumper
restart: unless-stopped
environment:
- DOMAIN=mail.${MAIL_DOMAIN} # single domain → /output/cert.pem, key.pem
- OVERRIDE_UID=2000 # Stalwart runs as UID/GID 2000
- OVERRIDE_GID=2000
volumes:
- /etc/dokploy/traefik/dynamic:/traefik:ro
- mail-certs:/output
volumes:
stalwart-etc:
stalwart-data:
roundcube-db:
mail-certs:
networks:
dokploy-network:
external: true
Three details in this file each fixed a real problem:
tls.domains[0].mainis pinned tomail.<domain>. Without it, Traefik picks the certificate's "main" name itself, and certs-dumper can't reliably find it inacme.json.OVERRIDE_UID/GID=2000. certs-dumper writes files as root by default. Stalwart runs as UID 2000 and couldn't read the private key.127.0.0.1:8080:8080. Traefik reaches 8080 over the Docker network. The host binding is only for the SSH tunnel. Never change it to0.0.0.0.
Generate the secrets on your laptop and keep them in .env (git-ignored):
openssl rand -base64 24 # admin password, then again for the Roundcube DES key
MAIL_DOMAIN=example.com
STALWART_RECOVERY_ADMIN=admin:<generated password>
ROUNDCUBE_DES_KEY=<generated key>
Step 6 — Deploy with Dokploy
In Dokploy:
- Projects → Create Project, named
mail - Inside it, Create Service → Compose, also named
mail, with Compose type Docker Compose - General → Provider: GitHub: pick your repo, branch
master, compose path./docker-compose.yml, then Save. From now on, a push tomasterredeploys. - Environment tab: paste the three lines from
.envand Save - Leave Isolated Deployments off, because the compose file joins
dokploy-networkitself - Don't add anything in the Domains tab. The Traefik labels in the file already do the routing, and doing both creates conflicting routers.
For the very first deploy, uncomment the STALWART_RECOVERY_ADMIN line in the compose file, commit, push, and hit Deploy.
Then check on the VPS:
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E 'stalwart|roundcube|certs-dumper'
docker logs stalwart 2>&1 | tail -20
Give Traefik about a minute to get the certificate, then:
curl -sI https://mail.example.com/ | head -1 # any status, no TLS error
docker logs dokploy-traefik 2>&1 | grep -i acme | tail -5 # no errors for your domain
docker exec stalwart ls -ln /data/certs/ # cert.pem + key.pem, owner 2000 2000
If /data/certs/ is empty, the certificate hasn't been issued yet. Recheck step 1 (usually one of the four names doesn't resolve) and the Traefik log.
Don't panic if the mail ports don't answer yet. On first boot, Stalwart runs in bootstrap mode with only the 8080 setup listener. The mail listeners appear after the wizard.
Step 7 — The setup wizard, through an SSH tunnel
From your laptop:
ssh -L 8080:127.0.0.1:8080 root@203.0.113.10
Open http://localhost:8080/admin and sign in as admin with the bootstrap password. These are the choices I made:
- Server identity: hostname
mail.example.com, default domainexample.com - Automatically obtain TLS certificate: off, because Traefik handles it
- Generate DKIM signing keys: on
- Storage: defaults (RocksDB for everything)
- Account directory: Internal Directory
- Logging: Console, so logs show up in Dokploy and
docker logs - DNS management: Manual, since Stalwart has no Hostinger DNS integration
The last screen shows the permanent admin email and password once. Put it in a password manager now.
Then restart:
docker restart stalwart
From now on you sign in at https://mail.example.com/admin, the public URL, with the permanent admin account.
Step 8 — Give the mail ports a real certificate
Out of the box, Stalwart serves a self-signed certificate on 465 and 993, and mail apps will complain. Point it at the files certs-dumper writes:
- Admin UI → Settings › TLS › Certificates → Add, type File
- Certificate:
/data/certs/cert.pem - Private key:
/data/certs/key.pem - Make it the default certificate, so clients that don't send SNI get it too, then Save
docker restart stalwart
Verify from anywhere:
openssl s_client -connect mail.example.com:993 -servername mail.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
openssl s_client -connect mail.example.com:465 -servername mail.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -issuer
# the issuer must be Let's Encrypt, not self-signed
Renewals: Traefik renews about 30 days before expiry and certs-dumper rewrites the files, but Stalwart only reads certificate files at startup. A monthly restart fixes that. On the VPS (crontab -e):
0 4 1 * * docker restart stalwart
Step 9 — Add the port 587 listener
This surprised me. The wizard creates listeners on 25, 465, 993, 995, 4190, 443 and 8080, but not 587. Plenty of clients, and ERP systems like ERPNext, expect submission with STARTTLS on 587.
In the admin UI, under the network listener settings, add a listener:
- Protocol SMTP, bound to
[::]:587 - TLS not implicit, so the client upgrades with STARTTLS
Restart and check:
openssl s_client -starttls smtp -connect mail.example.com:587 </dev/null 2>/dev/null | head -3
Step 10 — Harden it
- Remove the bootstrap admin. Delete
STALWART_RECOVERY_ADMINfrom Dokploy's Environment tab, comment the line out again in the compose file, push, and deploy. Delete it from your local.envtoo. - Turn on 2FA for the admin account.
- HTTP settings (Settings › Network › HTTP): enable Obtain remote IP from Forwarded header, so auto-ban sees real IPs on web traffic. Enable HTTP Strict Transport Security. Keep Permissive CORS off.
- Restart Stalwart after enabling HSTS. It only applies after a restart, and I spent a while wondering why the header was missing.
- Keep the 8080 listener. Stalwart's docs suggest disabling it after setup, but here it's how Traefik reaches the web UI, and it's only reachable from the Docker network and localhost.
curl -sI https://mail.example.com/ | grep -i strict
curl -sI https://webmail.example.com/ | grep -i strict
Step 11 — Publish the full DNS zone
This is where Stalwart shines. Admin UI → Management › Domains → ⋮ next to your domain → View DNS Zone file gives you every record it wants. Publish everything from it that you haven't added yet:
; DKIM: two keys, copy the values exactly
v1-ed25519-20261001._domainkey TXT "v=DKIM1; k=ed25519; h=sha256; p=…"
v1-rsa-20261001._domainkey TXT "v=DKIM1; k=rsa; h=sha256; p=MIIBIjANBg…"
; Client auto-discovery
_imaps._tcp SRV 0 1 993 mail.example.com.
_submissions._tcp SRV 0 1 465 mail.example.com.
_submission._tcp SRV 0 1 587 mail.example.com.
_jmap._tcp SRV 0 1 443 mail.example.com.
_caldavs._tcp SRV 0 1 443 mail.example.com.
_carddavs._tcp SRV 0 1 443 mail.example.com.
; MTA-STS and TLS reporting
_mta-sts TXT "v=STSv1; id=<from Stalwart>"
_smtp._tls TXT "v=TLSRPTv1; rua=mailto:postmaster@example.com"
Notes:
- Paste long DKIM values as one string, without the quotes. Hostinger splits them into 255-character chunks automatically.
- Double-check which key went under which name. I pasted the Ed25519 and RSA values under each other's selectors, and every DKIM check would have failed. Use
dig +short TXT v1-rsa-20261001._domainkey.example.comand compare. _submission._tcpfor 587 isn't in Stalwart's list, since the wizard didn't create that listener. Add it yourself.
I deliberately did not publish some of what Stalwart suggests:
- TLSA (DANE) records. They're ignored without DNSSEC, which my zone doesn't have. The
3 1 1entries also pin the current key, and Traefik replaces the key on every renewal. _pop3s._tcp, because port 995 isn't publishedua-auto-config, because it isn't routed by Traefik or on the certificatev=spf1 mx -alland DMARCp=reject. That's where you end up, not where you start. See "Tightening DMARC" below.
Expect Hostinger's DNS to be slow. For over an hour its nameservers served old and new values side by side, first for DMARC and then for DKIM. If a record seems stuck, re-entering it forces a re-push. Check against Hostinger's own nameservers and public resolvers:
for ns in solar.dns-parking.com lunar.dns-parking.com 1.1.1.1 8.8.8.8 9.9.9.9; do
echo "== $ns"; dig +short TXT v1-rsa-20261001._domainkey.example.com @$ns | head -c 80; echo
done
Step 12 — Accounts and aliases
Admin UI → Management › Accounts. Create these first:
postmaster@,abuse@,dmarc@as aliases of your admin account. Other mail servers expect them, and DMARC and TLS reports land inpostmaster@.- One account per person:
firstname.lastname@ - Groups (shared mailboxes) for
info@,sales@,accounts@ - A dedicated account for any app that sends mail, like
erp@for ERPNext
Give each account a storage quota (5–10 GB is plenty for most people), and use app passwords for phones and desktop clients.
Check that the server accepts real recipients and rejects unknown ones, without sending anything:
swaks --server mail.example.com --to postmaster@example.com --quit-after RCPT # 250
swaks --server mail.example.com --to nobody@example.com --quit-after RCPT # 550 5.1.2
Step 13 — Test deliverability
Send a real message through submission:
swaks --server mail.example.com --port 465 --tls-on-connect \
--auth LOGIN --auth-user you@example.com --auth-password 'APP_PASSWORD' \
--from you@example.com --to your-personal@gmail.com \
--header "Subject: mail server test"
Then go through this list:
- Inbound: Gmail →
you@example.comarrives - Outbound: to Gmail and Outlook.com, landing in the inbox, not spam
- Gmail → ⋮ → Show original shows
SPF: PASS,DKIM: PASS,DMARC: PASS - mail-tester.com scores 9/10 or better
- MXToolbox diagnostics confirm you're not an open relay
My result was 9.2/10. The 0.8 points came almost entirely from FROM_FMBLA_NEWDOM14, a rule that penalises domains registered 7 to 14 days earlier. Mail-tester also flags missing HTML and List-Unsubscribe, but those only matter for newsletters.
Warm the IP up. For the first few weeks, send only normal person-to-person mail. No bulk sends. Ask regular contacts to reply, and to mark you "Not spam" if you land there.
Step 14 — Backups
Dokploy's Volume Backups upload named volumes to any S3-compatible bucket. I use Cloudflare R2. Add the bucket once under Settings → S3 Destinations, then go to the mail service → Volume Backups → Create:
stalwart-data: every mailbox, account, setting, DKIM private key and the outbound queue. Nightly, turn off container: on.stalwart-etc: Stalwart's startup config. Nightly, turn off container: on.roundcube-db: webmail preferences and the Roundcube address book. Nightly, optional.mail-certs: skip it, since it's regenerated from Traefik
Stopping the container matters. RocksDB can be copied in an inconsistent state while it's running. The nightly outage is about a minute, and sending servers simply retry.
Keep 14–30 days of backups. Then test a restore. A backup you haven't restored is a hope, not a backup. Restore into a new volume name and look inside:
docker run --rm -v restore-test_stalwart-data:/v alpine ls -la /v
docker volume rm restore-test_stalwart-data
Do it again every quarter.
Step 15 — Connect mail apps
Thanks to the autoconfig and SRV records, most apps set themselves up from the email address and password alone. For anything that asks:
Username full email address
Incoming (IMAP) mail.example.com 993 SSL/TLS
Outgoing (SMTP) mail.example.com 465 SSL/TLS (or 587 STARTTLS)
Password account password or an app password
Calendar (CalDAV) https://mail.example.com/dav/cal
Contacts (CardDAV) https://mail.example.com/dav/card
Webmail https://webmail.example.com
Optional — External search and in-memory stores
If you expect large mailboxes or many users, Stalwart can move full-text search into Meilisearch and short-lived data (rate limits, greylisting, locks) into Redis. Mail and blobs stay in RocksDB.
The clean way to do it in Dokploy is a second compose service, mail-stores, on an internal-only Docker network that the mail service also joins:
docker network create --internal mail-internal
services:
redis:
image: redis:8.10-alpine
container_name: mail-redis
restart: unless-stopped
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD}
command:
- sh
- -c
- exec redis-server --requirepass "$$REDIS_PASSWORD" --save '' --appendonly no --maxmemory 96mb --maxmemory-policy volatile-lru
mem_limit: 128m
networks: [mail-internal]
meilisearch:
image: getmeili/meilisearch:v1.54
container_name: mail-meilisearch
restart: unless-stopped
environment:
- MEILI_ENV=production
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
- MEILI_NO_ANALYTICS=true
- MEILI_MAX_INDEXING_MEMORY=256Mb
volumes:
- meili-data:/meili_data
mem_limit: 512m
networks: [mail-internal]
volumes:
meili-data:
networks:
mail-internal:
external: true
Add mail-internal (as external: true) to the Stalwart service's networks, deploy mail-stores first, then point Stalwart at it:
- Settings › Storage › Search Store → Meilisearch, URL
http://mail-meilisearch:7700, Bearer auth using Meilisearch's Default Admin API Key (not the master key) - Settings › Storage › In-Memory Store → Redis, URL
redis://:<REDIS_PASSWORD>@mail-redis:6379 - Restart Stalwart, then run a StoreMaintenance → reindex all accounts task under Management › Tasks so existing mail becomes searchable
Neither store needs a backup. Redis holds nothing permanent, and the search index can be rebuilt from Stalwart's data. One limit to know about: Meilisearch caps each query at 1,000 results.
The eight things that went wrong
Every one of these is fixed now, and every one is a few lines in the steps above. This is the list I wish I'd had.
- DKIM keys swapped. I pasted the two TXT values under each other's selectors. Everything looked fine until you verified the signatures.
- HSTS header missing after enabling it. Stalwart only applies it after a restart.
- No listener on 587. The wizard doesn't create one, so I added it by hand.
- Mail ports silent after the first deploy. That's just bootstrap mode. Finish the wizard and restart.
- Mail ports looked firewalled. On Hostinger a port with nothing listening times out instead of refusing. A temporary
nc -llistener on the VPS proved the rules were fine. - Slow DNS propagation. Old and new values were served side by side for over an hour. Patience, and re-entering stuck records.
- DMARC record truncated to
v=DMARC1when saved in hPanel. I re-entered it and checked withdig. - Certificate key unreadable and its path unpredictable. Fixed with
OVERRIDE_UID/GID=2000,DOMAIN=mail.<domain>, and pinningtls.domains[0]in the Traefik labels.
None of these were hard problems. Every one of them was silent.
Maintenance
Tightening DMARC. Watch the aggregate reports that arrive at postmaster@ (Stalwart parses them under Reports). After about two weeks of clean reports:
- Move DMARC to
p=quarantine; pct=25, thenpct=100, then optionallyp=reject - Move SPF from
~allto-all - Once TLS reports show other servers picking up your policy (policy type
sts, no failures), move MTA-STS fromtestingtoenforce
My first TLS report came from Google on day one: one encrypted delivery and no failures, but no-policy-found, because the MTA-STS record was still propagating. That kind of detail is exactly why you start in monitoring mode.
Upgrades. I pin stalwartlabs/stalwart:v0.16. For patch releases, run docker pull stalwartlabs/stalwart:v0.16 and then Deploy in Dokploy. For a minor version, read Stalwart's upgrade notes, run a manual backup, change the tag, and push.
If you can't send. If outbound mail is rate-limited or keeps landing in spam despite passing auth, route outbound through a smart host such as Amazon SES, Brevo or Mailgun on port 587 (Admin UI → MTA › Outbound › Routing), and add the provider's include: to SPF. I haven't needed it.
Monthly: check blocklists on MXToolbox, look at the outbound queue (MTA › Queue), confirm the latest backup is in the bucket, and run df -h.
Locked out of admin? Set STALWART_RECOVERY_ADMIN=admin:<pass> in the Environment tab, uncomment its line and add STALWART_RECOVERY_MODE=1, deploy, use the SSH tunnel on 8080, then revert both changes.
FAQ
Can you self-host email on a Hostinger VPS?
Yes. Outbound port 25 was open on my Hostinger VPS, reverse DNS is self-service in hPanel, and the panel firewall makes it easy to expose only the mail ports. With correct DNS, my server scored 9.2/10 on mail-tester on its first day.
Is port 25 blocked on Hostinger VPS?
It wasn't on mine. Test before you commit: nc -vz -w 5 gmail-smtp-in.l.google.com 25 from the VPS should connect. If you run into sending limits, a smart-host relay solves it without changing anything else.
How much RAM does Stalwart need?
Very little. I gave Stalwart a 1 GB limit and Roundcube 256 MB on a server that also runs other Dokploy apps. Stalwart is a single Rust binary, with no separate Postfix, Dovecot, Rspamd or database to run.
Why not let Stalwart get its own TLS certificate?
It can, but it needs either a DNS provider with an API it supports (for DNS-01) or port 443 (for TLS-ALPN-01). With DNS at Hostinger and Traefik owning 443, it's simpler to let Traefik get one certificate and share it through certs-dumper.
Do I need Roundcube?
Only if people want webmail. Stalwart has an admin UI but no webmail client. Desktop and phone apps connect directly over IMAP or JMAP.
Is self-hosted email worth it?
For a company that wants full control of its data and addresses, and has someone who likes owning infrastructure, yes. It costs a share of a VPS you probably already pay for. The real cost is the first week of careful DNS work, which this guide is meant to shorten.
Credits
This setup is as small as it is because of three projects:
- Stalwart: SMTP, IMAP, JMAP, CalDAV, CardDAV, spam filtering, DKIM, MTA-STS, DMARC reporting and an admin UI in one binary. The generated DNS zone file alone saves an afternoon.
- Hostinger: a VPS with outbound SMTP available, self-service reverse DNS and a panel firewall, which are the three things that usually make mail hosting painful.
- Dokploy: Git-driven compose deploys and volume backups to S3 without writing a single backup script.
The docs I leaned on: Stalwart Docker install, Stalwart DNS setup, Stalwart behind Traefik, securing Stalwart, Dokploy Docker Compose and Dokploy Volume Backups.
If this guide saved you a weekend, I post shorter notes like this on my Telegram channel, @abelmairegstream. If you'd rather have someone set this up for you, the button below is how you reach me.