Skip to content

Instantly share code, notes, and snippets.

@pabloko
Last active August 11, 2025 20:37
Show Gist options
  • Save pabloko/073afecfc64886b44851145a8eb16e29 to your computer and use it in GitHub Desktop.
Save pabloko/073afecfc64886b44851145a8eb16e29 to your computer and use it in GitHub Desktop.

Hestia on dynamic/undisclosed IP: mail stack via VPS

If you run a mail server under a dynamic ip, or an ip that should not be disclosed and you're proxiying with cloudflare, let's configure a 1€/month IONOS vps as mail relay server.

You will need a vanity MX subdomain mail.yourdomain.com with SSL certificate. Each domain should use it as MX server, fix the SPF record and be advertised on SRV records if used.

The vps's IP should resolve to this subdomain. Ensure with the vps provider that the IP rDNS record points to your vanity subdomain, and in case of IONOS ask support to enable port 25, and allow on the external firewall.

Server internal connection (optional)

With both servers, HESTIA and VPS the best option is to use wireward to interconnect them, this is highly encouraged and i will use the interface ips on this tutorial. Generate a key and create the file /etc/wireguard/wg0.conf on both servers HESTIA

[Interface]
PrivateKey = A_PRIVATE_KEY
Address = 10.0.0.2/24
[Peer]
PublicKey = B_PUBLIC_KEY
Endpoint = VPS_FIXED_IP:18632
AllowedIPs = 10.0.0.1/32
PersistentKeepalive = 25

VPS

[Interface]
PrivateKey = B_PRIVATE_KEY
Address = 10.0.0.1/24
ListenPort = 18632
[Peer]
PublicKey = A_PUBLIC_KEY
AllowedIPs = 10.0.0.2/32

Use wg-quick to enable and start the VPN (ensure your VPS allow connections on ListenPort/udp).

HESTIA ip: 10.0.0.2   # if not using wireward, assume public external ip
VPS ip:    10.0.0.1   # if not using wireward, assume public external ip

Now both servers should be able to ping between them. You can restrict smtp in hestia firewall from 0.0.0.0/0 -> 10.0.0.1 so hestia's exim isnt reachable from internet, but the VPS.

Postfix MTA

It's tempting to try to just use socat or ssh lateral port forwarding to receive inbound mail, but doing that, exim will see all traffic coming from 127.0.0.1 instead of the original IP, making all email local and trusted, converting your VPS into an open relay (useful for spammers and amplification attacks). and it dont fix the issue for outbound email, wich also shuld be relayed by the VPS.

The best option is to use an MTA like postfix, that will work like this:

  • Inbound email for local domains (eg. recive email from gmail): Perform rDNS, RBL, SPF, DKIM, DMARC checks on IP/Sender, then relay to exim
  • Inbound email for remote domains (eg. outlook/mail app) and outbound email (eg. send to gmail): require to be a trusted ip (127.0.0.1, 10.0.0.2) or AUTH*, then relay to the remote.

Auth (Dovecot SASL)

We need a way to authorize connections on postfix. exim uses dovecot SASL authenticator, that can be exposed to the VPS via network port, edit /etc/dovecot/conf.d/10-master.conf:

