Skip to content

listmonk on Railway via Posthorn

You’re running listmonk for newsletters. listmonk only sends mail over SMTP. Railway’s docs say it plainly: on the Free, Trial, and Hobby plans, “SMTP is disabled on these plans to prevent spam and abuse”, and they point you at providers with HTTPS APIs instead. Posthorn is the bridge: it runs as a second service in the same Railway project, listmonk hands it mail over SMTP on the private network, and Posthorn sends it on to Resend over HTTPS, which isn’t blocked.

Railway is the worked example. The shape is the same on any host that blocks ports 25, 465, and 587.

At the end: listmonk’s SMTP settings point at posthorn.railway.internal:2525 with a username and password. Posthorn forwards to Resend. Posthorn’s config is baked into a two-line image you build from a tiny repo, so there’s no volume to seed and nothing to do on the first deploy.

Railway project (private network)ResendlistmonkPosthornSMTP listener (AUTH PLAIN) SMTP onposthorn.railway.internal:2525HTTPS

Railway encrypts service-to-service traffic on the private network with WireGuard, so the SMTP hop runs without TLS and stays private anyway. Posthorn’s outbound leg is HTTPS to Resend, which Railway allows on every plan.

  • A Railway project with listmonk already running
  • A Resend account with a verified sending domain. Postmark, Mailgun, or SES work the same way; swap the transport block
  • A Git repository for two files, Dockerfile and posthorn.toml. Private is fine; Railway builds from it
  • listmonk’s default from address on the verified domain
  1. Create the config repo.

    Two files at the root. Railway only picks up a Dockerfile named exactly Dockerfile.

    # Dockerfile
    FROM ghcr.io/craigmccaskill/posthorn:2
    COPY posthorn.toml /etc/posthorn/config.toml

    The image’s entrypoint already runs posthorn serve --config /etc/posthorn/config.toml, so copying the file to that path is the whole build. Secrets are not in the file; the ${env.VAR} placeholders are filled from Railway variables at start.

    posthorn.toml
    [smtp_listener]
    listen = ":2525"
    auth_required = "smtp-auth"
    require_tls = false # the hop never leaves Railway's private network, which is already encrypted
    allowed_senders = ["*@example.com"]
    max_recipients_per_session = 50 # newsletter sends batch recipients per session
    max_message_size = "10MB" # newsletters embed images
    [[smtp_listener.smtp_users]]
    username = "listmonk"
    password = "${env.LISTMONK_SMTP_PASSWORD}"
    [smtp_listener.transport]
    type = "resend"
    [smtp_listener.transport.settings]
    api_key = "${env.RESEND_API_KEY}"

    listen = ":2525" binds both IPv4 and IPv6, which matters on Railway: environments created before 2025-10-16 have an IPv6-only private network. Nothing to change either way.

    Check the file before you push it. Placeholder values are fine for the env vars; validation only needs them to exist.

    Terminal window
    docker run --rm --entrypoint /usr/local/bin/posthorn \
    -v "$PWD/posthorn.toml:/etc/posthorn/config.toml:ro" \
    -e RESEND_API_KEY=x -e LISTMONK_SMTP_PASSWORD=x \
    ghcr.io/craigmccaskill/posthorn:2 validate --config /etc/posthorn/config.toml
  2. Add the Posthorn service in Railway.

    In the project, add a new service from the GitHub repo you just made. The build log should say it detected the Dockerfile. Name the service posthorn; the name becomes its private hostname, posthorn.railway.internal.

    Under the service’s Variables, add RESEND_API_KEY and LISTMONK_SMTP_PASSWORD (any long random string). Seal both so the values never show in the UI again.

    Don’t add a public domain to this service. Nothing outside the project needs to reach it. Railway’s private networking needs no port configuration.

  3. Point listmonk at Posthorn.

    In listmonk’s admin UI, Settings, SMTP:

    SettingValueWhy
    Hostposthorn.railway.internalThe Posthorn service’s private hostname
    Port2525Matches listen
    Auth protocolplainlistmonk defaults to CRAM-MD5. Posthorn accepts AUTH PLAIN only
    Username / Passwordlistmonk / the LISTMONK_SMTP_PASSWORD valueMatches smtp_users
    TLSOfflistmonk defaults to STARTTLS. The listener doesn’t offer it with require_tls = false
    Max connections4Well under Posthorn’s per-client cap of 16
    Retries2listmonk’s default; fine

    Settings, General: the default from email must be on a domain in allowed_senders.

    Settings, Performance: set Concurrency to 2 and Message rate to 4. listmonk applies the rate per worker, so that’s about eight messages a second. Resend allows ten API requests a second per team by default and answers 429 above that. Posthorn retries a 429 once after five seconds, then returns 451 to listmonk, which retries on its own schedule. Staying under the cap avoids the dance entirely. listmonk’s defaults (10 workers, 10 per second, 10 connections) would blow through it on any real send.

  4. Send a test.

    Save the settings, then send a campaign to a list containing only your own address. Watch the Posthorn service’s logs in Railway:

    {"msg":"smtp_listener registered","listen":":2525","transport":"resend","smtp_users":1}
    {"msg":"smtp_session_open", ...}
    {"msg":"submission_sent","transport_message_id":"...", ...}

    The transport_message_id matches the message in Resend’s dashboard. The received mail should be the HTML version; listmonk sends multipart/alternative with quoted-printable encoding, and Posthorn v2.0.0 and later decode it correctly.

