SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Send mail from your self-hosted apps

Send mail from self-hosted apps without running a mail server. One SMTP relay at host level, SPF, DKIM and DMARC, and a fix for blocked port 25.

What self-hosted apps need to send mail

To send mail from self-hosted apps you do not need a mail server. You need a relay: one authenticated SMTP account, configured once on the host, that every app on the box hands its outgoing mail to. Running a mailbox is the hard problem, and it is a different problem.

Receiving mail means accepting connections from the whole internet on port 25, filtering spam, storing and backing up mailboxes, and defending an IP reputation for as long as the server exists. That job has genuinely got harder. Sending mail means the password reset, the signup confirmation, the "backup failed" alert, and the forum reply notification. Those are short, low volume, and go out one at a time. A relay handles them, and setting it up takes an afternoon.

Decide which of the two you are actually trying to solve. Whether running your own mailbox is still worth it is a real question with a real answer, and for most people the answer is no. If your answer is yes, a full Mailcow mail server on a VPS is the honest path. The other half, sending, is what almost everyone needs and almost nobody plans for.

Two terms first. SMTP (simple mail transfer protocol) is the protocol every part of this uses. A relay, also called a smarthost, is a server that accepts your authenticated mail and delivers it onward using its own addresses and its own reputation.

Why your VPS cannot deliver mail on port 25

Almost every VPS provider blocks outbound TCP port 25 by default. Port 25 is the port mail servers use to reach each other, so a compromised VPS with outbound 25 open can deliver spam straight to every receiving mail server. Providers drop those packets instead of refusing them, which is why the symptom is a connection that hangs and then times out, rather than an error.

Test it from the server:

sudo apt update && sudo apt install -y netcat-openbsd
nc -vz -w 5 gmail-smtp-in.l.google.com 25
nc -vz -w 5 smtp.relay.example 587

If the first command sits there for the full five seconds while the second answers at once, the block is confirmed. Some providers lift it after an account review. Most do not.

The block is not the main reason to use a relay. Even with port 25 open, mail sent straight from a fresh VPS address lands in spam or is refused outright, because that address has no sending history and sits in a range receivers treat as hosting space. Google's sender guidance requires valid forward and reverse DNS for the sending IP, and many VPS addresses carry a generic PTR (pointer) record you cannot change. A relay gives you addresses that already have a history.

Submission ports are the way out. Port 587 carries STARTTLS, where the session begins in cleartext and is upgraded. Port 465 carries implicit TLS (transport layer security), where the session is encrypted from the first byte. Both are meant for authenticated clients, both are open on VPS networks, and your relay supports at least one.

Pick a relay and a sending subdomain

There are many transactional mail providers and they all do the same job. Judge them on four things:

  • a submission port, 587 or 465, with SMTP AUTH
  • DKIM signing with your own domain and your own selector, not only with theirs
  • bounce and complaint data you can read, by dashboard or by webhook
  • a tier that fits your volume. As of August 2026 several providers still include a few thousand messages a month at no cost, and those terms change often, so read the current pricing page rather than any blog post

Send app mail from a subdomain. Use something like notify.example.com instead of example.com. Receivers score reputation per domain, so a bad send from your apps stays off the domain your invoices and your team's mail come from. Be honest about the limit: some receivers roll subdomain signals up to the organisational domain, so a subdomain reduces the damage rather than sealing it off.

Configure the relay once for every self-hosted app

The tempting approach is to open each app's settings page and paste the SMTP host, username and password into it. Nextcloud, the forum, Grafana, Vaultwarden and the uptime monitor all have that form. Do that and the credential now lives in six places, in six formats, several of them inside a database you back up as data rather than as config. When you rotate the password you will update five of them. The sixth stops sending, and it stops quietly, because most apps log the SMTP error server side and still show the user a success page.

Configure it once on the host instead, and let apps submit locally. Two tools do this well, and the choice between them is about queuing.

msmtp is a sendmail-compatible client with no daemon. It connects, sends, and exits. It does not queue, so if the relay is unreachable the message is lost and the calling app gets a non-zero exit status.

Postfix configured as a satellite is a full mail transfer agent with a real queue. It accepts the message instantly, retries for days on failure, and keeps the relay credential in a root-only file. Use it when losing an alert during a relay outage matters, or when several apps run as different system users.

msmtp, the small option

sudo apt update
sudo apt install -y msmtp msmtp-mta ca-certificates

msmtp-mta installs the /usr/sbin/sendmail symlink, so anything that calls sendmail reaches msmtp without knowing it exists.

Write /etc/msmtprc:

defaults
auth            on
tls             on
tls_trust_file  /etc/ssl/certs/ca-certificates.crt
syslog          on

account         relay
host            smtp.relay.example
port            587
from            apps@notify.example.com
set_from_header on
user            <relay username>
password        <relay password>

account default : relay

set_from_header on always sets a From header and overrides any existing one, so it replaces whatever the app produced with the address in from. Without it a cron job sends as root@your-hostname, and the relay refuses that because it is not an address you verified. syslog on sends the log through syslog, so you read it with journalctl -t msmtp. A shared logfile path is the alternative, and it needs write permission for every user that sends mail, which is a trap on a multi-user box.

Set the permissions yourself. msmtp enforces permissions on a per-user config (~/.msmtprc), where it refuses to run with contains secrets and therefore must have no more than user read/write permissions. It enforces nothing on /etc/msmtprc, because it simply loads that file if it is readable.

sudo chown root:root /etc/msmtprc
sudo chmod 600 /etc/msmtprc
printf 'Subject: relay test\n\nsent from the host\n' | sudo sendmail -v you@example.com

-v prints the whole SMTP conversation, so you see each reply from the relay. A working send ends with a 250 reply accepting the message. An authentication failed line means the username or password is wrong, or the relay expects an API key in place of the account password.

Now the catch, and it is the reason many people end up on Postfix. With mode 600 and owner root, only root can send. An app running as www-data cannot read the file, msmtp skips it, and the app fails with an error about the default account not being found. The fix is a group:

sudo chgrp mail /etc/msmtprc
sudo chmod 640 /etc/msmtprc
sudo usermod -aG mail www-data

Say plainly what that means: every member of the mail group can read the relay password and send mail as your domain from that box. On a VPS you administer alone, that is acceptable. Where several apps you did not write run as different users, it is not, and Postfix is the better answer because those apps never see the credential.

Postfix as a satellite

sudo debconf-set-selections <<'EOF'
postfix postfix/main_mailer_type select Satellite system
postfix postfix/mailname string notify.example.com
postfix postfix/relayhost string [smtp.relay.example]:587
EOF
sudo DEBIAN_FRONTEND=noninteractive apt install -y postfix libsasl2-modules

libsasl2-modules is not optional. Without it Postfix logs warning: SASL authentication failure: No worthy mechs found, because the PLAIN and LOGIN mechanism libraries are not installed under /usr/lib/sasl2.

Set the rest with postconf -e, which edits /etc/postfix/main.cf in place:

sudo postconf -e 'relayhost = [smtp.relay.example]:587'
sudo postconf -e 'smtp_sasl_auth_enable = yes'
sudo postconf -e 'smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd'
sudo postconf -e 'smtp_sasl_security_options = noanonymous'
sudo postconf -e 'smtp_tls_security_level = encrypt'
sudo postconf -e 'smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt'
sudo postconf -e 'inet_interfaces = loopback-only'

The square brackets around the relay hostname stop Postfix looking up an MX record for that name and make it connect to the name directly. Some relay hostnames do publish MX records pointing somewhere else, and without the brackets your mail follows them to the wrong server.

