Use AWS SES to send your Ghost newsletter emails without changing anything in Ghost.
Ghost has built-in support for Mailgun to send newsletters. But if you'd rather use AWS SES (it's cheaper), this bridge sits between Ghost and SES and translates between them. Ghost thinks it's talking to Mailgun. SES does the actual sending.
No Ghost code changes.
Originally based on ghost-ses-proxy, now independently maintained at ghost-mail-bridge.
Ghost sends emails through two separate “lanes”:
| Lane | What it sends | How it works with the bridge |
|---|---|---|
| Transactional | Magic links, password resets, staff invites | SES via SMTP (no bridge needed) |
| Newsletter | Bulk subscriber emails | Ghost → bridge API (Fake Mailgun) → MySQL + SQS → bridge worker → SES |
For event tracking (deliveries, opens, clicks, bounces, complaints), the flow goes the other direction:
Ghost send request → API → MySQL batch/job rows → SQS send queue → worker → SES
SES → SNS → SQS → worker → MySQL events/suppressions
↑
Ghost reads events from here
Ghost mail bridge also includes a admin dashboard so you can see what's going on without digging through logs.
git clone https://github.com/ifrederico/ghost-mail-bridge.git
cd ghost-mail-bridge
cp .env.example .envOpen .env and fill in your AWS credentials and settings. At minimum you'll need:
DATABASE_URL— MySQL connection string for the bridgeMAILGUN_DOMAIN— the domain value Ghost sends (for exampleyourdomain.com)PROXY_API_KEY— the API key Ghost will use to authenticate (you pick this)AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYSES_EVENTS_QUEUE_URL— your SQS queue that receives SES eventsNEWSLETTER_SEND_QUEUE_URL— your dedicated SQS queue for outbound newsletter jobsGHOST_ADMIN_URL— recommended if you want Ghost session auth on/ghost/mail
See Configuration variables for quick-start options. For advanced tuning, see Advanced configuration.
With Docker (recommended):
cp docker-compose.example.yml docker-compose.yml
docker compose up -dWithout Docker:
You'll need Node.js 20+ and a reachable MySQL instance.
npm install
npm run devcurl http://localhost:3003/healthYou should see something like:
{
"status": "ok",
"tables": {
"batches": 0,
"send_jobs": 0,
"recipient_emails": 0,
"events": 0,
"suppressions": 0
}
}Ghost uses two lanes:
- transactional mail goes straight to SES SMTP
- newsletter mail goes to the bridge over Docker's internal network
If Ghost and the bridge are in the same Compose project, set up both email lanes in your Ghost service config:
services:
ghost:
environment:
# --- Transactional emails (direct to SES via SMTP) ---
mail__transport: SMTP
mail__from: '"Your Site" <noreply@yourdomain.com>'
mail__options__host: email-smtp.us-east-1.amazonaws.com
mail__options__port: 587
mail__options__secure: "false"
mail__options__auth__user: ${SES_SMTP_USERNAME}
mail__options__auth__pass: ${SES_SMTP_PASSWORD}
# --- Newsletter emails (through the bridge) ---
bulkEmail__mailgun__baseUrl: http://ghost-mail-bridge:3003/v3
bulkEmail__mailgun__apiKey: ${PROXY_API_KEY}
bulkEmail__mailgun__domain: ${MAILGUN_DOMAIN}If Ghost is running from the official ghost-docker stack in /opt/ghost and the bridge is running separately in /opt/ghost-mail-bridge, use this tested pattern:
- Attach only the bridge API service to Ghost's existing Docker network.
Detect the real network name:
docker network ls --format '{{.Name}}' | grep ghost_networkThen update /opt/ghost-mail-bridge/docker-compose.yml so ghost-mail-bridge joins that external network:
services:
ghost-mail-bridge:
networks:
- default
- ghost_network
networks:
ghost_network:
external: true
name: your_real_ghost_network_name- Set Ghost's transactional SMTP lane in
/opt/ghost/.env:
mail__transport=SMTP
mail__from="Your Site <hello@yourdomain.com>"
mail__options__host=email-smtp.YOUR_AWS_REGION.amazonaws.com
mail__options__port=587
mail__options__secure=false
mail__options__auth__user=YOUR_SES_SMTP_USERNAME
mail__options__auth__pass=YOUR_SES_SMTP_PASSWORD- Set Ghost's newsletter lane in
/opt/ghost/.env:
bulkEmail__mailgun__baseUrl=http://ghost-mail-bridge:3003/v3
bulkEmail__mailgun__apiKey=your-secure-api-key-here
bulkEmail__mailgun__domain=yourdomain.com- Expose the bridge dashboard in
/opt/ghost/caddy/Caddyfilebefore the default Ghost proxy:
handle /ghost/mail* {
reverse_proxy ghost-mail-bridge:3003
}
handle {
reverse_proxy ghost:2368
}- Restart Caddy and Ghost:
cd /opt/ghost
docker compose up -d --force-recreate caddy ghost- Sync Ghost's stored Mailgun settings once so old DB values do not override the new bridge target:
cd /opt/ghost-mail-bridge
bash scripts/sync-ghost-mailgun-settings.shGhost should continue calling the bridge internally at http://ghost-mail-bridge:3003/v3. You do not need to expose /v3 publicly.
Ghost and ghost-mail-bridge should be on the same Docker network. If you are migrating an existing Ghost install that was already configured for Mailgun, update the stored mailgun_base_url once so Ghost stops calling the old host.
For Docker-based Ghost installs, you can use the migration helper instead of opening the database manually:
bash scripts/sync-ghost-mailgun-settings.shOptional convenience alias:
npm run ghost:sync-mailgun-settingsIt reads the existing Ghost DB credentials from the running Ghost container, updates the stored Mailgun settings to http://ghost-mail-bridge:3003/v3, and restarts Ghost. Treat it as part of install and upgrade hygiene for migrated sites or any Ghost instance that previously pointed at Mailgun.
If you ever want to switch the stored settings back to Mailgun, run:
MAILGUN_BASE_URL=https://api.mailgun.net/v3 \
MAILGUN_API_KEY=your-real-mailgun-api-key \
MAILGUN_DOMAIN=mg.yourdomain.com \
bash scripts/reset-ghost-mailgun-settings.shIf you want a Ghost-like /opt/ghost-mail-bridge deployment with separate API and worker services, use the templates in deploy/README.md, deploy/systemd/ghost-mail-bridge-api.service, deploy/systemd/ghost-mail-bridge-worker.service, and deploy/caddy/Caddyfile.example.
Run through this checklist after setup:
- Magic link sign-in works (transactional lane)
- Password reset works (transactional lane)
- Staff invite emails arrive (transactional lane)
- Newsletter send goes through the bridge (newsletter lane)
- Events show up in Ghost — delivery, opens, clicks
The bridge includes a simple dashboard for monitoring at /ghost/mail (configurable via ADMIN_BASE_PATH).
It shows send summaries, queued/processing/failed batch counts, worker status, and SES-event poller status. Authentication uses your Ghost admin session by default. Set GHOST_ADMIN_URL to your Ghost HTTPS URL.
If you're using the official Ghost Docker stack, Caddy must proxy /ghost/mail* to ghost-mail-bridge:3003 before the default Ghost route. The bridge dashboard is the only path that needs public proxying. Keep /v3 internal between Ghost and the bridge.
For local styling/development work without a Ghost session, you can use demo mode:
/ghost/mail/?demo=1
If you're running behind Nginx, add a proxy rule:
location /ghost/mail/ {
proxy_pass http://ghost-mail-bridge:3003/ghost/mail/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
}You'll need these AWS resources:
- SES — a verified domain identity and a Configuration Set
- SNS — a topic that SES publishes events to
- SQS (events) — a queue subscribed to that SNS topic for SES delivery/open/click/bounce/complaint events
- SQS (newsletter send) — a dedicated outbound queue for newsletter batch jobs
- SQS DLQ (optional) — useful for operations, but not required for the app to run
- MySQL — a dedicated bridge database (preferred over sharing Ghost’s DB)
- Bridge IAM user — AWS API credentials for SES + SQS
- SES SMTP credentials — separate credentials for Ghost transactional mail
The IAM user/role for the bridge needs these permissions:
ses:SendRawEmailses:GetAccountif you want/ghost/mailto show SES sandbox/account statussqs:SendMessageon the newsletter send queuesqs:ReceiveMessage,sqs:DeleteMessage,sqs:GetQueueAttributeson the newsletter send queue and SES event queue
Make sure:
- your SNS topic policy allows
ses.amazonaws.comto publish to the topic - your SQS queue policy only allows your SNS topic to publish to it (
aws:SourceArn) - SES, SNS, SQS, and credentials all use the same AWS region
- if your SES account is still in sandbox, test sends must go only to verified recipients or mailbox simulator addresses
A few things to be aware of:
- Only implements the slice of the Mailgun API that Ghost actually uses — this isn't a general-purpose Mailgun replacement.
- No attachment support.
- Event tracking is not instant.
- This release does not migrate old SQLite data. Keep the old file as backup/reference only.
| Method | Path | What it does |
|---|---|---|
POST |
/v3/:domain/messages |
Queue bulk email send via SES worker |
GET |
/v3/:domain/events |
Fetch events in Mailgun format |
GET |
/v3/:domain/events/:pageToken |
Fetch next event page |
DELETE |
/v3/:domain/:type/:email |
Delete a suppression record |
GET |
/health |
Service status and table counts |
All routes relative to ADMIN_BASE_PATH (default: /ghost/mail).
| Method | Path | What it does |
|---|---|---|
GET |
/ |
Dashboard HTML |
GET |
/api/health |
Health + poller status |
GET |
/api/summary |
24h send/event summary |
GET |
/api/failures |
Recent failures and complaints |
| SES event | Mailgun event | Creates suppression? |
|---|---|---|
| Delivery | delivered |
No |
| Open | opened |
No |
| Click | clicked |
No |
| Bounce (Permanent) | failed (permanent) |
Yes |
| Bounce (Transient) | failed (temporary) |
No |
| Complaint | complained |
Yes |
| Reject | failed (permanent) |
Yes |
| Send, DeliveryDelay | (skipped) | — |
| Variable | Description |
|---|---|
AWS_ACCESS_KEY_ID |
IAM access key |
AWS_SECRET_ACCESS_KEY |
IAM secret key |
DATABASE_URL |
MySQL connection string for the bridge |
SES_EVENTS_QUEUE_URL |
SQS queue URL for SES events |
NEWSLETTER_SEND_QUEUE_URL |
Dedicated SQS queue URL for outbound newsletter jobs |
PROXY_API_KEY |
API key Ghost uses to authenticate (you choose this) |
MAILGUN_DOMAIN |
Domain value Ghost sends (e.g., mg.yourdomain.com) |
| Variable | Default | Description |
|---|---|---|
AWS_REGION |
us-east-1 |
AWS region |
APP_ROLE |
all |
Runtime role: api, worker, or all |
SES_CONFIGURATION_SET |
ghost-mail-bridge |
SES Configuration Set name |
PORT |
3003 |
HTTP port |
LOG_LEVEL |
info |
Set debug for per-recipient logs |
SEND_CONCURRENCY |
10 |
Max parallel SES sends |
SEND_BATCH_SIZE |
1000 |
Max recipients per Ghost-like worker batch |
SEND_BATCH_CONCURRENCY |
2 |
Max parallel worker batches per send job |
SUPPRESSION_RETENTION_DAYS |
0 |
Suppression retention (0 = forever) |
ADMIN_BASE_PATH |
/ghost/mail |
Dashboard URL path |
GHOST_ADMIN_URL |
(empty) | Ghost HTTPS base URL for dashboard auth (required if using dashboard) |
ALLOW_INSECURE_GHOST_ADMIN_URL |
false |
Allow http:// Ghost admin URL only for trusted local/private setups |
NEWSLETTER_SEND_DLQ_URL |
(empty) | Optional DLQ URL for docs/ops parity |
Advanced configuration (optional)
For retry/backoff tuning, request-size limits, Ghost Admin API compatibility overrides, and local dev/testing switches, see Advanced configuration and .env.advanced.example.
The bridge now uses MySQL only.
Core tables: batches, send_jobs, recipient_emails, events, suppressions, runtime_heartbeats.
Daily cleanup removes batch/send/event data older than the configured retention windows. Suppressions are kept forever unless SUPPRESSION_RETENTION_DAYS is set.
cp .env.example .env
npm install
npm run dev # API + worker in one process
# or split roles locally:
npm run dev:api
npm run dev:workerFor dashboard-only work without AWS, use the local dev/testing switches documented in Advanced configuration.
MIT