service auth {
  inet_listener auth {
    port = 12345
  }
  ...

Add to hestia firewall a tcp allow for port 12345 to the VPS (10.0.0.1) check with telnet 10.0.0.2 12345 on the VPS that SASL authenticator is available.

Modern SMTP servers use TLS forward secrecy, wich just means that AUTH is only available while using a TLS encrypted connection, either by using SSL (465)/STARTTLS (587) port or negotating STARTTLS on 25. As the user and password travel in plain base64 MITM attacks could exfiltrate those credentials easily without this limitation.

VPS Configuration

Start by installing the required packages: apt install postfix postfix-policyd-spf-python opendmarc opendkim fail2ban

Transfer your ssl certificate of your vanity domain (hostname) to /etc/ssl/certs/fullchain.pem - /etc/ssl/private/privkey.pem

Write to /etc/postfix/main.cf

# Host Identity
myhostname = mail.yourdomain.com
myorigin = mail.yourdomain.com
mydestination =
mynetworks = 127.0.0.1 10.0.0.1 10.0.0.2
# TLS
smtpd_tls_cert_file = /etc/ssl/certs/fullchain.pem
smtpd_tls_key_file = /etc/ssl/private/privkey.pem
smtpd_tls_security_level = may
smtpd_tls_auth_only = yes
smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3
smtpd_tls_mandatory_ciphers = high
smtpd_tls_dh1024_param_file = /etc/ssl/certs/dhparam.pem
# PSF
smtp_tls_security_level = may
#smtp_tls_security_level = encrypt
smtp_tls_note_starttls_offer = yes
smtp_tls_loglevel = 1
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
# Relay and Routing
relay_domains = hash:/etc/postfix/relay_domains
relay_recipient_maps =
transport_maps = hash:/etc/postfix/transport
relayhost =
# Authenticated SMTP
smtpd_sasl_auth_enable = yes
smtpd_sasl_type = dovecot
smtpd_sasl_path = inet:10.0.0.2:12345
smtpd_sasl_security_options = noanonymous
smtpd_sasl_tls_security_options = noanonymous
# HELO verification
smtpd_helo_required = yes
disable_vrfy_command = yes
strict_rfc821_envelopes = yes
smtpd_delay_reject = yes
smtpd_helo_restrictions = 
    reject_invalid_helo_hostname, 
    reject_non_fqdn_helo_hostname, 
    permit
# Message filtering
smtpd_client_restrictions =
    permit_mynetworks,
    permit_sasl_authenticated,
    check_client_access cidr:/etc/postfix/rbl_exempt.cidr,
    check_policy_service unix:private/policyd-spf,
    reject_rbl_client zen.spamhaus.org,
    reject_rbl_client bl.spamcop.net,
    permit
smtpd_recipient_restrictions =
    permit_mynetworks,
    permit_sasl_authenticated,
    check_recipient_access hash:/etc/postfix/relay_domains,
    reject_unauth_destination
# DKIM/DMARC/SPF milters
milter_default_action = accept
milter_protocol = 6
smtpd_milters = inet:localhost:8891, inet:localhost:8893
non_smtpd_milters = inet:localhost:8891, inet:localhost:8893
milter_macro_daemon_name = $myhostname
# NIS warn skip
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
# misc
smtpd_relay_before_recipient_restrictions = yes
compatibility_level = 3.6

(you might need to generate a dhparam.pem or copy from hestia server)

Write to /etc/postfix/master.cf

# ==========================================================================
# service type  private unpriv  chroot  wakeup  maxproc command + args
#               (yes)   (yes)   (no)    (never) (100)
# ==========================================================================
smtp      inet  n       -       y       -       -       smtpd
#smtp     inet  n       -       y       -       1       postscreen
#smtpd    pass  -       -       y       -       -       smtpd
#dnsblog  unix  -       -       y       -       0       dnsblog
#tlsproxy unix  -       -       y       -       0       tlsproxy
#628      inet  n       -       y       -       -       qmqpd
pickup    unix  n       -       y       60      1       pickup
cleanup   unix  n       -       y       -       0       cleanup
qmgr      unix  n       -       n       300     1       qmgr
#qmgr     unix  n       -       n       300     1       oqmgr
tlsmgr    unix  -       -       y       1000?   1       tlsmgr
rewrite   unix  -       -       y       -       -       trivial-rewrite
bounce    unix  -       -       y       -       0       bounce
defer     unix  -       -       y       -       0       bounce
trace     unix  -       -       y       -       0       bounce
verify    unix  -       -       y       -       1       verify
flush     unix  n       -       y       1000?   0       flush
proxymap  unix  -       -       n       -       -       proxymap
proxywrite unix -       -       n       -       1       proxymap
smtp      unix  -       -       y       -       -       smtp
relay     unix  -       -       y       -       -       smtp
  -o syslog_name=postfix/$service_name
# -o smtp_helo_timeout=5 -o smtp_connect_timeout=5
showq     unix  n       -       y       -       -       showq
error     unix  -       -       y       -       -       error
retry     unix  -       -       y       -       -       error
discard   unix  -       -       y       -       -       discard
local     unix  -       n       n       -       -       local
virtual   unix  -       n       n       -       -       virtual
lmtp      unix  -       -       y       -       -       lmtp
anvil     unix  -       -       y       -       1       anvil
scache    unix  -       -       y       -       1       scache
postlog   unix-dgram n  -       n       -       1       postlogd
uucp      unix  -       n       n       -       -       pipe
  flags=Fqhu user=uucp argv=uux -r -n -z -a$sender - $nexthop!rmail ($recipient)

submission inet n       -       y       -       -       smtpd
  -o smtpd_tls_security_level=encrypt
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_sasl_type=dovecot
  -o smtpd_sasl_path=inet:10.0.0.2:12345
  -o smtpd_recipient_restrictions=permit_mynetworks,permit_sasl_authenticated,reject
  -o milter_macro_daemon_name=ORIGINATING

smtps      inet n       -       y       -       -       smtpd
  -o smtpd_tls_wrappermode=yes
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_sasl_type=dovecot
  -o smtpd_sasl_path=inet:10.0.0.2:12345
  -o smtpd_recipient_restrictions=permit_mynetworks,permit_sasl_authenticated,reject
  -o milter_macro_daemon_name=ORIGINATING

policyd-spf unix -      n       n       -       0       spawn
  user=nobody argv=/usr/bin/python3 /usr/bin/policyd-spf /etc/postfix-policyd-spf-python/policyd-spf.conf

Edit /etc/opendkim.conf to use a network port and only verify:

Mode                    v
...
#Socket                 local:/run/opendkim/opendkim.sock
Socket                  inet:8891@localhost

Do the same for /etc/opendmarc.conf

#Socket local:/run/opendmarc/opendmarc.sock
Socket  inet:8893@localhost
TrustedAuthservIDs mail.yourdomain.com

Local domains

Postfix needs to know what domains should be relayed to exim, for now lets build a static list (later in this tutorial this will be addressed***)

List the domains on /etc/postfix/transport like in the example

yourdomain.com smtp:[10.0.0.2]:25
otherdomain.com smtp:[10.0.0.2]:25

also /etc/postfix/relay_domains

yourdomain.com OK
otherdomain.com OK

This routes verified emails to exim. hash the files with postmap /etc/postfix/relay_domains and postmap /etc/postfix/transport and then restart everything service opendkim restart; service opendmarc restart; service postfix restart

Exim inbound and outbound

The VPS is ready, now its needed to configure exim to properly handle mail. Edit /etc/exim4/exim4.conf.template first we want to force an internal hostname that is not the same as the vanity subdomain, and add the VPS ip as relay allowed (local mail) and add VPS ip as "local" host (authorized by default)

smtp_banner = server1.yourdomain.com
smtp_active_hostname = server1.yourdomain.com
...
hostlist relay_from_hosts = 127.0.0.1 10.0.0.1 10.0.0.2

After this change, inbound email should work, but lets also use the VPS as exim's "smarthost" and using it as relay. Hestia provides functionality creating this file /etc/exim4/smtp_relay.conf

host:10.0.0.1
port:25
user:
pass:

This configuration ensures outbound messages are properly DKIM-signed and relayed to destination via postfix on the VPS, removing the internal IP component (using wireward, or removing the extra Recived header)

Brute force

After a small time you will notice a lot of inbound connections try brute-forcing accounts on the server, log will show something like this on /var/log/mail.log:

postfix/smtpd[257723]: connect from unknown[81.30.107.5]
postfix/smtpd[257723]: disconnect from unknown[81.30.107.5] ehlo=1 auth=0/1 rset=0/1 quit=1 commands=2/4
postfix/smtpd[257470]: connect from unknown[81.30.107.6]
postfix/smtpd[257470]: disconnect from unknown[81.30.107.6] ehlo=1 auth=0/1 rset=0/1 quit=1 commands=2/4
postfix/smtpd[257723]: connect from unknown[81.30.107.5]
postfix/smtpd[257723]: disconnect from unknown[81.30.107.5] ehlo=1 auth=0/1 rset=0/1 quit=1 commands=2/4
postfix/smtpd[257470]: connect from unknown[81.30.107.6]
postfix/smtpd[257470]: disconnect from unknown[81.30.107.6] ehlo=1 auth=0/1 rset=0/1 quit=1 commands=2/4

Since dovecot does not run on the VPS the logs are not available, so lets just make a simple fail2ban rule to block out spammers using the mail.log disconnections from IPs with auth=0/N, where N = failed attempts. /etc/fail2ban/jail.d/postfix-authfail.local

[postfix-authfail]
enabled = true
port = smtp,ssmtp,submission
filter = postfix-authfail
logpath = /var/log/mail.log
maxretry = 3
findtime = 600
bantime = 3600
action = iptables-multiport[name=PostfixAuth, port="smtp,ssmtp,submission", protocol=tcp]
ignoreip = 127.0.0.1/8 10.0.0.2 10.0.0.1

/etc/fail2ban/filter.d/postfix-authfail.conf

[Definition]
failregex = ^.*postfix/smtpd\[\d+\]: disconnect from \S+\[<HOST>\] .*auth=0/(?P<attempts>\d+).*
ignoreregex =

Then apply and check

# service fail2ban restart
# fail2ban-client status postfix-authfail
Status for the jail: postfix-authfail
|- Filter
|  |- Currently failed: 0
|  `- Total failed:     6
`- Actions
   |- Currently banned: 2
   |- Total banned:     4
   `- Banned IP list:   81.30.107.5 81.30.107.6

Automatic maintenance

The server works but requires us to update the domain (ls /etc/exim4/domains) list and SSL certificate of the vanity subdomain after renewal, wich we have on hestia. Lets crete a shell script that updates the VPS and we can call it on a cronjob or a hook:

#!/bin/bash

### CONFIGURATION ###
VPS_USER="root"
VPS_HOST="10.0.0.1"
VPS_PASS="your_ssh_password_here"

CRT_SRC="/usr/local/hestia/ssl/certificate.crt"
KEY_SRC="/usr/local/hestia/ssl/certificate.key"

CRT_DEST="/etc/ssl/certs/fullchain.pem"
KEY_DEST="/etc/ssl/private/privkey.pem"

RELAY_DOMAINS_FILE="/tmp/relay_domains"
TRANSPORT_FILE="/tmp/transport"

### STEP 1: Generate relay_domains and transport files ###
echo "Generating relay_domains and transport from /etc/exim4/domains..."

> "$RELAY_DOMAINS_FILE"
> "$TRANSPORT_FILE"

for domain in $(ls /etc/exim4/domains); do
    echo "$domain OK" >> "$RELAY_DOMAINS_FILE"
    echo "$domain smtp:[10.0.0.2]:25" >> "$TRANSPORT_FILE"
done

### STEP 2: Push certs and files to VPS ###
echo "Transferring certificate, key, and domain config to VPS..."

sshpass -p "$VPS_PASS" scp "$CRT_SRC" "$VPS_USER@$VPS_HOST:$CRT_DEST"
sshpass -p "$VPS_PASS" scp "$KEY_SRC" "$VPS_USER@$VPS_HOST:$KEY_DEST"
sshpass -p "$VPS_PASS" scp "$RELAY_DOMAINS_FILE" "$VPS_USER@$VPS_HOST:/etc/postfix/relay_domains"
sshpass -p "$VPS_PASS" scp "$TRANSPORT_FILE" "$VPS_USER@$VPS_HOST:/etc/postfix/transport"

### STEP 3: Rebuild DB and reload Postfix ###
echo "Reloading Postfix on VPS..."

sshpass -p "$VPS_PASS" ssh "$VPS_USER@$VPS_HOST" bash <<EOF
postmap /etc/postfix/transport
postmap /etc/postfix/relay_domains
chmod 600 $CRT_DEST $KEY_DEST
postfix reload
EOF

### CLEANUP ###
rm "$RELAY_DOMAINS_FILE" "$TRANSPORT_FILE"

echo "✅ All done. Certificates and domain configs updated on VPS."

Now the configuration can be synchronized quickly calling this script.

MTA-STS

This means a SPF-alike verification will be done over HTTPS. To enable MTA-STS, you will need to add this records per mail domain:

_mta-sts.<domain>.com. 3600 IN TXT "v=STSv1; id=2025080000"
<domain>.com. 3600 IN MX 10 mail.yourdomain.com.
mta-sts.<domain>.com. 3600 IN CNAME mail.yourdomain.com.

Then each domain should have a nginx configuration /home/admin/conf/web/mta-sts.<domain>.com/nginx.ssl.conf_mtasts and make sure the subdomain has a valid SSL certificate

location = /.well-known/mta-sts.txt {
  default_type text/plain;
  return 200 "version: STSv1\nmode: enforce\nmx: mail.yourdomain.com\nmax_age: 604800\n";
}

DANE and other would require DNSSEC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment