Skip to content

Mailpit as a Dev SMTP Catch-All Behind Traefik

Testing email flows in development has two failure modes: you disable sending entirely and miss broken templates, or you accidentally send to real users. Mailpit is an SMTP catch-all. It accepts every outbound email your app sends and displays it in a Web UI, without forwarding anything anywhere. This post covers running Mailpit in Docker, routing its Web UI through Traefik on a dedicated port, adding login auth, persisting messages to disk, and wiring it into Django, FastAPI, Node.js, and Laravel apps. Assumed knowledge: Docker Compose basics, what SMTP is.

Prerequisites

  • Docker Engine 24+
  • Docker Compose v2
  • Traefik v3 running on infra_net with a mail-server entrypoint defined on port 8005

Versions used in this post

Mailpit latest · Traefik v3.2

Related posts

This post uses the infra_net network and Traefik setup from Self-Hosted Dev Infrastructure with Docker Compose. If you're running Mailpit standalone, define the mail-server entrypoint in traefik/traefik.yml first. The Mailpit labels have no effect until Traefik knows what mail-server is.


Background

Mailpit runs two services internally: an SMTP server on port 1025 and a Web UI on port 8025. Configure your app to send to mailpit:1025 and every email lands in Mailpit's inbox. Nothing leaves the server.

Mailpit replaced MailHog as the standard dev SMTP catcher. MailHog is unmaintained. Mailpit renders HTML emails fully, shows raw headers, displays attachments, and exposes a REST API for asserting email content in integration tests. Mailtrap provides similar functionality as a SaaS but adds a network dependency and a monthly cost.

An unauthenticated Web UI on a public VPS exposes real data

Port 8005 is reachable from anywhere unless you restrict it at the firewall. An unprotected Mailpit inbox exposes every email your app generates: password reset links, verification tokens, internal notifications. Unlike the SMTP endpoint, the Web UI has no single-use protection on the links it renders. Always set MP_UI_AUTH.


Implementation

1. Define the Traefik entrypoint

The mail-server entrypoint must exist in Traefik's static config before Mailpit's labels can reference it:

traefik/traefik.yml
entryPoints:
  mail-server:
    address: ":8005"

Add the port binding to the Traefik service:

docker-compose.yml
  traefik:
    ports:
      - "0.0.0.0:8005:8005"

2. Mailpit service

