Send Email from a Docker Container or VPS When Port 25 Is Blocked
Cloud hosts block outbound port 25, so mail from a VPS or container never leaves. Use SMTP on 587 or the HTTPS API: msmtp for cron, curl for containers.
The classic way to send mail from a Linux box is to let it talk SMTP on port 25 straight to the recipient's server. On a modern VPS that no longer works: Google Cloud blocks outbound 25 outright, AWS throttles it until you file a request, and DigitalOcean, Hetzner and Azure block it on new accounts. Even where it is open, mail from a fresh cloud IP with no reputation lands in spam. The fix is not to fight for port 25. Hand the message to a mail service you authenticate to, over port 587 or HTTPS, and let it deliver. Here is how to do that from cron, from a script and from inside a container.
Quick answer
Create a mailbox on your domain for the server, for example alerts@yourdomain.com. For cron and shell scripts, install msmtp and point it at mail.oqumail.com on port 587 with STARTTLS and the mailbox login. For containers and application code, skip SMTP and POST to the HTTPS send API with an API key from the same mailbox. Both leave the server on ports that are never blocked.
First, confirm what is blocked
Run these from the server or from inside the container. A timeout on 25 and a quick answer on 587 is the usual picture:
# port 25: expect a timeout on most cloud hosts
nc -zv -w 5 smtp.gmail.com 25
# port 587 to the relay: expect "succeeded"
nc -zv -w 5 mail.oqumail.com 587
# full STARTTLS handshake; type EHLO test after it connects, then QUIT
openssl s_client -starttls smtp -crlf -connect mail.oqumail.com:587Option 1: msmtp for cron and shell scripts
msmtp is a tiny SMTP client that speaks only to a relay. The msmtp-mta package also installs it as /usr/sbin/sendmail, so cron's MAILTO and anything that shells out to sendmail starts working with no other change:
# Debian / Ubuntu
sudo apt-get install -y msmtp msmtp-mta
# /etc/msmtprc (chmod 600, owned by root)
defaults
auth on
tls on
tls_starttls on
tls_trust_file /etc/ssl/certs/ca-certificates.crt
logfile /var/log/msmtp.log
account oqumail
host mail.oqumail.com
port 587
from alerts@yourdomain.com
user alerts@yourdomain.com
passwordeval "cat /etc/msmtp.pass"
account default : oqumail
# test it
printf 'Subject: test from %s\n\nhello\n' "$(hostname -f)" | msmtp ops@yourdomain.com
# crontab: every job's output now arrives by email
MAILTO=ops@yourdomain.com
0 3 * * * /usr/local/bin/backup.shOption 2: the HTTPS API from a container
Inside a container you usually do not want a mail client at all. Pass the API key in as an environment variable from an env file or a Docker secret, never baked into the image, and call the API with whatever HTTP client the app already has. From a shell inside the container:
# docker-compose.yml
services:
app:
image: yourapp:latest
env_file: .env.production # contains OQUMAIL_API_KEY=oqm_live_...
# inside the container, or in an entrypoint / healthcheck script
curl -sS --fail -X POST https://api.oqumail.com/api/v1/emails \
-H "Authorization: Bearer $OQUMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": ["ops@yourdomain.com"],
"subject": "Container started on '"$(hostname)"'",
"text": "The app container came up and passed its healthcheck."
}'Free business email on your own domain
OquMail gives you up to 15 mailboxes on your domain — free — with guided SPF/DKIM/DMARC, webmail, IMAP/SMTP for any mail app, and a send API. Setup is a few DNS records; changes usually take effect within minutes, occasionally up to 48 hours. Start at oqumail.com.
The same send from application code
For anything beyond a one-liner, make the call from the app so failures are logged where you look. Python with requests, which most images already have:
import os, requests
def notify(subject, text, to="ops@yourdomain.com"):
r = requests.post(
"https://api.oqumail.com/api/v1/emails",
headers={"Authorization": "Bearer " + os.environ["OQUMAIL_API_KEY"]},
json={"to": [to], "subject": subject, "text": text},
timeout=10,
)
if r.status_code != 202:
raise RuntimeError(f"send failed: {r.status_code} {r.text}")
return r.json()["id"]Which option when
- Cron output, logwatch, unattended-upgrades, smartd and anything else that calls sendmail: msmtp. One config file and the whole box is covered.
- A containerised app, a sidecar, a Kubernetes CronJob: the API. No daemon, no mail client in the image, one secret.
- A legacy app that only knows SMTP: point it at the relay on 587 with the mailbox login. It thinks it has a local mail server.
- Thousands of messages a day: neither. That is bulk mail and belongs in a list tool; the free plan allows 250 sends a day per mailbox.
Pitfalls
- Installing Postfix and trying to deliver direct. Even with 25 open, a cloud IP with no history and no reverse DNS is filed as spam.
- Choosing SSL on port 587. The relay expects STARTTLS on 587; in msmtp that is tls on plus tls_starttls on.
- Outbound firewall rules. If you run ufw or a cloud firewall with an egress policy, allow 587 and 443; nothing here needs 25.
- A world-readable msmtprc or an env file committed to git. chmod 600 the config, keep the password in a separate file, and add .env.production to .gitignore.
- Sending from root@hostname or a made-up address. The relay only accepts mail from the mailbox you log in as, which is what keeps the domain DKIM-aligned.
- IPv6-only hosts. If the server has no IPv4 and the relay is unreachable, check the provider's NAT64 or add an IPv4 address; this is rare but confusing when it happens.
Common questions
Can I just ask the provider to open port 25?
AWS and Hetzner will usually open it on request, and Google Cloud will not. Even when they do, you are then running a mail server with all the reputation work that implies. Authenticated submission on 587 or the API is what the port was closed to push you towards, and it is less work.
Does msmtp handle attachments?
Not by itself; it sends what you pipe into it. For a report file, build the message with mutt (mutt -s "subject" -a report.pdf -- ops@yourdomain.com) configured to use msmtp as its sendmail, or base64 the file into the attachments array of an API call.
How do I know a message was delivered?
Every API send appears in the delivery log in the OquMail dashboard with the receiving server's response. For msmtp, the logfile records the relay's acceptance line per message, and the same delivery log shows what happened after that.