~/abel
Available
← back to writing
Ops·Oct 2026·18 min read

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

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 master and 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:

  • mail A: 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 to postmaster@
  • 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].main is pinned to mail.<domain>. Without it, Traefik picks the certificate's "main" name itself, and certs-dumper can't reliably find it in acme.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 to 0.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 to master redeploys.
  • Environment tab: paste the three lines from .env and Save
  • Leave Isolated Deployments off, because the compose file joins dokploy-network itself
  • 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 domain example.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_ADMIN from Dokploy's Environment tab, comment the line out again in the compose file, push, and deploy. Delete it from your local .env too.
  • 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.com and compare.
  • _submission._tcp for 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 1 entries also pin the current key, and Traefik replaces the key on every renewal.
  • _pop3s._tcp, because port 995 isn't published
  • ua-auto-config, because it isn't routed by Traefik or on the certificate
  • v=spf1 mx -all and DMARC p=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 in postmaster@.
  • 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.com arrives
  • 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 -l listener 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=DMARC1 when saved in hPanel. I re-entered it and checked with dig.
  • Certificate key unreadable and its path unpredictable. Fixed with OVERRIDE_UID/GID=2000, DOMAIN=mail.<domain>, and pinning tls.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, then pct=100, then optionally p=reject
  • Move SPF from ~all to -all
  • Once TLS reports show other servers picking up your policy (policy type sts, no failures), move MTA-STS from testing to enforce

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.

© 2026 Abel Maireg — built from scratch, no template. designed & deployed with care