A sandbox SMTP server belongs in the same compose file as your application: it starts with docker compose up, needs no account, works offline, and — this is the useful part — the very same configuration works in CI, where nobody can click through a dashboard. The container you put there is usually Mailpit: one image, two ports, actively maintained, and it replaced the unmaintained MailHog.

The service

Mailpit's own documentation recommends this shape, and it is what most compose files end up with:

services:
  mailpit:
    image: axllent/mailpit
    container_name: mailpit
    restart: unless-stopped
    volumes:
      - ./data:/data
    ports:
      - 8025:8025   # web UI
      - 1025:1025   # SMTP
    environment:
      MP_MAX_MESSAGES: 5000
      MP_DATABASE: /data/mailpit.db
      MP_SMTP_AUTH_ACCEPT_ANY: 1
      MP_SMTP_AUTH_ALLOW_INSECURE: 1

What each line buys you:

  • MP_DATABASE + the volume keep captured mail across restarts; without them Mailpit is in-memory and a container restart empties the inbox.
  • MP_MAX_MESSAGES prunes the store so it cannot grow forever (5000 is generous for development; CI can drop it to a few hundred).
  • MP_SMTP_AUTH_ACCEPT_ANY / MP_SMTP_AUTH_ALLOW_INSECURE accept any SMTP credentials, which matters because many application configs insist on sending a username and password even in development.

If SMTP only has to reach the containers that need it, drop the 1025:1025 mapping: published ports are exactly how a test SMTP server ends up being an open relay on the internet.

Point your application at it

Inside the compose network the host is the service name, not localhost — the single most common mistake with containerised sandboxes:

# Laravel (.env)
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=anything
MAIL_PASSWORD=anything
MAIL_ENCRYPTION=null
# Django settings.py
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'mailpit'
EMAIL_PORT = 1025
EMAIL_HOST_USER = 'anything'
EMAIL_HOST_PASSWORD = 'anything'
EMAIL_USE_TLS = False

From your laptop (outside the network) the same service is on 127.0.0.1:1025, and the web UI is on http://127.0.0.1:8025.

smtp4dev and MailHog in the same slot

  • smtp4dev is the .NET equivalent: same idea, same two ports, an image of its own. Pick it if your team lives in Visual Studio.
  • MailHog still appears in older compose files. It is no longer maintained — see MailHog alternative: what to use instead for the five-minute migration (the ports are identical, so it is the image name that changes).
  • A hosted sandbox (this instance) is the option for when you do not want the container at all — it has no compose service, but also no container to keep alive.

Waiting for it in CI

docker compose up -d returns before the SMTP port accepts connections, which makes the first mail flake. Either wait for the port, or use a healthcheck:

    healthcheck:
      test: ["CMD", "/mailpit", "readyz"]
      interval: 2s
      timeout: 2s
      retries: 15

Then docker compose up -d --wait blocks until it is up (Compose v2.17+) — no sleeps in the workflow.

Which container, exactly?

Option Maintained Persistence Notes
axllent/mailpit yes MP_DATABASE + volume the default choice: REST API, HTML/link/spam checks, optional POP3, pruning
smtp4dev yes its own volume .NET and Windows teams
MailHog no MongoDB or file migrate away; the ports are the same
A hosted sandbox (this instance) by its operator server-side, with a retention setting no container at all, and it catches webhooks too

The other half: webhooks

A compose file that catches mail still has nowhere to send a Stripe or GitHub callback. Some teams add a second container for that; the hosted option here does both on one address. See How to test webhooks locally without ngrok.

Next steps