Back to Blog

Docker SMTP Testing: The Complete Developer Guide

Sending emails from your application during development has always been a minefield. Use your real SMTP credentials and you risk blasting test emails to actual users. Disable email entirely and you lose visibility into what your application is actually sending. The solution that most modern teams have landed on: containerized SMTP testing with Docker.

This guide walks through everything you need to set up, configure, and troubleshoot Docker-based SMTP testing -- from simple single-service setups to full CI/CD pipeline integration.

Why Docker Is Ideal for SMTP Testing

Docker solves three fundamental problems with email testing during development:

Isolation. Every developer gets their own SMTP server. No shared inboxes, no cross-contamination between branches or features. When you tear down the container, the emails disappear with it.

Reproducibility. Your SMTP testing environment is defined in code. New team members run docker compose up and have the exact same setup as everyone else. No "works on my machine" debugging sessions for email-related issues.

Disposability. Containers are ephemeral by design. Need a clean slate? Destroy the container and start fresh. No accumulated test data to wade through, no stale emails from last week's debugging session cluttering your inbox.

The alternative -- configuring every developer's machine with a local SMTP server like Postfix -- is brittle, platform-dependent, and a maintenance headache that nobody wants to own.

Basic Setup: Docker Compose with an SMTP Catcher

The simplest useful setup is a single SMTP catcher service. Mailpit is the current go-to choice -- it is actively maintained, lightweight, and provides a clean web UI for inspecting captured emails.

# docker-compose.yml
services:
  mailpit:
    image: axllent/mailpit:latest
    ports:
      - "8025:8025"   # Web UI
      - "1025:1025"   # SMTP server
    environment:
      MP_SMTP_AUTH_ACCEPT_ANY: 1
      MP_MAX_MESSAGES: 500

Run docker compose up -d and you have an SMTP server accepting mail on port 1025 and a web interface at http://localhost:8025.

Point your application's SMTP configuration at localhost:1025 and every outbound email gets captured and displayed in the Mailpit UI instead of reaching any real inbox.

Multi-Service Setup: App + Database + SMTP

In practice, you rarely run an SMTP catcher in isolation. Here is a more realistic Docker Compose configuration for a web application with a database, application server, and SMTP testing:

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/myapp
      SMTP_HOST: mailpit
      SMTP_PORT: 1025
      SMTP_USERNAME: ""
      SMTP_PASSWORD: ""
      SMTP_ENCRYPTION: ""
      MAIL_FROM: [email protected]
    depends_on:
      db:
        condition: service_healthy
      mailpit:
        condition: service_started
    networks:
      - backend

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - backend

  mailpit:
    image: axllent/mailpit:latest
    ports:
      - "8025:8025"
    networks:
      - backend

volumes:
  pgdata:

networks:
  backend:
    driver: bridge

Notice that the SMTP port (1025) is not published to the host in this configuration. The application container communicates with Mailpit over the internal Docker network using the service name mailpit as the hostname. Only the web UI port (8025) is exposed for developers to inspect captured emails in their browser.

Configuring Application Frameworks

Laravel

# .env
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="MyApp Dev"

Django

# settings/development.py
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'mailpit'
EMAIL_PORT = 1025
EMAIL_USE_TLS = False
EMAIL_USE_SSL = False

Rails

# config/environments/development.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: 'mailpit',
  port: 1025,
  enable_starttls_auto: false
}

Node.js (Nodemailer)

const nodemailer = require('nodemailer');

const transporter = nodemailer.createTransport({
  host: 'mailpit',
  port: 1025,
  secure: false,
  tls: {
    rejectUnauthorized: false
  }
});

Spring Boot

# application-dev.yml
spring:
  mail:
    host: mailpit
    port: 1025
    properties:
      mail.smtp.auth: false
      mail.smtp.starttls.enable: false

The key principle across all frameworks: point your SMTP host at the Docker service name (not localhost) when your application runs inside a container. Use localhost only when the application runs on the host machine and the SMTP port is published.

Docker Networks for SMTP Communication

When you have multiple Docker Compose stacks that need to share an SMTP service, Docker networks become essential.

# shared-services/docker-compose.yml
services:
  mailpit:
    image: axllent/mailpit:latest
    ports:
      - "8025:8025"
    networks:
      - shared-mail

networks:
  shared-mail:
    name: shared-mail
    driver: bridge
# my-app/docker-compose.yml
services:
  app:
    build: .
    environment:
      SMTP_HOST: mailpit
      SMTP_PORT: 1025
    networks:
      - shared-mail
      - default

networks:
  shared-mail:
    external: true

The external: true declaration tells Docker Compose that the network already exists and should not be created or destroyed with this stack. Start the shared services stack first to create the network, then start your application stack.

This pattern is common in microservice architectures where multiple services need to send email and you want a single place to inspect all outbound messages.

Docker SMTP in CI/CD Pipelines

SMTP testing in CI/CD is where Docker really shines. Here is a GitHub Actions workflow that spins up an SMTP catcher, runs tests that send emails, and verifies the results via Mailpit's API:

