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.
The shape
Section titled “The shape”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.
Prerequisites
Section titled “Prerequisites”- 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
Walkthrough
Section titled “Walkthrough”-
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 insideposthorn-internal.docker-compose.yml networks:posthorn-internal:internal: trueservices:posthorn:image: ghcr.io/craigmccaskill/posthorn:latestrestart: unless-stoppedvolumes:- ./posthorn.toml:/etc/posthorn/config.toml:roenv_file: .envnetworks:- posthorn-internalports:- "127.0.0.1:8080:8080"ghost-db:image: mysql:8restart: unless-stoppedenvironment:MYSQL_DATABASE: ghostMYSQL_USER: ghostMYSQL_PASSWORD: ${GHOST_DB_PASSWORD}MYSQL_ROOT_PASSWORD: ${GHOST_DB_ROOT_PASSWORD}volumes:- ghost-db-data:/var/lib/mysqlnetworks:- posthorn-internalghost:image: ghost:5-alpinerestart: unless-stoppeddepends_on:- ghost-db- posthornenv_file: .env.ghostvolumes:- ghost-content:/var/lib/ghost/contentnetworks:- posthorn-internalports:- "127.0.0.1:2368:2368"volumes:ghost-db-data:ghost-content: -
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.comdatabase__client=mysqldatabase__connection__host=ghost-dbdatabase__connection__user=ghostdatabase__connection__password=${GHOST_DB_PASSWORD}database__connection__database=ghost# Mail config. Posthorn is the SMTP server.mail__transport=SMTPmail__from="Blog <noreply@example.com>"mail__options__host=posthornmail__options__port=2525mail__options__secure=falsemail__options__ignoreTLS=true# mail__options__auth__user and mail__options__auth__pass intentionally omitted.mail__options__ignoreTLS=truetells nodemailer to skip STARTTLS even if the listener advertises it.mail__options__secure=falseensures Ghost doesn’t attempt direct TLS on connect. Together they keep the connection plaintext, which is what Posthorn’s listener expects onauth_required = "none".mail__frommust match one of the patterns in Posthorn’sallowed_sendersallowlist. -
Write
posthorn.toml.[smtp_listener]listen = ":2525"auth_required = "none"require_tls = falseallowed_senders = ["*@example.com"]max_recipients_per_session = 50 # higher than default; newsletter sends batchmax_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_sessionandmax_message_sizethan 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. -
Bring it up and trigger a test.
Terminal window docker compose up -ddocker compose logs -f posthornPosthorn 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, thensubmission_sentwith atransport_message_idmatching the message ID in your provider’s dashboard.
Why this beats Ghost’s direct SMTP
Section titled “Why this beats Ghost’s direct SMTP”- 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.
Gotchas
Section titled “Gotchas”| Symptom | Cause | Fix |
|---|---|---|
Ghost can’t send mail; logs show connection refused | Services aren’t on the same Docker network | Confirm both ghost and posthorn list posthorn-internal under networks: |
| Ghost’s mail times out or hangs | Ghost is trying STARTTLS against Posthorn’s plaintext listener | Set mail__options__ignoreTLS=true and mail__options__secure=false; restart Ghost |
| Newsletter sends partially succeed then 552 | Message size exceeded max_message_size | Raise the limit in posthorn.toml (newsletters with embedded images can be 5-15 MB) |
| Member signup confirmations work but newsletter doesn’t | max_recipients_per_session cap hit | Raise the cap; newsletters batch many recipients per session |
| Mail sends but lands in spam | DNS misconfiguration on your sending domain | See DNS (SPF, DKIM, DMARC) |
See also
Section titled “See also”- Self-hosted Gitea via Posthorn for the same SMTP-relay pattern with Gitea’s notification flows
- Hugo blog + Comentario comments for a related recipe combining HTTP form ingress and SMTP listener
- Internal SMTP relay (Docker Compose) for the generic pattern this recipe specializes
- SMTP ingress for the full listener feature reference