SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor · Updated 2026-08-19

Self-host a disposable email inbox on a VPS

Catch every test email on a throwaway domain with Mailpit, read it in a web UI, and stop your staging app from ever mailing a real customer again.

What a disposable email inbox is

A disposable email inbox is a small SMTP (simple mail transfer protocol) server that accepts mail for every address and delivers none of it. Your staging application sends to it instead of to a real mail provider, and every message stops there. You read what arrived in a web interface, so a wrong recipient list or a broken template costs you nothing, because the mail never leaves the box.

This guide builds one on a single VPS with Docker Compose. Mailpit is the catch-all sink. Its SMTP listener is bound where only your application can reach it, its web interface sits behind nginx with transport layer security (TLS) and a password, and a retention limit keeps the mailbox from filling the disk. If Compose itself is new to you, the Compose basics for a VPS covers the file layout this guide assumes.

The result is a test tool, not a mail server. It has no accounts, no delivery, and no spam filtering. Real mailboxes for real people are a full mail server such as Mailcow and a much larger job.

Mailpit vs Inbucket vs MailHog: which sink to run

Three tools do this job. What separates them is maintenance status, the ports they listen on, and what they can do with a message once they accept it. Versions below were checked in August 2026.

MailHog (mailhog/mailhog) listens on 1025 for SMTP and serves its interface on 8025. It still works. Its default branch has had no commit since August 2022 and the tracker holds more than 250 open issues, so you would be running unpatched dependencies in your test path. Do not start new work on it.

Inbucket (inbucket/inbucket) listens on 2500 for SMTP, 9000 for the web interface, and 1100 for POP3 (post office protocol version 3). Version 3.1.1 shipped in December 2025. It stores messages as files under /storage and prunes them on its own: the image sets INBUCKET_STORAGE_RETENTIONPERIOD=72h and INBUCKET_STORAGE_MAILBOXMSGCAP=300. Choose it when a test needs to collect mail with a POP3 client library instead of an HTTP call.

Mailpit (axllent/mailpit) uses the same ports as MailHog, 1025 and 8025, so it replaces MailHog without touching application config. Version 1.30.7 was released on 8 August 2026. It carries what this guide needs inside the binary: a password file for the web interface and the API (application programming interface), a message cap, an age cap, and a recipient filter. The rest of this guide runs Mailpit.

How the catch-all works, and why DNS is not involved

Your application does not look up where to deliver here. You hand it a host and a port, it opens a TCP connection, and it announces RCPT TO:<anyone@example.test>. Mailpit accepts that recipient whatever it says, stores the message, and forwards nothing. The domain is never resolved, so example.test works even though .test is a reserved name that exists nowhere in the domain name system (DNS).

That is the whole mechanism, and it is why the inbox is safe by default. No MX (mail exchanger) record is involved, no delivery is attempted, and no message can reach a real person.

Point your staging app at the sink

Set the application's SMTP host to mailpit when the app runs as a container in the same Compose project, or to 127.0.0.1 when it runs on the host. Set the port to 1025, turn TLS off, and leave the username and password empty. Mailpit accepts anonymous mail.

Some frameworks refuse to send without credentials. MP_SMTP_AUTH_ACCEPT_ANY=1 makes Mailpit accept any username and password, and MP_SMTP_AUTH_ALLOW_INSECURE=1 permits the PLAIN and LOGIN mechanisms on an unencrypted connection. Those two settings are safe here only because the listener is unreachable from the internet, which the deployment below enforces.

MP_SMTP_ALLOWED_RECIPIENTS is worth setting on day one. It takes a regular expression and rejects every recipient that does not match it. Point it at your test domain, and a staging database that still holds a real customer address produces a visible failure in your application log instead of a message that quietly lands in the sink.

The Docker Compose file

Create the directory and a password file for the web interface first. htpasswd -B writes a bcrypt hash, and Mailpit reads bcrypt as well as plain text.

mkdir -p ~/mailpit/data
cd ~/mailpit
sudo apt update && sudo apt install -y apache2-utils
htpasswd -B -c data/ui-auth qa

Write compose.yaml:

services:
  mailpit:
    image: axllent/mailpit:v1.30
    container_name: mailpit
    restart: unless-stopped
    ports:
      - "127.0.0.1:8025:8025"
      - "127.0.0.1:1025:1025"
    volumes:
      - ./data:/data
    environment:
      MP_DATABASE: /data/mailpit.db
      MP_MAX_MESSAGES: 2000
      MP_MAX_AGE: 14d
      MP_UI_AUTH_FILE: /data/ui-auth
      MP_SMTP_AUTH_ACCEPT_ANY: 1
      MP_SMTP_AUTH_ALLOW_INSECURE: 1
      MP_SMTP_ALLOWED_RECIPIENTS: '@example\.test$$'

The doubled dollar sign is not a typo. Compose reads a single $ as the start of a variable to expand, so $$ is how you pass one literal dollar through to the container. The regex reaches Mailpit as @example\.test$.

Start it and check the health state:

docker compose up -d
docker compose ps

The STATUS column should read Up ... (healthy). The image ships its own healthcheck that runs /mailpit readyz every 15 seconds, so a container that stays at starting or turns unhealthy is not serving on 8025 inside the container. Read docker compose logs mailpit before changing anything else.

Both published ports carry an address, and that address is the security control. Inside the container Mailpit listens on 0.0.0.0, which is fine, because the container has its own network namespace. The left side of the mapping decides who reaches it from outside. Write 8025:8025 and Docker binds every address on the host, including the public one.

If your staging app is a service in this same file, delete the 1025 mapping completely and point the app at the hostname mailpit on port 1025. Containers on a shared Compose network reach each other directly, so the SMTP port never touches the host at all. How Compose networks resolve service names covers that lookup.

Send one message and check it landed

python3 - <<'EOF'
import smtplib
from email.message import EmailMessage

m = EmailMessage()
m["From"] = "staging@example.test"
m["To"] = "anyone@example.test"
m["Subject"] = "Mailpit smoke test"
m.set_content("If this appears in the web interface, the sink works.")
with smtplib.SMTP("127.0.0.1", 1025) as s:
    s.send_message(m)
EOF

The script prints nothing when it succeeds. Confirm the message is stored through the API:

curl -s -u qa:yourpassword http://127.0.0.1:8025/api/v1/messages

That returns JSON listing the stored messages. Drop the -u flag and the same request is refused, because MP_UI_AUTH_FILE protects the API and the web interface together. Any test that reads the inbox has to send those credentials too.

A ConnectionRefusedError from the Python script means nothing is listening on 127.0.0.1:1025. That is the expected result if you removed the SMTP mapping, and the check then has to run from a container on the same Compose network.

Publish the web interface through nginx with a password

The interface currently answers only on the loopback address. nginx terminates TLS and asks for a password before anything reaches it.