# .github/workflows/test.yml
name: Test Email Functionality
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      mailpit:
        image: axllent/mailpit:latest
        ports:
          - 1025:1025
          - 8025:8025

    steps:
      - uses: actions/checkout@v4

      - name: Setup application
        run: |
          cp .env.ci .env
          composer install --no-interaction
          php artisan key:generate

      - name: Run tests
        env:
          MAIL_HOST: localhost
          MAIL_PORT: 1025
        run: php artisan test --filter=Email

      - name: Verify emails were sent
        run: |
          EMAIL_COUNT=$(curl -s http://localhost:8025/api/v1/messages | jq '.total')
          if [ "$EMAIL_COUNT" -eq "0" ]; then
            echo "ERROR: No emails were captured during test run"
            exit 1
          fi
          echo "Captured $EMAIL_COUNT emails during test run"

Mailpit exposes a REST API that makes programmatic assertions straightforward. You can query for specific recipients, subject lines, or content patterns:

# Search for emails to a specific address
curl -s "http://localhost:8025/api/v1/search?query=to:[email protected]" | jq '.messages'

# Get the latest message body
curl -s "http://localhost:8025/api/v1/messages" | jq '.messages[0]'

# Delete all messages (clean slate between test suites)
curl -X DELETE "http://localhost:8025/api/v1/messages"

For GitLab CI, the pattern is similar using the services directive:

# .gitlab-ci.yml
test:
  image: php:8.3-cli
  services:
    - name: axllent/mailpit:latest
      alias: mailpit
  variables:
    MAIL_HOST: mailpit
    MAIL_PORT: 1025
  script:
    - php artisan test --filter=Email

When Docker SMTP Is Not Enough

Local Docker SMTP catchers work well for individual developers. They start to break down when teams need:

Shared visibility. A QA engineer wants to see what emails the staging environment sends without SSH access to the server. A product manager needs to verify the wording in a transactional email. With a local Docker SMTP catcher, only the person running the container can see captured emails.

Persistent history. Docker containers are ephemeral. When you need to review emails sent last week or compare email output between releases, a disposable container is the wrong tool.

Team collaboration. Multiple developers working on email-related features need a shared environment where everyone can inspect, discuss, and verify email output without stepping on each other's work.

Webhook testing. You need to test how your application reacts to email events -- delivery confirmations, bounces, complaints -- and your local SMTP catcher does not simulate those.

This is where a cloud-hosted SMTP sandbox like SendPit fills the gap. The setup is similar -- point your SMTP configuration at a remote host instead of a local container -- but the emails are captured in a shared, persistent, web-accessible interface.

# .env (using SendPit as your SMTP sandbox)
MAIL_MAILER=smtp
MAIL_HOST=smtp.sendpit.com
MAIL_PORT=587
MAIL_USERNAME=mb_your_mailbox_id
MAIL_PASSWORD=your_mailbox_password
MAIL_ENCRYPTION=tls

SendPit provides shared team mailboxes with encrypted credentials, so your entire team -- developers, QA, product -- can inspect captured emails from any browser. It also supports webhooks for testing event-driven email workflows.

The practical approach for most teams: use Docker SMTP locally for rapid development iteration, and a cloud sandbox like SendPit for shared environments (staging, CI/CD, QA).

Troubleshooting Common Docker SMTP Issues

Connection Refused on Port 1025

The most common issue. Check these in order:

# 1. Is the container actually running?
docker compose ps mailpit

# 2. Is the port published correctly?
docker compose port mailpit 1025

# 3. Can you reach it from the host?
nc -zv localhost 1025

# 4. Can you reach it from inside another container?
docker compose exec app nc -zv mailpit 1025

If the host can reach it but your containerized app cannot, your containers are on different networks.

DNS Resolution Failures

Inside Docker, service names resolve to container IPs via Docker's internal DNS. If your application cannot resolve the hostname mailpit, check that both containers are on the same network:

# List networks for each container
docker inspect app_container --format='{{json .NetworkSettings.Networks}}' | jq
docker inspect mailpit_container --format='{{json .NetworkSettings.Networks}}' | jq

Emails Sent but Not Appearing in the UI

Check that you are connecting to the SMTP port (1025), not the HTTP port (8025). This is a surprisingly common misconfiguration -- the web UI and the SMTP server are different services on different ports.

Also verify that your application is not silently swallowing SMTP errors. Temporarily enable verbose SMTP logging:

// Laravel: config/mail.php
'smtp' => [
    'transport' => 'smtp',
    'host' => env('MAIL_HOST', 'mailpit'),
    'port' => env('MAIL_PORT', 1025),
    'timeout' => 5,
    // Add for debugging:
    'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', ''), PHP_URL_HOST)),
],

TLS/SSL Handshake Errors

Most SMTP catchers do not support encryption. If your application is configured with MAIL_ENCRYPTION=tls or MAIL_ENCRYPTION=ssl, set it to null or an empty string for local development.

Container Startup Order

If your application container starts before the SMTP container is ready, initial emails might fail silently. Use depends_on with health checks:

services:
  mailpit:
    image: axllent/mailpit:latest
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:8025/api/v1/info"]
      interval: 5s
      timeout: 3s
      retries: 5

  app:
    build: .
    depends_on:
      mailpit:
        condition: service_healthy

Summary

Docker-based SMTP testing gives you isolated, reproducible email testing that works the same way for every developer on the team and in every CI/CD run. Start with a simple Mailpit container, expand to multi-service setups as your application grows, and consider a cloud SMTP sandbox when your team needs shared visibility.

The configuration is straightforward, the tooling is mature, and the alternative -- manually verifying emails in production or disabling email entirely during development -- is not worth the risk.

N

Nikhil Rao

Creator of SendPit. Building developer tools for email testing and SMTP infrastructure.

About SendPit →

More from the blog