Skip to main content

Previewing Emails Locally with MailDev in Docker

Run MailDev as a Docker SMTP sink to capture outbound mail in local development, point Nodemailer, Django, or Rails at it, and inspect messages in a browser UI.

You are building a signup flow and you want to see the actual welcome email — rendered, with headers and source — without sending a real message to a real inbox or burning your ESP's sending reputation. MailDev is a tiny SMTP sink that captures everything your app tries to send and shows it in a browser. This guide wires it up in Docker.

The need: capture outbound SMTP, send nothing real

In local development every email your application emits is a liability if it leaves the machine: it can hit a real recipient, count against your provider's quota, trigger rate limits, or pollute deliverability metrics with test traffic. The fix is to make the SMTP endpoint a local trap. MailDev listens on an SMTP port, accepts any message your app hands it, stores it in memory, and renders it in a web UI — so you get the real rendered message, its raw MIME source, and every header, with zero egress.

This is the same interception strategy used across local email preview servers: the application's mailer is pointed at a local sink instead of a production relay, and nothing the app sends ever reaches the public internet.

MailDev capture flow The app sends over SMTP on port 1025 to MailDev, which captures the message and serves it on the web UI at port 1080 to a browser. Local Capture Path App mailer transport MailDev SMTP sink + store Browser UI view + source SMTP :1025 HTTP :1080 No message ever leaves the host — nothing reaches a real recipient or ESP.
Outbound SMTP on 1025 is trapped by MailDev and exposed for inspection on the web UI at 1080.

The Docker setup

Run MailDev as a Compose service. SMTP is on 1025, the web UI on 1080.

# docker-compose.yml
services:
  app:
    build: .
    depends_on:
      - maildev
    environment:
      # Point the application's mailer at the MailDev service by its Compose hostname.
      SMTP_HOST: maildev
      SMTP_PORT: "1025"
      SMTP_SECURE: "false"   # plaintext: MailDev has no TLS on the capture port

  maildev:
    # Pin a tag for reproducibility; do not float on :latest in shared dev setups.
    image: maildev/maildev:2.1.0
    ports:
      - "1025:1025"   # SMTP intake — apps connect here
      - "1080:1080"   # web UI + REST API — open http://localhost:1080
    environment:
      # Cap stored messages so a chatty test loop can't exhaust memory.
      MAILDEV_INCOMING_USER: ""   # no auth in local dev
      MAILDEV_INCOMING_PASS: ""

Bring it up with docker compose up -d maildev, then open http://localhost:1080. The UI lists every captured message and, per message, shows the rendered HTML, a plain-text tab, the raw MIME source, and the full header set — which is where you confirm From, Reply-To, Content-Type, and MIME boundaries are what you intended.

Pointing your app's transport at MailDev

The only application change is the SMTP transport target. Three common stacks:

// Nodemailer (Node.js) — config/mailer.js
import nodemailer from 'nodemailer';

export const transport = nodemailer.createTransport({
  host: process.env.SMTP_HOST,          // "maildev" in Compose, "localhost" on the host
  port: Number(process.env.SMTP_PORT),  // 1025
  secure: false,                        // MailDev capture port is plaintext
  ignoreTLS: true,                      // do NOT attempt STARTTLS on 1025
});
# Django — settings.py
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = os.environ["SMTP_HOST"]          # maildev / localhost
EMAIL_PORT = int(os.environ["SMTP_PORT"])     # 1025
EMAIL_USE_TLS = False                         # no STARTTLS on the capture port
EMAIL_USE_SSL = False                         # no implicit TLS either
# Rails — config/environments/development.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: ENV["SMTP_HOST"],          # maildev / localhost
  port: ENV["SMTP_PORT"].to_i,        # 1025
  enable_starttls_auto: false,        # MailDev capture port has no TLS
}

Send a message from the app and refresh the UI — it appears instantly. The most common mistake is leaving STARTTLS on: clients that auto-negotiate encryption against the plaintext 1025 port fail the handshake and the message silently never arrives, so always disable TLS for the local transport.

Choosing among MailDev, Mailpit, and MailHog

All three are local SMTP sinks with a web UI; they differ in maintenance status and features.

Tool SMTP port UI port Notes
MailDev 1025 1080 Lightweight, fast to stand up, simple UI; great default for quick local capture
Mailpit 1025 8025 Actively maintained, richer REST API, link/HTML checks, SQLite persistence
MailHog 1025 8025 Widely deployed but effectively unmaintained; avoid for new projects

If you need a scriptable REST API for asserting message contents in a test harness, the sibling guide on running local previews with Mailpit covers that workflow in depth. For pure eyeball-the-render local development, MailDev's minimalism is the draw.

Capturing in tests

MailDev exposes a small REST API on the UI port, so an integration test can trigger a send and then assert on what landed.

# List captured messages (newest first) and pull the first subject.
curl -s http://localhost:1080/email | \
  jq '.[0] | { subject: .subject, to: .to[0].address, from: .from[0].address }'

# Clear the store between test runs so assertions start clean.
curl -s -X DELETE http://localhost:1080/email/all

Wire the DELETE /email/all call into your test setup so each test sees only its own messages, then GET /email to assert subject, recipients, and headers after the action under test runs.

Pipeline integration: development only

MailDev belongs in docker-compose.yml for local development and, optionally, as a CI service for integration tests — never in production. Keep the MailDev service out of any production Compose or orchestration manifest; production must send through a real relay or ESP. Gate it behind environment so the transport host resolves to MailDev only when NODE_ENV/DJANGO_SETTINGS/RAILS_ENV indicate a non-production environment, and let production read its SMTP credentials from secrets. The capture sink is a development convenience, and routing real customer mail through an in-memory store would mean those messages are silently dropped.