Without storage, a Resend outage that outlasts Posthorn’s inline retry hands the message back to listmonk as a 451, and listmonk’s own retry setting takes over. That is fine for most newsletters. If you want Posthorn to own the retry across restarts, add the storage block and a volume:

[storage]
path = "/data/posthorn.db"

Two things to know on Railway before you do. Each service gets one volume, so mount it at /data. And Railway’s docs note that images running as a non-root user “will have permissions issues when performing operations within an attached volume”; their fix is setting RAILWAY_RUN_UID=0, which runs the container as root. Posthorn’s image runs as nonroot on purpose, so this trade is yours to make. The baseline recipe stays stateless to avoid it.

Why this beats wiring listmonk to a provider’s SMTP

Section titled “Why this beats wiring listmonk to a provider’s SMTP”
  • It works on the plans where SMTP is switched off. listmonk keeps speaking SMTP; the blocked ports are never used.
  • One credential. The Resend key lives in Posthorn’s variables, not listmonk’s database. Rotate it by redeploying Posthorn.
  • Provider swap is a TOML edit. Move to Postmark or Mailgun by changing the transport block. listmonk doesn’t change.
  • One log stream. Anything else in the project that sends mail can use the same listener with its own smtp_users entry.
SymptomCauseFix
listmonk reports an auth failure or unsupported mechanismAuth protocol left on CRAM-MD5Set it to plain
Connection hangs or a TLS errorTLS left on STARTTLSSet TLS to Off
no such host: posthorn.railway.internalServices in different projects or environments, or the service isn’t named posthornSame project and environment; match the hostname to the service name
421 on many connectionslistmonk’s max connections above Posthorn’s max_connections_per_ipLower listmonk’s max connections
451 Upstream transport failed during a sendResend 429 from too much concurrencyLower concurrency and message rate
550 sender not allowedFrom address not in allowed_sendersAdd the domain, or fix listmonk’s from address
HTML arrives as plain text, or with =3D in itPosthorn older than v2.0.0Use the :2 image tag
List-Unsubscribe headers missing at ResendThe listener doesn’t pass them through yet (#115)Body unsubscribe links still work; track the issue
Container exits at start with a config errorDeployed the public image instead of building the repo, or the Dockerfile isn’t named DockerfileBuild from the two-file repo