docker-compose.yml
  mailpit:
    image: axllent/mailpit:latest
    container_name: mailpit
    restart: unless-stopped
    ports:
      - "127.0.0.1:1025:1025"
    environment:
      MP_SMTP_AUTH_ACCEPT_ANY: 1
      MP_SMTP_AUTH_ALLOW_INSECURE: 1
      MP_UI_AUTH: ${MAILPIT_UI_AUTH}
      MP_LABEL: ${MAILPIT_LABEL:-Dev Mail}
      MP_DATABASE: /data/mailpit.db
    volumes:
      - mailpit_data:/data
    networks:
      - infra_net
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.mailpit.rule=PathPrefix(`/`)"
      - "traefik.http.routers.mailpit.entrypoints=mail-server"
      - "traefik.http.services.mailpit.loadbalancer.server.port=8025"
    healthcheck:
      test: ["CMD", "/mailpit", "readyz"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 10s

MP_SMTP_AUTH_ACCEPT_ANY: 1 tells Mailpit to accept any SMTP credentials. This is intentional: in development you want SMTP to succeed regardless of what credentials the app sends. MP_UI_AUTH applies only to the Web UI. The two auth mechanisms are independent of each other.

MP_DATABASE: /data/mailpit.db persists messages to SQLite on the named volume. Without this, messages live in memory and vanish on every container restart.

3. Environment variables

.env
# Space-separated "username:password" pairs
MAILPIT_UI_AUTH=admin:strongpassword dev:anotherpassword
MAILPIT_LABEL=Dev Mail

MP_UI_AUTH accepts multiple space-separated username:password pairs. All users share the same inbox. For most team setups, one shared account is sufficient.

To cap message history and prevent unbounded database growth:

.env
MP_MAX_MESSAGES=500

Without MP_MAX_MESSAGES, the SQLite file grows indefinitely. 500 messages is a reasonable default for a dev stack. Raise it if integration tests require longer email history.

4. Wiring your app

Any container on infra_net reaches Mailpit at mailpit:1025. No credentials required.

Django:

settings.py
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "mailpit"
EMAIL_PORT = 1025
EMAIL_USE_TLS = False
EMAIL_USE_SSL = False

FastAPI with fastapi-mail:

app/config.py
from fastapi_mail import ConnectionConfig

conf = ConnectionConfig(
    MAIL_SERVER="mailpit",
    MAIL_PORT=1025,
    MAIL_STARTTLS=False,
    MAIL_SSL_TLS=False,
    MAIL_USERNAME="any",
    MAIL_PASSWORD="any",
    MAIL_FROM="noreply@example.com",
)

Node.js with Nodemailer:

mailer.js
const transporter = nodemailer.createTransport({
  host: "mailpit",
  port: 1025,
  secure: false,
  ignoreTLS: true,
});

Laravel:

.env (Laravel)
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null

5. Using the Mailpit API in integration tests

Mailpit exposes a REST API at /api/v1/messages that returns received messages as JSON. Use it to assert email content without scraping the Web UI:

tests/test_email.py
import httpx

def get_latest_email(to_address: str) -> dict:
    resp = httpx.get("http://mailpit:8025/api/v1/messages")
    resp.raise_for_status()
    messages = resp.json()["messages"]
    for msg in messages:
        if any(r["Address"] == to_address for r in msg["To"]):
            return msg
    raise AssertionError(f"No email found for {to_address}")


def test_welcome_email_sent(client):
    client.post("/register", json={"email": "user@example.com"})
    email = get_latest_email("user@example.com")
    assert "Welcome" in email["Subject"]

Testing and Verification

Start the container:

terminal
docker compose up -d mailpit

Send a test message using swaks:

terminal
swaks --to test@example.com \
      --from sender@example.com \
      --server localhost:1025 \
      --body "Hello from the terminal"
# -> 250 OK

Open http://your-server:8005 and log in. The email should appear immediately.

Confirm the container is healthy:

terminal
docker inspect mailpit --format='{{.State.Health.Status}}'
# healthy

Verify Traefik has registered the route:

terminal
curl -s http://localhost:8080/api/http/routers | jq '.[] | select(.name == "mailpit@docker") | .rule'
# "PathPrefix(`/`)"

Pitfalls

Binding SMTP port 1025 to 0.0.0.0

Containers reach Mailpit over infra_net using mailpit:1025. No host port binding is needed for that. The 127.0.0.1:1025:1025 binding exists only for running swaks or similar tools from your local machine. Binding to 0.0.0.0 opens your server as a relay endpoint to anyone on the internet.

MP_DATA_FILE is a deprecated variable name

Older Mailpit releases used MP_DATA_FILE for the database path. Current releases use MP_DATABASE. Using the old name silently falls back to in-memory storage: messages appear to save but vanish on restart. Check the Mailpit docs if you're unsure which variable your version supports.

Selective delivery in staging

Staging environments sometimes need to catch most emails in Mailpit but deliver to specific QA inboxes for real. MP_SMTP_REJECT_RECIPIENTS lets you reject specific addresses, forcing them through to a real SMTP relay. Use this to deliver to your QA team's actual inboxes while still catching everything else.


Production Considerations

The Mailpit API at /api/v1/messages is unauthenticated. The MP_UI_AUTH credentials protect the Web UI, not the API path. If integration tests call the API, ensure port 8025 is not publicly reachable. Traefik routes traffic through 8005 to the Web UI, but the raw internal port is only protected by Docker network isolation.

For staging environments that actually deliver email, swap mailpit:1025 for a transactional provider's SMTP endpoint (Resend, Postmark, SendGrid). The app config change is identical. The two can coexist via environment variable: SMTP_HOST=mailpit in dev, SMTP_HOST=smtp.resend.com in staging.

Rotate MAILPIT_UI_AUTH passwords if the .env file is shared across more than one person. They are stored in plaintext and grant access to the full shared inbox.


Wrapping Up

Mailpit gives you a real SMTP server and a full Web UI in a single container. Behind Traefik with auth, persistence, and a message cap, it is a complete email testing layer with no SaaS dependency and no risk of accidental delivery. The REST API makes it testable, not just observable.

The next step is locking down the network surface around the whole stack:


Comments