A fuller Compose configuration

The minimal service is enough to capture mail, but a few extra settings make MailDev behave predictably across a team and in CI. The block below adds a healthcheck so dependent services wait until the sink is actually accepting connections, a message cap to bound memory, and explicit network isolation.

# docker-compose.yml
services:
  app:
    build: .
    depends_on:
      maildev:
        condition: service_healthy   # wait until MailDev answers, not just until the container starts
    environment:
      SMTP_HOST: maildev             # Compose service hostname, resolved on the shared network
      SMTP_PORT: "1025"
      SMTP_SECURE: "false"           # plaintext capture port — no TLS
    networks: [dev-net]

  maildev:
    image: maildev/maildev:2.1.0     # pinned tag; never float on :latest in shared dev setups
    ports:
      - "1025:1025"                  # SMTP intake — apps connect here
      - "1080:1080"                  # web UI + REST API — open http://localhost:1080
    environment:
      MAILDEV_INCOMING_USER: ""      # no auth in local dev
      MAILDEV_INCOMING_PASS: ""
      MAILDEV_WEB_PORT: "1080"       # explicit so the healthcheck and UI agree
    healthcheck:
      # MailDev's REST endpoint returning 200 means SMTP is up too.
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:1080/healthz"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks: [dev-net]

networks:
  dev-net:
    driver: bridge

The service_healthy condition is the meaningful upgrade over a bare depends_on: without it, the app container can start, fire its first email, and fail before MailDev's SMTP listener is bound — a flaky-on-cold-start failure that is maddening to diagnose because it disappears on the second run.

Wiring each stack's transport

The application change is only the transport target; the message-building code is untouched. The three configs from earlier, with the production contrast made explicit so the same code path serves both:

// Nodemailer (Node.js)
import nodemailer from 'nodemailer';
export const transport = nodemailer.createTransport({
  host: process.env.SMTP_HOST,          // "maildev" in Compose; email-smtp.us-east-1.amazonaws.com for SES in prod
  port: Number(process.env.SMTP_PORT),  // 1025 locally; 587 for SES/SendGrid STARTTLS in prod
  secure: false,                        // MailDev capture port is plaintext; SES on 465 would be secure: true
  ignoreTLS: true,                      // do NOT attempt STARTTLS on 1025 — silent handshake failure otherwise
});
# Django — settings.py
EMAIL_HOST = os.environ["SMTP_HOST"]      # maildev / localhost
EMAIL_PORT = int(os.environ["SMTP_PORT"]) # 1025
EMAIL_USE_TLS = False                     # no STARTTLS on the capture port (True for SES/Postmark in prod)
EMAIL_USE_SSL = False                     # never implicit SSL on 1025
# Rails — config/environments/development.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: ENV["SMTP_HOST"],     # maildev / localhost
  port: ENV["SMTP_PORT"].to_i,   # 1025
  enable_starttls_auto: false,   # MailDev capture port has no TLS; true breaks the handshake
}

MailDev vs Mailpit: when to switch

MailDev's strength is minimalism — it stands up in seconds and the UI is uncluttered, which is ideal for a manual "does the welcome email look right" loop. Its limits show up the moment you need automation depth:

  • Persistence. MailDev stores messages in memory only; restart the container and every capture is gone. Mailpit can persist to SQLite via MP_DATABASE, which survives restarts and is useful when debugging an intermittent send across sessions.
  • REST API surface. MailDev offers GET /email, GET /email/:id, and DELETE /email/all — enough for basic assertions. Mailpit adds structured search, a raw-source endpoint, attachment metadata, and per-message release to a real relay, which is what richer automated snapshot testing harnesses lean on.
  • Built-in checks. Mailpit runs HTML-compatibility and link checks per message; MailDev does not.

The practical rule: stay on MailDev for eyeball-the-render local development, and move to the sibling guide on running local previews with Mailpit when your tests need to assert message contents programmatically or you want persistence across runs. Both are covered together in the overview of local email preview servers, which lays out the selection criteria side by side.

Auto-relay: MailDev's escape hatch

When you genuinely need a captured message to land in a real Gmail or Outlook inbox for a deliverability spot-check, MailDev can forward through an upstream relay rather than dropping the message:

maildev:
  image: maildev/maildev:2.1.0
  environment:
    MAILDEV_OUTGOING_HOST: email-smtp.us-east-1.amazonaws.com  # Amazon SES relay
    MAILDEV_OUTGOING_PORT: "587"
    MAILDEV_OUTGOING_USER: "${SES_SMTP_USER}"                  # SES SMTP credentials, from secrets
    MAILDEV_OUTGOING_PASS: "${SES_SMTP_PASS}"
    MAILDEV_OUTGOING_SECURE: "true"                            # STARTTLS upstream to SES on 587

With this configured, the UI's Relay action forwards a single chosen message upstream — a controlled, one-message escape rather than re-sending from the application and risking quota or reputation effects. Keep these credentials in secrets and out of any committed Compose file.

Validation checklist

  • MailDev runs as a Compose service with ports 1025 (SMTP) and 1080 (UI) mapped.
  • The image tag is pinned (e.g. maildev/maildev:2.1.0), not :latest.
  • The app's SMTP host points at the MailDev service hostname in Compose.
  • TLS/STARTTLS is disabled in the mailer config for the local transport.
  • A test message appears in the UI with correct rendered HTML, source, and headers.
  • Integration tests clear the store (DELETE /email/all) before each run.
  • MailDev is excluded from all production manifests; production uses a real relay.

← Back to Local Email Preview Servers