sudo htpasswd -B -c /etc/nginx/mailpit.htpasswd qa
server {
    listen 443 ssl;
    server_name mail-test.example.com;

    ssl_certificate     /etc/letsencrypt/live/mail-test.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mail-test.example.com/privkey.pem;

    auth_basic           "mailpit";
    auth_basic_user_file /etc/nginx/mailpit.htpasswd;

    location / {
        proxy_pass http://127.0.0.1:8025;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Reload after a syntax check with sudo nginx -t && sudo systemctl reload nginx. What each directive in a reverse proxy block does is worth reading once if this is your first proxy.

Use the same username and password in the nginx file and in data/ui-auth. nginx forwards the browser's Authorization header upstream, so matching credentials satisfy both checks with a single prompt. Different credentials leave the browser holding one set that the second check rejects.

The Upgrade and Connection headers are not decoration. Mailpit pushes new mail to an open page over a WebSocket, and a proxy running HTTP/1.1 without those headers cannot upgrade the connection. The page then loads correctly and never changes: mail arrives, the API shows it, the list sits still until you reload.

Keep both locks. The nginx password guards the public address, and MP_UI_AUTH_FILE guards port 8025 itself, which matters because every password reset link your staging app has ever generated is readable in that interface.

Never let the sink become an open relay

An open relay is an SMTP server that takes a message from anyone and forwards it to any destination. Spammers scan for them constantly, and finding one on your address ends in abuse reports and a suspended account.

Mailpit is not an open relay out of the box, because it never forwards. Relaying stays off until you point MP_SMTP_RELAY_CONFIG at a relay configuration file, and the release action in the interface does nothing until you do. Leaving that unset is a deliberate choice.

There are two ways to lose that property. Configure a relay so the release button works and then expose the SMTP port to the internet, and you have built a working open relay. Expose the port with no relay, and strangers cannot send mail through you, but they can fill your storage and put content into the interface your team trusts.

The trap on a Docker host is the firewall. Publishing a port makes Docker write its own rules into the nat table, and traffic destined for the container is matched there before ufw (uncomplicated firewall) rules get a say. sudo ufw deny 1025/tcp reports success and changes nothing. Why Docker publishes ports straight past ufw walks through the chain order.

The fix is the address in the mapping, not a firewall rule. Check what is actually bound:

sudo ss -ltnp | grep -E ':(1025|8025)'

Healthy output shows 127.0.0.1:1025 and 127.0.0.1:8025. A line reading 0.0.0.0:1025 means the mapping lost its address and the sink is listening to the internet. From another machine, nc -vz mail-test.example.com 1025 should time out or be refused.

When the application lives on a different server, do not open 1025 to bridge the two. Put both machines on a private network or a VPN tunnel, and bind the mapping to that interface address.

Only publish MX records if you want real inbound mail

An MX (mail exchanger) record tells other mail servers which host accepts mail for a domain. With no MX record on your throwaway domain, no mail from the internet can arrive, because sending servers have nowhere to deliver it. The inbox holds only what your own applications submitted, which is what a test mailbox is for.

Receiving real mail means an MX record pointing at the box, Mailpit listening on port 25 (MP_SMTP_BIND_ADDR=0.0.0.0:25), and that port open. At that moment you are running a public catch-all for every address at the domain. Be clear about what follows.

  • Spam starts within days of the record appearing, because harvesters read DNS. Dictionary attacks then walk through common names and store a message for every attempt.
  • Attachments from strangers land on your disk and stay there. Nothing filters them, so an archive from an unknown sender sits beside your own test mail.
  • Anyone who learns the domain can sign up to third party services with an address at it, and the confirmation mail is delivered to your server. If the password gate ever slips, those accounts belong to whoever is reading the inbox.
  • Retention limits stop being housekeeping and become load bearing, because the volume is no longer yours to control.

If you need real inbound mail for a deliverability check, give it a dedicated subdomain, keep MP_MAX_AGE short, and treat everything in it as public. If you need mailboxes that people rely on, run a real mail server with filtering and backups instead.

Retention: how an unbounded catch-all fills the disk

Mailpit keeps 500 messages by default and periodically deletes the oldest beyond that. MP_MAX_MESSAGES: 0 turns automatic deletion off completely, and that one change is how a catch-all fills a disk with nobody noticing. MP_MAX_AGE adds a time limit and takes hours or days, written as 36h or 14d.

MP_DATABASE decides whether any of this survives. Without it, Mailpit writes to a temporary file that is deleted when the process exits, so every restart empties the inbox. With it, the mail survives restarts and the file grows.

Attachments are what consume the space. A nightly job that mails a 2 MB PDF report to 300 test addresses is 600 MB per night, and a message count cap alone will not react in time. Budget that growth against whatever else shares the volume, because a media heavy neighbour such as PhotoPrism or Immich will already have claimed most of a small VPS disk.

du -h ~/mailpit/data/mailpit.db
df -h /

Empty the store between CI runs rather than waiting for a cap to trigger:

curl -s -u qa:yourpassword -X DELETE http://127.0.0.1:8025/api/v1/messages

Inbucket handles the same problem with INBUCKET_STORAGE_RETENTIONPERIOD (72h in the image) and INBUCKET_STORAGE_MAILBOXMSGCAP (300). Whichever you run, pick the limit before the first test suite points at it.

Reading the inbox from your test suite

GET /api/v1/messages lists what is stored, GET /api/v1/message/{ID} returns one message with its parts and headers, GET /api/v1/search filters, and DELETE /api/v1/messages clears the store. Interactive documentation for the version you are running is served at http://127.0.0.1:8025/api/v1/.

A useful test sends a message, polls until it appears, checks the subject and the link inside it, then deletes everything. Poll in a short retry loop rather than with a single request, because an application that queues mail in a background worker returns from its send call before Mailpit has the message. The same pattern shows up in self-hosted API testing and mocking tools, which is usually the other half of a staging environment that never touches production.

FAQ

Is a self-hosted disposable email inbox an open relay?

Not while relaying stays off. Mailpit stores messages and never forwards them until you point MP_SMTP_RELAY_CONFIG at a relay configuration, so a stranger who reaches port 1025 cannot send mail through your server. They can still fill your storage, so bind the SMTP port to an address only your application can reach. Publishing it as 1025:1025 in Compose binds every host address, and sudo ufw deny 1025/tcp will not close it, because Docker's own nat rules are matched first.

Do I need an MX record for my test domain?

Only if you want mail from the internet to arrive. Without an MX record, sending servers have nowhere to deliver, so the inbox holds only what your own applications submit over SMTP. Publish the record and open port 25, and you are running a public catch-all: spam within days, dictionary attacks that store a message per attempt, and attachments from strangers on your disk with no filtering.

Why does the message list only update when I reload the page?

Mailpit pushes new mail to an open page over a WebSocket. An nginx location block missing proxy_http_version 1.1 and the Upgrade and Connection headers cannot upgrade that connection, so the page loads normally and then freezes. Mail still arrives and the API still returns it, which is why the inbox looks stale rather than broken. Add those lines, reload nginx, then reload the page.

How do I stop the inbox filling the disk?

Keep MP_MAX_MESSAGES at a real number and add MP_MAX_AGE. The default cap is 500 messages, and setting it to 0 disables deletion entirely, which is how a catch-all with attachments grows quietly. MP_MAX_AGE accepts hours or days, such as 36h or 14d. Clear the store in CI teardown with curl -X DELETE http://127.0.0.1:8025/api/v1/messages. Inbucket does the same job with INBUCKET_STORAGE_RETENTIONPERIOD (72h) and INBUCKET_STORAGE_MAILBOXMSGCAP (300).

Should I run Mailpit, Inbucket or MailHog?

Mailpit for new work, as of August 2026. MailHog still runs, but its default branch has had no commit since August 2022, so it ships unpatched dependencies. Inbucket is actively maintained (3.1.1, December 2025) and is the better pick when a test needs POP3, since Mailpit's POP3 server only starts once you give it a password file. Mailpit uses the same ports as MailHog, 1025 and 8025, so replacing MailHog costs one image name in your Compose file.