Self-hosted Listmonk newsletter manager running on Cloudflare Containers with Supabase PostgreSQL
This repository deploys Listmonk — a high-performance, self-hosted newsletter and mailing list manager — on Cloudflare Containers with Supabase PostgreSQL as the backend database.
Why this stack?
| Benefit | Description |
|---|---|
| Zero server management | Cloudflare Containers handles provisioning, scaling, and TLS |
| Global edge routing | Requests are routed through Cloudflare's network (300+ cities) |
| Auto-sleep | Container sleeps after 30 minutes of inactivity — pay only for usage |
| Managed database | Supabase PostgreSQL with automatic backups, connection pooling, and dashboards |
| One-command deploy | npm run deploy builds the Docker image and deploys globally |
┌──────────────────────────────────────────────────────────────────┐
│ Cloudflare Edge Network │
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌────────────────┐ │
│ │ Worker │───►│ Durable Object │───►│ Container │ │
│ │ (Router) │ │ (Lifecycle Mgr) │ │ (Listmonk) │ │
│ │ │ │ │ │ Port 9000 │ │
│ │ /__health │ │ Sleep: 30min │ │ │ │
│ │ /__version │ │ Max: 1 instance │ │ Alpine Linux │ │
│ │ /* proxy │ │ │ │ Go binary │ │
│ └─────────────┘ └──────────────────┘ └───────┬────────┘ │
│ │ │
└──────────────────────────────────────────────────────┼───────────┘
│ TLS
┌────────▼────────┐
│ Supabase │
│ PostgreSQL │
│ │
│ ┌────────────┐ │
│ │ subscribers │ │
│ │ campaigns │ │
│ │ templates │ │
│ │ lists │ │
│ │ settings │ │
│ └────────────┘ │
└─────────────────┘
| Tool | Version | Purpose |
|---|---|---|
| Node.js | >= 20.0.0 | Runtime for Wrangler CLI |
| Wrangler | >= 4.0.0 | Cloudflare deployment tool |
| Docker | Latest | Container image builds |
| Supabase account | — | Managed PostgreSQL database |
| Cloudflare account | — | Workers + Containers runtime |
git clone https://github.com/HeyMegabyte/mail.megabyte.space.git
cd mail.megabyte.space
npm installEdit wrangler.jsonc with your settings:
# Database password (from Supabase dashboard → Settings → Database)
wrangler secret put DB_PASSWORDnpm run deployYour Listmonk instance will be live at https://your-domain.com within minutes.
| Variable | Required | Default | Description |
|---|---|---|---|
APP_DOMAIN |
Yes | mail.megabyte.space |
Public-facing domain name |
DB_HOST |
Yes | — | Supabase PostgreSQL hostname |
DB_PORT |
No | 5432 |
PostgreSQL port |
DB_USER |
No | postgres |
Database username |
DB_NAME |
No | postgres |
Database name |
DB_SSL_MODE |
No | require |
PostgreSQL SSL mode |
DB_PASSWORD |
Yes | — | Database password (set as secret) |
ADMIN_USER |
No | admin |
Listmonk admin username |
ADMIN_PASSWORD |
Yes | — | Listmonk admin password |
| Setting | Value | Description |
|---|---|---|
| Container Port | 9000 |
Listmonk's HTTP server port |
| Sleep After | 30 minutes |
Auto-sleep after inactivity |
| Max Instances | 1 |
Single container (Durable Object) |
| Instance Type | standard-1 |
Cloudflare container tier |
| Internet Access | true |
Required for SMTP + database |
| DB Pool (open) | 25 |
Max open PostgreSQL connections |
| DB Pool (idle) | 25 |
Max idle PostgreSQL connections |
| DB Pool (lifetime) | 300s |
Max connection lifetime |
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/__health |
GET | None | Health check (bypasses container) |
/__version |
GET | None | Deployed version information |
/admin |
GET | Basic Auth | Listmonk admin dashboard |
/api/* |
Various | API Key | Listmonk REST API |
Deploy separate instances for different domains or environments:
// In wrangler.jsonc, add an "env" block:
{
"env": {
"staging": {
"name": "listmonk-mail-staging",
"vars": {
"APP_DOMAIN": "mail-staging.megabyte.space",
"DB_HOST": "db.YOUR_STAGING_PROJECT.supabase.co"
// ... other vars
},
"routes": [
{ "pattern": "mail-staging.megabyte.space", "custom_domain": true }
]
}
}
}Deploy with:
npm run deploy:staging
# or: npx wrangler deploy --env staging# Production logs
npm run logs
# Staging logs
npm run logs:stagingAll log entries include request IDs for correlation:
[a1b2c3d4e5f6] GET /admin — forwarding to container
[a1b2c3d4e5f6] GET /admin — 200 (145ms)
Errors return structured JSON with machine-readable codes:
{
"error": "Container request timed out",
"code": "CONTAINER_TIMEOUT",
"request_id": "a1b2c3d4e5f6",
"timestamp": "2026-02-18T10:30:00.000Z",
"version": "2.1.0"
}| Error Code | HTTP Status | Cause |
|---|---|---|
CONTAINER_FETCH_ERROR |
502 | Container unreachable or internal error |
CONTAINER_TIMEOUT |
504 | No response within 30 seconds |
DURABLE_OBJECT_ERROR |
503 | Durable Object routing failure |
The GitHub Actions workflow (.github/workflows/deploy.yml) automatically:
- Checks out the code
- Installs Node.js 22 and dependencies
- Sets up Docker Buildx for container builds
- Runs TypeScript type checking
- Deploys to Cloudflare via Wrangler
- Sets the
DB_PASSWORDsecret
| Secret | Description |
|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare API token with Workers + DNS permissions |
CLOUDFLARE_ACCOUNT_ID |
Your Cloudflare account ID |
DB_PASSWORD |
Supabase PostgreSQL password |
Trigger a manual deployment from the GitHub Actions tab with an optional
environment parameter (e.g., staging).
This same Cloudflare Containers + Supabase pattern works for many other open-source applications. See docs/COMPANION-APPS.md for a complete guide.
Best companions for Listmonk:
| App | Category | Why |
|---|---|---|
| Umami | Analytics | Track newsletter campaign clicks |
| n8n | Automation | Automate subscriber workflows |
| Shlink | URL Shortener | Short links in newsletters with tracking |
| Hasura | GraphQL | API layer over your Supabase data |
# Install dependencies
npm install
# Type check
npm run typecheck
# Local development
npm run dev
# Check health of live deployment
npm run health
# View deployed version
npm run version.
├── src/
│ └── index.ts # 310 lines — Worker + Durable Object + Container
├── docs/
│ ├── ARCHITECTURE.md # Deep dive into system design
│ ├── DEPLOYMENT.md # Step-by-step deployment runbook
│ └── COMPANION-APPS.md # Other apps for this stack
├── .github/
│ └── workflows/
│ └── deploy.yml # CI/CD pipeline
├── Dockerfile # Listmonk container with PostgreSQL client
├── wrangler.jsonc # Cloudflare Workers configuration
├── package.json # Project metadata and scripts
├── tsconfig.json # TypeScript strict configuration
├── CLAUDE.md # AI development context
├── LICENSE # MIT License
└── README.md # This file
Container takes a long time to start
Cold starts take 10-15 seconds because the container must:
- Boot the Alpine Linux image
- Install
postgresql16-clientvia apk - Run
listmonk --install --yes(idempotent migration) - Patch the
root_urlsetting in the database - Start the Listmonk Go binary
The container stays warm for 30 minutes after the last request.
502 Bad Gateway errors
This usually means the container failed to start. Check:
npm run logsfor container error messages- Verify your Supabase database is reachable and credentials are correct
- Ensure
DB_PASSWORDis set:wrangler secret list - Try redeploying:
npm run deploy
Database connection refused
- Check Supabase dashboard — is the project paused?
- Verify
DB_HOSTmatches your Supabase project - Ensure
DB_SSL_MODEis set torequire - Check that Supabase network restrictions allow Cloudflare IPs
Admin panel shows wrong URL
The Dockerfile patches root_url on every boot. If it's still wrong:
- Check
APP_DOMAINinwrangler.jsonc - The SQL patch runs:
UPDATE settings SET value = '"https://APP_DOMAIN"' WHERE key = 'app.root_url' - Redeploy to trigger the patch again
MIT — Megabyte Labs
Built with Cloudflare Containers + Supabase PostgreSQL
Deployed at mail.megabyte.space
{ "vars": { "APP_DOMAIN": "mail.yourdomain.com", // Your domain "DB_HOST": "db.xxxxx.supabase.co", // Supabase host "DB_PORT": "5432", "DB_USER": "postgres", "DB_NAME": "postgres", "DB_SSL_MODE": "require", "ADMIN_USER": "admin", "ADMIN_PASSWORD": "your-secure-password" } }