smtp_tls_security_level = encrypt makes TLS mandatory, so the message is never sent in cleartext. It does not verify the certificate. Postfix's own documentation is explicit about this: at that level, delivery continues even if the server certificate is untrusted or bears the wrong name. If you want the certificate checked, use verify or secure and keep smtp_tls_CAfile set.

The credential goes in one root-only file:

echo '[smtp.relay.example]:587 relay-username:relay-password' | sudo tee /etc/postfix/sasl_passwd
sudo chmod 600 /etc/postfix/sasl_passwd
sudo postmap hash:/etc/postfix/sasl_passwd
sudo systemctl restart postfix

postmap builds the indexed copy that Postfix actually reads. Edit the text file later and forget postmap, and Postfix keeps using the old database with nothing in the log to tell you. On Postfix 3.9 and newer the default map type is lmdb, so write lmdb: in both the parameter and the postmap argument if you prefer that. Naming the type on both lines is what keeps them in step.

Apps still address their mail as root@hostname. Rewrite the sender:

echo '/.+/    apps@notify.example.com' | sudo tee /etc/postfix/sender_canonical
sudo postconf -e 'sender_canonical_classes = envelope_sender, header_sender'
sudo postconf -e 'sender_canonical_maps = regexp:/etc/postfix/sender_canonical'
sudo systemctl reload postfix

A regexp: table is read directly, so it needs no postmap. Every message now leaves with the same envelope sender and the same From header, which is what the relay wants. The cost is that replies all land in one place, so set a Reply-To header inside each app where replies should reach a person.

Send a test and read the log:

printf 'Subject: postfix relay test\n\nsent through the queue\n' | sendmail you@example.com
sudo tail -n 20 /var/log/mail.log

A delivered message logs status=sent followed by the relay's own reply in brackets. Anything else names the reason. status=deferred with Connection timed out means something is still pointed at port 25. Host or domain name not found. Name service error for name=smtp.relay.example type=A means the relay hostname is wrong or DNS is broken on the box. mailq lists what is stuck and sudo postqueue -f retries it now.

Reaching the host relay from Docker containers

A container cannot call the host's sendmail, because the binary is not in the image and the queue is not shared. Give the containers a network target instead. Postfix can listen on the Docker bridge address.

ip -4 addr show docker0
sudo postconf -e 'inet_interfaces = 127.0.0.1, 172.17.0.1'
sudo postconf -e 'mynetworks = 127.0.0.0/8 172.17.0.0/16'
sudo systemctl restart postfix

Read your own bridge address from that first command rather than copying this one, because a Compose project creates its own network on a different subnet, and docker network inspect <name> prints it. Use restart and not reload here: Postfix documents that you must stop and start after changing inet_interfaces, and a reload will not pick the change up. Each app then gets SMTP host 172.17.0.1, port 25, no authentication and no TLS, because that traffic never leaves the host. If your services sit on a Compose network, running Docker Compose on a VPS covers where that subnet comes from.

This is the step that can hurt you. A Postfix listening on a public address with a wide mynetworks is an open relay: strangers send their mail through your relay account, the provider suspends it, and your domain's reputation is damaged for months. Check both sides after every change.

ss -tlnp | grep ':25'

The output must show only the loopback address and the bridge address. From another machine, nc -vz your.server.ip 25 must fail.

SPF, DKIM and DMARC for the sending domain

Publish all three records before the first real send. They are free, they are DNS, and they are what receivers check first.

SPF (sender policy framework) lists who may put your domain in the envelope sender. Publish it on the sending subdomain:

notify.example.com.  IN  TXT  "v=spf1 include:_spf.relay.example -all"

Copy the include: value from your relay's own setup page, because an include that does not resolve gives you a permanent error instead of a pass. SPF evaluation stops after ten DNS-querying mechanisms and returns permerror, which receivers treat as a failure, so keep the includes few. Publish exactly one v=spf1 record per name: two of them is also a permerror.

DKIM (domainkeys identified mail) signs each message with a private key the relay holds, and receivers fetch the matching public key from DNS. Your relay gives you a selector and either a TXT record or a CNAME to publish:

sel1._domainkey.notify.example.com.  IN  CNAME  sel1.dkim.relay.example.

DKIM matters more than SPF, because DKIM survives forwarding. When a mailing list or a .forward rule passes your message on, it arrives from the forwarder's IP address, so SPF fails while the signature still verifies.

DMARC (domain-based message authentication, reporting and conformance) tells receivers what to do when neither check aligns, and asks them to report back. Publish it on the organisational domain:

_dmarc.example.com.  IN  TXT  "v=DMARC1; p=none; rua=mailto:dmarc@example.com; adkim=r; aspf=r"

Start at p=none and read the reports for two weeks. p=none changes nothing about delivery, it only turns on reporting, which is how you find the systems you forgot were sending as your domain. Then move to p=quarantine, then to p=reject. Publishing p=reject on day one is how people discover their invoicing system was sending as the domain, by way of a customer who never got an invoice.

Check what the world sees, not what your DNS panel shows:

dig +short TXT notify.example.com
dig +short TXT sel1._domainkey.notify.example.com
dig +short TXT _dmarc.example.com

Empty output means the record has not propagated or the name is wrong. A record you fixed five minutes ago can stay wrong in caches for the length of its previous TTL (time to live), so check the TTL before you conclude anything.

Keep From and Return-Path aligned

Every message carries two sender addresses and they are checked differently. The envelope sender is given in the SMTP MAIL FROM command and appears in the delivered message as Return-Path. The header From is the address the reader sees.

SPF checks the envelope sender's domain against the connecting IP address. DKIM reports the domain that signed, as d=. DMARC passes only when at least one of those two domains aligns with the domain in the header From. With relaxed alignment (adkim=r, aspf=r, which is the default) a subdomain counts, so an envelope sender at notify.example.com aligns with a header From at example.com. With strict alignment it does not.

The practical rule is short: put the header From and the envelope sender on the same domain, and the question never arises. That is exactly what set_from_header on does in msmtp and what sender_canonical_maps does in Postfix.

Read the verdict in a delivered message. In Gmail, "Show original" prints the header the receiver wrote:

Authentication-Results: mx.google.com;
       dkim=pass header.i=@notify.example.com;
       spf=pass (google.com: domain of apps@notify.example.com designates 198.51.100.25 as permitted sender) smtp.mailfrom=apps@notify.example.com;
       dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=example.com

All three checks pass there. Anything else names the check that failed and usually why, which is the fastest debugging information you will get on this topic.

Bounces and complaints, before the volume arrives

A bounce is a receiver refusing your message. A hard bounce is permanent, and Gmail phrases it 550 5.1.1 The email account that you tried to reach does not exist. A soft bounce is temporary, a 4xx code for a full mailbox or for greylisting, and the relay retries on its own.

Relays measure your hard bounce rate and suspend accounts that keep mailing addresses which do not exist, because that pattern is what a purchased list looks like. Complaints matter more. A complaint is a person pressing the spam button, and Google's sender guidance (checked August 2026) asks senders to stay under a 0.30% spam rate as reported in Postmaster Tools, and recommends staying below 0.10%.

Four things to have in place before volume arrives:

  • a webhook, or a weekly look at the relay's suppression list, so you see bounces at all
  • a From address that is a real mailbox somebody reads, with Reply-To set where replies belong
  • confirmation before any address is added to anything, so you never send to an address its owner did not enter
  • a rate limit on whatever form triggers mail

Those last two are where self-hosted apps fail first. An unprotected signup form lets anyone type a stranger's address, your server sends the confirmation, and the stranger marks it as spam. Stopping subscription bombing at your signup form is a deliverability job as much as an abuse job.

Keep bulk mail off this path. Newsletters need list management and unsubscribe headers that transactional mail never has, so run them through a self-hosted Listmonk instance on its own subdomain with its own reputation. Notification mail from a self-hosted forum sits in between, transactional in shape and bulk in volume, and it is usually the first thing that shows you whether your setup holds.

