Skip to content

Mailpit

Mailpit is an SMTP server that delivers nothing. A stack sends its mail to it, the message is kept, and you read it in a web UI — the verification link, the password reset, the booking confirmation. One container, one static binary.

It earns its place the moment you try a stack that sends mail. With no relay configured, an application either drops every message without a word or refuses to start until it has SMTP credentials. The sink satisfies both: it accepts any login, and what the application would have sent becomes readable.

Last run on a host 8 Nov 2026

Pinned at v1.33.1 (released 7 Nov 2026) · the newest upstream release

To try it: Docker, nothing else. See Try it locally.

To deploy it behind the proxy: Traefik running and a domain.

On your own machine, with no proxy or domain. From apps/mailpit/:

Terminal window
cp .env.local.example .env.local
docker compose -f docker-compose.local.yml --env-file .env.local up -d

The UI listens on 127.0.0.1:8025, SMTP on 127.0.0.1:1025. Point anything on your machine at that port with any username and password, or send one message by hand:

Terminal window
python3 -c "import smtplib; s=smtplib.SMTP('127.0.0.1',1025); s.login('anything','goes'); s.sendmail('[email protected]',['[email protected]'],'Subject: probe\r\n\r\nhello')"

Open http://localhost:8025. Messages stay in memory here and are gone when the container stops.

For a deployment on a server. From apps/mailpit/:

  1. Copy the environment file and set APP_TRAEFIK_HOST and TZ:

    Terminal window
    cp .env.example .env
  2. Create the message store. The binary runs as USERMAP_UID, which defaults to 1000 — the account that runs this command, on most installations:

    Terminal window
    mkdir -p volumes/data
  3. Create the login for the UI and the API — a bcrypt htpasswd, one user per line. Store the printed password now; it is not shown again:

    Terminal window
    mkdir -p .secrets && chmod 700 .secrets
    MP_PW="$(openssl rand -base64 24)"; echo "$MP_PW"
    docker run --rm httpd:2.4-alpine htpasswd -Bbn ops "$MP_PW" > .secrets/mailpit_htpasswd.txt
    unset MP_PW
    chmod 600 .secrets/mailpit_htpasswd.txt

    Without the file the start stops with bind source path does not exist.

  4. Start it:

    Terminal window
    docker compose up -d
  5. Open https://<APP_TRAEFIK_HOST> and log in as ops. There is no setup page and no account inside Mailpit. The router ships access-vpn, so the page answers on the VPN and nowhere else.

Terminal window
docker compose ps # one service, healthy

Then send one message the way a stack would — from a container on the proxy network, with a login that does not exist:

Terminal window
docker run --rm --network proxy-public python:3-alpine python -c "import smtplib; s=smtplib.SMTP('mailpit',1025,timeout=10); s.login('anything','goes'); s.sendmail('[email protected]',['[email protected]'],'Subject: probe\r\n\r\nhello'); print('sent')"

It appears in the UI within a second.

Each stack names its SMTP variables differently; the values are the same everywhere:

Setting Value
host mailpit
port 1025
username anything non-empty
password anything non-empty, in the stack’s .secrets/ file as usual
encryption none

The sending stack has to share the network: set TRAEFIK_NETWORK=proxy-public in its .env. On its own edge network, the examples’ default, mailpit does not resolve.

The port matters more than it looks. Many mail clients force STARTTLS on 587 and implicit TLS on 465, and this port offers neither — a stack that hard-codes 587 fails against it. Restart the stack, trigger a password reset, read it in the UI.

Everything in the inbox — reset links, verification links, invitations — is readable by whoever logs in to the UI. That is why it ships VPN-only behind the htpasswd login, and why the sink is for trying stacks out: a stack whose users are real people sends real reset links, and those belong in a mailbox.

Every container on the proxy network reaches the SMTP port, as a sink must be. The HTTP side is narrower: the API and the UI answer requests that carry the Traefik hostname or come from the healthcheck on localhost, and refuse a container calling http://mailpit:8025/ with 403. That is a Host-header check, so on its own it would stop only a casual read; the htpasswd login in MP_UI_AUTH_FILE is what holds against a container that reaches port 8025 directly.

Nothing leaves the machine. The version check upstream runs against the GitHub API is switched off; the link checker and screenshots in the UI fetch external resources only when you click them.

Nothing to back up. The store holds messages that were never meant to arrive anywhere. Delete volumes/data whenever it is in the way.

  1. Read the release notes.

  2. Raise APP_TAG in .env, then:

    Terminal window
    docker compose pull && docker compose up -d
  3. Send one message. There is no state to migrate.

A stack logs an SMTP authentication failure. It sent an empty username or password. Mailpit accepts any value, but the application’s own validation runs first — give it something.

A stack logs a TLS or STARTTLS error. It was pointed at port 587 or 465, or its client forces encryption regardless of port. Use 1025 where the port is a setting; where it is not, the stack needs a real relay.

The API answers 403 from another container. By design — see above. Read the inbox through the UI, or add the calling hostname to MP_ALLOWED_HOSTS.

Messages vanished. The store keeps the newest MP_MAX_MESSAGES and prunes the rest. In the local stack, they live in memory and end with the container.