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.
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, andDELETE /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) and1080(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.
Related
- Running local email previews with Mailpit — a sibling sink with a richer REST API for test assertions
- Local email preview servers — the broader pattern of intercepting SMTP in development
- Email testing & QA workflows — where local capture sits in the overall QA gate
← Back to Local Email Preview Servers