Skip to content

Self-hosted Ghost via Posthorn

You’re running Ghost for self-hosted publishing. Ghost emits SMTP for member signup confirmations, magic-link sign-ins, password resets, and newsletter sends. On a cloud VPS (DigitalOcean, Lightsail, Linode, Vultr) that blocks outbound SMTP on ports 25, 465, and 587, Ghost can’t reach a transactional provider directly. Posthorn’s SMTP listener accepts Ghost’s connection on a private Docker network and forwards via HTTP to whichever provider you’ve already chosen.

At the end: Ghost runs alongside its database (MySQL or SQLite) and Posthorn on the same Docker host. Ghost’s mail config points at posthorn:2525 over a private internal network (no TLS, no AUTH). Posthorn forwards via the configured HTTP transport to Postmark, Resend, Mailgun, or SES.

Docker hostPostmark · ResendMailgun · SESGhost(Docker)PosthornSMTP listener (auth=none)ghost-db(MySQL 8) SMTP on posthorn:2525(internal Docker network)MySQLHTTPS

Ghost’s web UI is reachable via your reverse proxy (loopback-bound). Ghost’s outbound mail never leaves the posthorn-internal Docker network until Posthorn forwards it over HTTP. The host-level SMTP block is irrelevant because Posthorn talks HTTPS to the provider, not SMTP.

  • A Docker host with docker compose
  • A transactional mail provider account (Postmark, Resend, Mailgun, or AWS SES) with a verified sending domain
  • A reverse proxy in front of the Docker host (Caddy, nginx, Traefik) for Ghost’s web UI
  • Ghost-specific: enough RAM for the MySQL container (Ghost officially supports MySQL 8). SQLite is fine for development but not recommended for production member-mail workloads
  1. Set up the Docker Compose stack.

    Three services on a private Docker network: Ghost, MySQL, and Posthorn. Ghost’s HTTP port and Posthorn’s HTTP port are loopback-bound for reverse-proxy access. Posthorn’s SMTP listener is NOT exposed via ports:; it’s only reachable from inside posthorn-internal.

    docker-compose.yml
    networks:
    posthorn-internal:
    internal: true
    services:
    posthorn:
    image: ghcr.io/craigmccaskill/posthorn:latest
    restart: unless-stopped
    volumes:
    - ./posthorn.toml:/etc/posthorn/config.toml:ro
    env_file: .env
    networks:
    - posthorn-internal
    ports:
    - "127.0.0.1:8080:8080"
    ghost-db:
    image: mysql:8
    restart: unless-stopped
    environment:
    MYSQL_DATABASE: ghost
    MYSQL_USER: ghost
    MYSQL_PASSWORD: ${GHOST_DB_PASSWORD}
    MYSQL_ROOT_PASSWORD: ${GHOST_DB_ROOT_PASSWORD}
    volumes:
    - ghost-db-data:/var/lib/mysql
    networks:
    - posthorn-internal
    ghost:
    image: ghost:5-alpine
    restart: unless-stopped
    depends_on:
    - ghost-db
    - posthorn
    env_file: .env.ghost
    volumes:
    - ghost-content:/var/lib/ghost/content
    networks:
    - posthorn-internal
    ports:
    - "127.0.0.1:2368:2368"
    volumes:
    ghost-db-data:
    ghost-content:
  2. Write .env.ghost.

    Ghost uses the nodemailer double-underscore env-var convention (mail__transport, mail__options__host, etc.). Point them at Posthorn:

    .env.ghost
    url=https://blog.example.com
    database__client=mysql
    database__connection__host=ghost-db
    database__connection__user=ghost
    database__connection__password=${GHOST_DB_PASSWORD}
    database__connection__database=ghost
    # Mail config. Posthorn is the SMTP server.
    mail__transport=SMTP
    mail__from="Blog <noreply@example.com>"
    mail__options__host=posthorn
    mail__options__port=2525
    mail__options__secure=false
    mail__options__ignoreTLS=true
    # mail__options__auth__user and mail__options__auth__pass intentionally omitted.

    mail__options__ignoreTLS=true tells nodemailer to skip STARTTLS even if the listener advertises it. mail__options__secure=false ensures Ghost doesn’t attempt direct TLS on connect. Together they keep the connection plaintext, which is what Posthorn’s listener expects on auth_required = "none".

    mail__from must match one of the patterns in Posthorn’s allowed_senders allowlist.

  3. Write posthorn.toml.

    [smtp_listener]
    listen = ":2525"
    auth_required = "none"
    require_tls = false
    allowed_senders = ["*@example.com"]
    max_recipients_per_session = 50 # higher than default; newsletter sends batch
    max_message_size = "10MB" # bigger than default; newsletters embed images
    [smtp_listener.transport]
    type = "postmark"
    [smtp_listener.transport.settings]
    api_key = "${env.POSTMARK_API_KEY}"

    Note the higher max_recipients_per_session and max_message_size than the defaults: newsletter sends batch many recipients per SMTP session, and newsletter messages embed inline images, both of which exceed the conservative defaults that are fine for plain transactional mail.

  4. Bring it up and trigger a test.

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

    Posthorn should log the listener registered:

    {"msg":"smtp_listener registered","listen":":2525","transport":"postmark","smtp_users":0}
    {"msg":"smtp ingress listening","addr":":2525"}

    To exercise the path end-to-end, open Ghost Admin and trigger any action that emits mail: invite a new staff user from Settings, Staff (sends a staff-invitation email), or create a member from Members and send them an invite. Watch Posthorn’s logs:

    Terminal window
    docker compose logs -f posthorn | grep -E 'smtp_|submission_'

    You should see smtp_session_open, smtp_data_received, then submission_sent with a transport_message_id matching the message ID in your provider’s dashboard.

  • Sidesteps cloud-VPS port blocks. Ghost talking SMTP to a container on a loopback Docker network never traverses the host’s outbound stack, so blocks on 25/465/587 don’t apply. Posthorn’s egress is HTTPS.
  • One credential. The Postmark / Resend / Mailgun / SES key lives in Posthorn’s .env, not Ghost’s. Rotating is a single Posthorn restart; Ghost doesn’t know it changed.
  • One log stream. Member signups, password resets, and newsletter sends all flow through Posthorn’s structured JSON, alongside any other app (Umami, Comentario, Gitea) on the same listener.
  • Provider migration without touching Ghost. Switching from Postmark to Resend is a TOML edit on the Posthorn side. Ghost still talks SMTP to posthorn:2525.
SymptomCauseFix
Ghost can’t send mail; logs show connection refusedServices aren’t on the same Docker networkConfirm both ghost and posthorn list posthorn-internal under networks:
Ghost’s mail times out or hangsGhost is trying STARTTLS against Posthorn’s plaintext listenerSet mail__options__ignoreTLS=true and mail__options__secure=false; restart Ghost
Newsletter sends partially succeed then 552Message size exceeded max_message_sizeRaise the limit in posthorn.toml (newsletters with embedded images can be 5-15 MB)
Member signup confirmations work but newsletter doesn’tmax_recipients_per_session cap hitRaise the cap; newsletters batch many recipients per session
Mail sends but lands in spamDNS misconfiguration on your sending domainSee DNS (SPF, DKIM, DMARC)