Receive-only mail in Docker: Postfix, Roundcube and a contact form, with no way to send
▶Prefer to watch? This short video runs this page's main commands in a real lab, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
Receive-Only Mail Server in Docker: A Step-by-Step Guide
docker compose up -d (and then never, ever let it send a single email)
Sometimes you want an inbox, not a mail server: a contact form, a "security@" address, a place for alerts to land. You do NOT want to run
an outbound mail system, because outbound is where the hard problems live (reputation, blocklists, DKIM, reverse DNS, being used as a spam relay).
This guide builds a mail server that can only RECEIVE. It has one mailbox, a web interface to read it, and no way to send anything to the outside world,
even if someone breaks in. You reply from your normal Gmail/Yahoo/Outlook account.
It uses docker-mailserver (Postfix + Dovecot + Rspamd) and Roundcube webmail, and it is the same design that runs behind the TechCommune contact form.
Version numbers below were current in September 2026 (docker-mailserver 16.0.1, Roundcube 1.7.4): pin versions, and read release notes before bumping.
What you will build
Internet --TCP 25--> router --> Docker host:25 --> [mail: Postfix, Dovecot, Rspamd] the ONLY published port
Browser --HTTPS--> nginx (password) --> [webmail: Roundcube] --IMAP--> mail reached only through the host's nginx
Website form --> nginx --> [small handler] --SMTP--> mail optional: a contact form that drops mail into the inbox
The security ideas (everything else is detail)
1. Receive only. Postfix is given no route to the internet. Anything it tries to send just fails locally.
2. Only port 25 is published. Webmail and any helpers are reachable only through your existing reverse proxy.
3. The helper containers sit on a Docker network marked internal, so they cannot reach the internet at all.
4. Only local recipients are accepted. Unknown users are refused at RCPT time (during the SMTP conversation), so you never send a bounce to a forged sender ("backscatter").
5. The webmail is behind two passwords (nginx basic auth, then the mailbox login). Roundcube has had a critical login-time hole (CVE-2025-49113, fixed in 1.6.11), so keep it patched and never expose it bare.
Before you start (the boring but essential checks)
* A domain whose DNS you control (any registrar: GoDaddy, Cloudflare, Namecheap all support MX records).
* A public IP that does not change (or dynamic DNS). Many home ISPs BLOCK inbound port 25: test first (below), or the rest is wasted effort.
* Docker Engine and the compose plugin. A small VM or an old mini PC is plenty (about 1 GB RAM without antivirus).
* A reverse proxy with TLS on the same host (nginx here) and certbot for certificates.
Step 1: DNS
Type Name Value Notes
A mail your public IP the mail host name
MX @ mail.example.com (pri 10) "deliver mail for example.com to this host"
TXT @ v=spf1 -all "this domain never sends mail" (edit an existing SPF record, never add a second one)
TXT _dmarc v=DMARC1; p=reject forged mail claiming to be you gets rejected by receivers that check
A webmail your public IP the web interface
You do NOT need a PTR (reverse DNS) or DKIM record: those matter only for sending. Remove any old "email forwarding" MX records your registrar added.
Check with: dig +short MX example.com and dig +short TXT example.com
Step 2: Port forward and the port 25 test
Forward external TCP 25 to the Docker host on your router. Once the stack runs (step 4), test from OUTSIDE your network (phone on mobile data, or a friend):
nc -v YOUR.PUBLIC.IP 25
You should see 220 mail.example.com ESMTP. Nothing at all usually means the router forward is wrong or your ISP blocks 25. Some ISPs will unblock it on request.
Step 3: The compose file (the important parts)
Networks first, because that is what makes it receive-only:
networks:
frontend: {} # ordinary network: only the mail container joins it, to publish port 25
backend:
internal: true # NO route to the internet
ipam:
config: [ { subnet: 172.31.240.0/24 } ]
The mail container:
services:
mail:
image: mailserver/docker-mailserver:16.0.1
hostname: mail.example.com
ports:
- "0.0.0.0:25:25" # the only published port. IPv4 only on purpose: it keeps real client addresses in the logs
volumes:
- ./data/dms/mail-data:/var/mail
- ./data/dms/mail-state:/var/mail-state
- ./data/dms/mail-logs:/var/log/mail
- ./config/dms:/tmp/docker-mailserver
- /etc/letsencrypt:/etc/letsencrypt:ro
environment:
- SSL_TYPE=letsencrypt
- ENABLE_RSPAMD=1 # spam filtering
- ENABLE_CLAMAV=0 # antivirus: needs 1.5 GB+ RAM; skip on a small box
- ENABLE_AMAVIS=0 # the older filter, not needed with Rspamd
- ENABLE_OPENDKIM=0 # signing is for sending
- ENABLE_OPENDMARC=0
- ENABLE_POLICYD_SPF=0
- ENABLE_POP3=0
- ENABLE_FAIL2BAN=0
- PERMIT_DOCKER=none # never treat Docker networks as trusted senders (open relay risk)
- POSTFIX_MESSAGE_SIZE_LIMIT=5242880
networks:
frontend: {}
backend: { ipv4_address: 172.31.240.10 }
Roundcube on the internal network only, with a fixed IP so the host's nginx can reach it:
webmail:
image: roundcube/roundcubemail:1.7.4-apache-nonroot # listens on port 8000, runs as www-data
environment:
- ROUNDCUBEMAIL_DB_TYPE=sqlite
- ROUNDCUBEMAIL_DB_DIR=/var/roundcube/db
- ROUNDCUBEMAIL_DEFAULT_HOST=tls://mail # IMAP with STARTTLS over the internal network
- ROUNDCUBEMAIL_DEFAULT_PORT=143
- ROUNDCUBEMAIL_SMTP_SERVER= # empty: nothing to send with
- ROUNDCUBEMAIL_USERNAME_DOMAIN=example.com
networks:
backend: { ipv4_address: 172.31.240.20 }
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
The SQLite volume belongs to root, but this image runs as www-data (uid 33). Add a tiny one-shot service that runs "chown -R 33:33 /db" on the volume
and make webmail depend on it with condition: service_completed_successfully, or Roundcube fails with "unable to open database file".
A few Roundcube settings go in a file mounted as /var/roundcube/config/custom.inc.php:
<?php
$config['imap_conn_options'] = ['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]]; // internal hop, name is just "mail"
$config['session_lifetime'] = 30;
$config['ip_check'] = true;
$config['show_images'] = 0;
$config['plugins'] = [];
$config['disabled_actions'] = ['mail.compose', 'mail.send']; // nothing can be sent, so hide the button
Step 4: Make Postfix truly receive-only
Put these in config/dms/postfix-main.cf (docker-mailserver appends the file to Postfix's main.cf):
default_transport = error:this server is receive-only and does not send mail
relay_transport = error:this server is receive-only and does not send mail
mynetworks = 127.0.0.0/8 [::1]/128 172.31.240.0/24
smtpd_relay_restrictions = reject_unauth_destination
smtpd_helo_required = yes
disable_vrfy_command = yes
smtpd_client_connection_rate_limit = 30
smtpd_client_message_rate_limit = 30
What each does: default_transport and relay_transport are where Postfix sends mail that is NOT for a local mailbox; pointing them at "error:" turns every
outbound attempt into an immediate failure. mynetworks lists who counts as local (keep it tiny). reject_unauth_destination means "only accept mail for my own domains".
The rest are courtesy limits against noisy clients.
Step 5: Certificates, start, mailbox
Get certificates with certbot's webroot method (nginx must answer /.well-known/acme-challenge/ for mail.example.com and webmail.example.com):
sudo certbot certonly --webroot -w /var/www/html -d mail.example.com
sudo certbot certonly --webroot -w /var/www/html -d webmail.example.com
Start it and create the single mailbox (within about two minutes of first start: docker-mailserver restarts itself if there is no account):
docker compose up -d
docker compose exec mail setup email add contact@example.com 'a-long-password'
docker compose exec mail setup alias add postmaster@example.com contact@example.com
docker compose exec mail setup email list
Add a certbot deploy hook that restarts the mail container after renewals, or the mail server will keep serving the old certificate:
/etc/letsencrypt/renewal-hooks/deploy/restart-mail.sh -> cd /path/to/stack && docker compose restart mail
Step 6: nginx in front of the webmail
Create a password file (openssl gives you an apr1 hash that nginx understands):
printf 'admin:%s\n' "$(openssl passwd -apr1 'a-long-gate-password')" | sudo tee /etc/nginx/webmail.htpasswd
server {
listen 443 ssl;
server_name webmail.example.com;
ssl_certificate /etc/letsencrypt/live/webmail.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/webmail.example.com/privkey.pem;
auth_basic "Private";
auth_basic_user_file /etc/nginx/webmail.htpasswd;
limit_req zone=webmail_req burst=30 nodelay; # define the zone in http{}: limit_req_zone $binary_remote_addr zone=webmail_req:10m rate=5r/s;
location / {
proxy_pass http://172.31.240.20:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Authorization ""; # do not hand your gate password to Roundcube
}
}
Yes, a host process can reach a container on an internal network: internal only blocks traffic to and from the outside world, and the host is not the outside world.
Step 7: Prove it (do not skip this)
Trust, but verify. Every claim above can be tested:
1. Mail arrives. Send yourself a message from Gmail. It should appear in webmail within seconds.
2. Unknown user. swaks or telnet to port 25 with RCPT TO:<nobody@example.com>: expect 550 (rejected during the conversation, no bounce later).
3. No relaying. RCPT TO:<someone@gmail.com>: expect a refusal (454/554 relay access denied).
4. No egress. docker compose exec webmail curl -m5 https://example.com must fail. Same for any helper container.
5. Only port 25. docker ps --format '{{.Names}} {{.Ports}}' shows exactly one published port.
6. Spam test. Send the standard GTUBE test line (a fake spam string that all filters must reject): it should be refused with "Gtube pattern".
7. Real client IPs. docker compose logs mail | grep smtpd should show public addresses, not 172.x. If you only see the Docker gateway, published ports are being proxied
(check that Docker's iptables handling has not been disabled, and that you publish an IPv4 address).
8. Web gate. curl -I https://webmail.example.com/ must return 401 without the password.
Optional: a contact form that lands in the inbox
A static website cannot send mail by itself. Add a small container on the INTERNAL network that accepts a form post, checks it, and hands one message to
mail:25 as a trusted internal client. Rules worth having: a hard length limit enforced on the server, honeypot field, signed timestamp token (bots submit instantly),
per-IP and global rate limits, Reply-To set from the form, and never echo user input back unescaped. Build messages with a real mail library (Python's EmailMessage
refuses header injection: a "name" field containing a newline cannot smuggle in a Bcc). Let nginx add limit_req, a 4 KB body cap and a POST-only rule.
Tell Rspamd that the internal subnet is local (config/dms/rspamd/override.d/options.inc: local_addrs = ["127.0.0.0/8", "::1", "172.31.240.0/24"];)
so form messages are not scored as spam, while everything from the internet is scanned.
Running it
Logs docker compose logs -f mail
Update docker compose pull && docker compose up -d (monthly; read the release notes before changing a pinned tag)
Backup tar czf mail-backup.tar.gz data/dms/mail-data config
Reset password docker compose exec mail setup email update contact@example.com
Harmless noise "sedfile ERROR" lines at container start are normal.
Spam in Junk Rspamd files borderline mail into the Junk folder: glance at it now and then.
Troubleshooting
Nothing arrives dig MX example.com, then nc -v your-ip 25 from outside. No banner = router or ISP. Banner but no mail = check docker compose logs mail.
Roundcube "DB Error" The volume ownership problem in step 3: the chown one-shot did not run.
Restarts every 2 minutes docker-mailserver restarts when no account exists: run setup email add.
Certificate errors The deploy hook did not restart the mail container, or the certificate name does not match the container hostname.
Rejected with "Helo command rejected" / test mail from example.org bounces: example.org publishes a "null MX" and many receivers refuse mail from domains that do not accept mail: test with a real sender domain.
Test from your own LAN is scored as spam Rspamd skips scanning for private addresses by default: use a test-only local_addrs override, remove it afterwards.
What this deliberately does not do
No sending, no DKIM signing, no antivirus, no fail2ban, no IMAP exposed to the internet. Each is a sensible addition if your needs grow, but every one
widens the attack surface or the maintenance load, and an inbox that only receives is already a very small target.
Tips
Keep it boring: fewer features means fewer things to patch. Pin image versions and update on a schedule.
Alert on the important thing: a container listening on a port other than 25 is a bug in your compose file, not a feature.
Test the receive-only promise after every change to the compose file. Ten minutes of curl beats one surprise in your logs.