For reference, Gmail's bulk sender rules apply above 5,000 messages a day to Gmail addresses and require SPF, DKIM, DMARC and one-click unsubscribe on marketing mail. Most self-hosted apps never reach that line. The authentication half is expected from every sender now regardless.

Test it before you trust it

swaks is the tool for this. It speaks SMTP and prints the whole conversation, so you see which step failed.

sudo apt install -y swaks
swaks --to you@example.com --from apps@notify.example.com --server smtp.relay.example --port 587 --tls --auth-user '<relay username>' --auth-password '<relay password>'

That tests the credentials against the relay directly. To test the path your apps actually use, point it at the host relay instead:

swaks --to you@example.com --from apps@notify.example.com --server 127.0.0.1

Then check the result end to end, from the server, with real mail. None of this can be proved from config files, so run it yourself:

  • send to a scoring service such as mail-tester.com, which reads your SPF, DKIM, DMARC and message content and gives you the reasons behind the score
  • send to a mailbox at each of the two providers your users are actually on, and read Authentication-Results in the raw message
  • walk one message through learndmarc.com when the alignment result is not obvious
  • trigger a send from the app itself, not only from the command line, because the app is what sets the From header

One honest warning to close on. A brand new domain with all three records correct still lands in spam sometimes, because it has no history and receivers are cautious about domains that appeared last week. Start small and send mail people expect. The reputation builds from there, and no configuration skips that part.

FAQ

Why is outbound port 25 blocked on my VPS?

Nearly every provider blocks outbound TCP port 25 by default, because a compromised server with that port open can deliver spam straight to receiving mail servers. The packets are dropped rather than refused, so the symptom is a connection that hangs and then times out, not an error message. Confirm it by running nc -vz -w 5 gmail-smtp-in.l.google.com 25 next to nc -vz -w 5 smtp.relay.example 587: the first sits there, the second answers at once. The answer is not to ask for the block to be lifted. Send through a relay on submission port 587 or 465, which stay open and are meant for authenticated clients.

Do I need SPF, DKIM and DMARC just to send a few app notifications?

Yes, and volume does not change that. Receivers apply the same checks to one password reset as to a campaign of fifty thousand messages. Without SPF and DKIM your mail is unauthenticated, and Google's current sender guidance requires at least one of them from every sender. Without DMARC you get no reports, so the first sign of a problem is a user saying the reset link never arrived. All three are DNS records, they cost nothing, and publishing them takes about ten minutes.

Should I use msmtp or Postfix as the relay client?

Use msmtp when one person administers the box and losing a message during a relay outage is acceptable. It is a single config file with no daemon, and because it does not queue, an unreachable relay means the message is gone. Use Postfix as a satellite when you want a queue that retries for days, or when several apps run as different system users. Postfix keeps the relay password in a root-only file that apps never read, while msmtp needs its config readable by every user that sends.

Why does my app's mail get rejected because it comes from root?

Cron jobs and many apps build the sender from the local user and hostname, producing something like root@srv1.localdomain. That is not an address you verified at the relay, so the relay refuses the message with a 553 or 554 reply naming the sender address. Fix it at host level rather than in each app: set_from_header on together with a from address in /etc/msmtprc, or sender_canonical_maps with sender_canonical_classes = envelope_sender, header_sender in Postfix. Set Reply-To inside each app if replies need to reach a person.

Does a separate subdomain for app mail really protect my main domain?

Partly, and it is still worth doing. Receivers track reputation per domain, so complaints against notify.example.com largely stay with notify.example.com while your main domain keeps delivering. The limit is real: some receivers roll subdomain signals up to the organisational domain, and a DMARC policy published at the organisational level applies to subdomains unless you set sp= separately. Treat the subdomain as damage limitation rather than a guarantee.