Skip to content

Internal SMTP relay (Docker Compose)

You’re running Remark42 (or Gitea / Ghost / Mastodon / Authentik / any other self-hosted app that emits SMTP for transactional mail) on the same Docker host as Posthorn. You want Remark42’s password resets and comment notifications to flow through Posthorn to your transactional provider. The naïve fix is to set up SMTP AUTH and generate a self-signed TLS cert just so Remark42 will agree to talk to the listener. The better fix is to recognize that container-to-container traffic on a private Docker network doesn’t need TLS or AUTH — network isolation is already the trust boundary.

At the end: Remark42 sends SMTP on posthorn:2525 over a private Docker network, no TLS, no AUTH credentials, and Posthorn forwards the message via your configured transport (Postmark, Resend, Mailgun, SES). The sender allowlist (*@yourdomain.com) is the only ingress gate.

SituationUse this recipe?
Posthorn + app on the same Docker host, on a shared private networkYes
Posthorn + app on the same host but different networksYes (with care that the bridge isn’t internet-reachable)
Posthorn on one host, app on another, communicating over the public internetNo. Use API mode or production SMTP (TLS + AUTH)
Posthorn on Docker, app on the same machine but outside DockerYes if you bind to a loopback Docker bridge; otherwise treat as cross-host

The principle: if network access to Posthorn already implies trust (because the network is private and operator-controlled), the SMTP AUTH layer adds setup work without adding security. The sender allowlist + recipient cap remain in force as the open-relay-prevention gates.

  1. Set up a private Docker network.

    docker-compose.yml
    networks:
    internal:
    internal: true # no external connectivity

    The internal: true flag tells Docker that nothing inside this network has internet egress. That’s overkill for some setups; if your apps also need internet (e.g., Remark42 verifying email links via a webhook), use a regular bridge network without that flag and rely on the absence of ports: exposure for isolation.

  2. Add Posthorn to the network with the new auth mode.

    services:
    posthorn:
    image: ghcr.io/craigmccaskill/posthorn:latest
    restart: unless-stopped
    volumes:
    - ./posthorn.toml:/etc/posthorn/config.toml:ro
    env_file: .env
    networks:
    - internal
    # No ports: — only reachable from inside the network.
  3. Write posthorn.toml.

    # Internal SMTP relay for Docker-network apps.
    [smtp_listener]
    listen = ":2525"
    auth_required = "none"
    require_tls = false
    allowed_senders = ["*@yourdomain.com"]
    max_recipients_per_session = 10
    max_message_size = "1MB"
    [smtp_listener.transport]
    type = "postmark"
    [smtp_listener.transport.settings]
    api_key = "${env.POSTMARK_API_KEY}"

    Note: no tls_cert, no tls_key, no [[smtp_listener.smtp_users]]. The allowlist of senders does the gatekeeping.

  4. Add Remark42 to the same network and point it at Posthorn.

    services:
    remark42:
    image: umputun/remark42:latest
    restart: unless-stopped
    networks:
    - internal
    environment:
    REMARK_URL: https://comments.yourdomain.com
    # ... other Remark42 settings ...
    # SMTP relay via Posthorn — no TLS, no AUTH.
    AUTH_EMAIL_ENABLE: "true"
    NOTIFY_TYPE: "email"
    NOTIFY_EMAIL_FROM: "noreply@yourdomain.com"
    SMTP_HOST: "posthorn"
    SMTP_PORT: "2525"
    SMTP_TLS: "false"
    SMTP_STARTTLS: "false"
    # No SMTP_USERNAME, no SMTP_PASSWORD.
  5. Start the stack.

    Terminal window
    docker compose up -d
    docker compose logs -f posthorn

    You should see Posthorn register the listener:

    {"msg":"smtp_listener registered","listen":":2525","transport":"postmark","smtp_users":0}

    smtp_users: 0 is correct — there are no users because there’s no auth.

  6. Trigger a Remark42 email (e.g., request a password reset link from the comments admin UI). The Posthorn log should show the session:

    {"msg":"smtp_session_open","session_id":"...","tls":"no"}
    {"msg":"smtp_submission_sent","session_id":"...","transport_message_id":"..."}
    {"msg":"smtp_session_close","session_id":"...","reason":"quit"}

    The email lands at the recipient via Postmark within seconds.

This recipe extends directly to any app that speaks SMTP and can be configured to skip TLS + AUTH:

AppSMTP env vars to set
GiteaGITEA__mailer__PROTOCOL=smtp, GITEA__mailer__SMTP_ADDR=posthorn, GITEA__mailer__SMTP_PORT=2525, GITEA__mailer__FROM=noreply@yourdomain.com (no user / password)
Ghostmail.options.host=posthorn, mail.options.port=2525, mail.options.secure=false (no auth)
MastodonSMTP_SERVER=posthorn, SMTP_PORT=2525, SMTP_AUTH_METHOD=none, SMTP_OPENSSL_VERIFY_MODE=none, SMTP_TLS=false
Authentikemail.host=posthorn, email.port=2525, email.use_tls=false (leave user/password blank)
VaultwardenSMTP_HOST=posthorn, SMTP_PORT=2525, SMTP_SECURITY=off
Healthchecks.ioEMAIL_HOST=posthorn, EMAIL_PORT=2525, EMAIL_USE_TLS=False

All of them benefit from the same model: one Posthorn config, one set of provider credentials, one place to rotate keys and tail logs. The apps don’t need to know which provider Posthorn forwards to.

SymptomLikely causeFix
posthorn validate fails with tls_cert: required when require_tls=truerequire_tls = true is still setSet require_tls = false explicitly (don’t rely on absence — *bool defaulting prefers TLS)
MAIL FROM rejected with 550 5.7.1 Sender not authorizedThe app’s configured From: doesn’t match allowed_sendersAdd the address (or a *@domain wildcard) to allowed_senders
Client connects but the listener immediately closesThe app is sending STARTTLS and the listener has no certTell the app to skip STARTTLS (the env var differs per app; common name: SMTP_TLS=false, SMTP_STARTTLS=false, email.use_tls=false)
App config has user + password fields you don’t know what to put inLeave them blank or omit them entirelyThe listener doesn’t perform AUTH in auth_required = "none" mode; the app shouldn’t try to send credentials
Posthorn started fine but the app can’t connectProbably a network mismatch — the app isn’t on the same Docker networkCheck docker compose config to confirm both services are in networks: - internal

This recipe is the internal posture — appropriate when Posthorn is on a network where you trust everyone who can reach it. The production posture (TLS + AUTH + signed certs) is appropriate when Posthorn is reachable from outside that trust boundary.

The two postures can coexist on different listeners if you need both, but Posthorn v1.0 ships with one [smtp_listener] block per process. Run two Posthorn instances if you need a public + internal split, or pick the lower-friction posture (internal) when the deployment shape allows.