Everything you need to run this listmonk instance — the app itself, how it is deployed on Replit, and what to do when something breaks.
This is a self-hosted listmonk instance — a newsletter and mailing-list manager. It stores everything in its own PostgreSQL database and sends mail through an external SMTP provider. It is deployed as a Replit Reserved VM from a small bundle of two files.
| Thing | Where it lives | Survives a redeploy? |
|---|---|---|
| Subscribers, lists, campaigns, templates, settings | PostgreSQL | Yes |
| Uploaded media (images in campaigns) | Cloudflare R2, via the S3 provider | Yes — only if S3 is configured |
| The listmonk binary | Re-downloaded and checksum-verified on every build | Rebuilt each time |
| Anything written to the VM disk | The VM disk | No |
Do these in order, in the admin UI, the first time the instance comes up.
smtp.resend.com, port 587, STARTTLS, username resend, password = a dedicated sending API key. Use the Test connection button before saving.Two axes, and they are independent:
unconfirmed until they click a confirmation link. Campaigns to a double opt-in list only reach confirmed subscribers.If a campaign reports far fewer recipients than the list size, this is usually why.
| Level | Values | Meaning |
|---|---|---|
| Global (the person) | enabled, blocklisted | Blocklisted subscribers receive nothing, from any list, ever. |
| Per list (the subscription) | unconfirmed, confirmed, unsubscribed | How this person relates to that one list. |
Unsubscribing from one list does not blocklist someone globally. A spam complaint should.
Every subscriber has a free-form JSON attribs object. This is the interesting part of listmonk: you can query subscribers with raw SQL fragments in the Subscribers page search box, and save the result as a segment for a campaign.
subscribers.attribs->>'city' = 'Dublin'
subscribers.attribs->>'plan' = 'pro' AND subscribers.created_at > now() - interval '30 days'
(subscribers.attribs->'orders')::int >= 2
Test a query on the Subscribers page and check the result count before attaching it to a campaign.
draft → scheduled → running → paused → finished (or cancelled). A running campaign can be paused and resumed. Sending speed is governed by the message rate and concurrency in Settings → Performance; if you are hitting provider rate limits, that is the dial to turn.
Bounce processing has to be switched on in Settings → Bounces, and something has to feed it — either a webhook from your sending provider or a POP3 mailbox. Once enabled you can auto-blocklist or delete a subscriber after N hard bounces. Without this, your list slowly rots and your sending reputation goes with it.
Everything in the UI is available over the REST API at /api/*, authenticated with an API user and token:
curl -H "Authorization: token API_USER:API_TOKEN" \
https://YOUR-DOMAIN/api/lists
BasicAuth (curl -u "API_USER:API_TOKEN") works too. Permissions come from the role you assigned that user.
A campaign template is the wrapper (header, footer, styling). The campaign body is dropped into it. Every template must contain exactly one:
{{ template "content" . }}
Available in templates and campaign bodies:
| Expression | What it gives you |
|---|---|
{{ .Subscriber.Email }} | Recipient email |
{{ .Subscriber.Name }} / .FirstName / .LastName | Name parts |
{{ .Subscriber.Attribs.city }} | A custom attribute |
{{ .Subscriber.UUID }} | Stable per-subscriber ID |
{{ .Campaign.Subject }} / .Name / .FromEmail | Campaign fields |
{{ UnsubscribeURL }} | Required. Unsubscribe / preferences link |
{{ MessageURL }} | "View this email in your browser" link |
{{ TrackView }} | Open-tracking pixel (only if you want tracking) |
{{ TrackLink "https://example.com" }} | Click-tracked link. Shorthand: https://example.com@TrackLink |
{{ OptinURL }} | Double opt-in confirmation link |
{{ Date "2006-01-02" }} | Current date, Go layout syntax |
{{ UnsubscribeURL }} link and a physical postal address / contact line in the footer. That is a legal requirement in most jurisdictions (CAN-SPAM, GDPR, PECR), not a nicety, and mailbox providers weight it in spam scoring.dkim=pass, spf=pass, dmarc=pass.The deployment is two files in the repo, and nothing else:
| File | Job |
|---|---|
deploy/listmonk/build.sh | Downloads the pinned listmonk release and verifies its SHA-256 before extracting. A mismatch fails the build rather than shipping an unverified binary. |
deploy/listmonk/start.mjs | Turns PORT and DATABASE_URL into listmonk's environment, applies schema migrations, then runs the server. No npm dependencies. |
Boot sequence. The launcher first runs a schema upgrade. On an empty database that call fails harmlessly without writing anything, which is the signal to run a first-time install instead. That ordering is deliberate: if the admin bootstrap secrets are missing, the deploy fails while the database is still untouched, rather than leaving a half-configured instance with an unclaimed admin account on a public URL.
Health check. Probe /health — it is public and returns {"data":true}. Do not point a monitor at /api/health; that sits behind API auth and answers 403.
| Setting | Value | Why |
|---|---|---|
| Deployment type | Reserved VM, web server | listmonk runs resident campaign workers. Autoscale would suspend mid-send and can run several instances at once. |
| Build command | bash deploy/listmonk/build.sh | |
| Run command | node deploy/listmonk/start.mjs | |
| Port | Whatever PORT supplies | The launcher binds 0.0.0.0:$PORT automatically. |
| Database | A dedicated PostgreSQL | Never share another app's database. |
| Name | Required | Notes |
|---|---|---|
DATABASE_URL | Always | This deployment's own database. |
LISTMONK_ADMIN_USER | First boot only | Bootstrap super-admin username. |
LISTMONK_ADMIN_PASSWORD | First boot only | Change it in the UI after first login. Leave the secret set. |
LISTMONK_DB_SSL_MODE | Optional | Overrides SSL mode. Precedence: this → ?sslmode= in DATABASE_URL → require. |
PORT | Supplied by Replit | Defaults to 5000 locally. |
Publish on the .replit.app URL first and confirm the login page loads and data survives a restart. Then add the custom domain under Publishing → Domains and copy the exact A and TXT records into DNS. If Cloudflare manages the DNS, keep the A record DNS-only (grey cloud) so the platform can provision and renew TLS.
linux_amd64 row:https://github.com/knadh/listmonk/releases/download/vX.Y.Z/listmonk_X.Y.Z_checksums.txtbuild.sh, change both VERSION and EXPECTED_SHA256. Changing one without the other fails the build — that is the safety net working.home.html for the new tag and re-apply the one-line guide link.| Symptom | Cause and fix |
|---|---|
this database has no listmonk schema yet… | Fresh database, and the admin bootstrap secrets are missing. The database was not modified. Add both secrets and redeploy. |
missing/invalid required configuration: DATABASE_URL | Secret absent, or not a postgres:// URL with a database name on the end. Percent-encode any special characters in the password. |
SHA-256 mismatch … refusing to build | The download does not match the pin. Do not paste the "actual" digest in to make it pass — check it against upstream's checksums file first. If they disagree, stop. |
no executable listmonk binary at … | The build step did not run. Run the build command. |
| Deploy succeeds, app unreachable | Something else is binding the port, or the run command was overridden in the UI. Check the deployment logs for http server started on 0.0.0.0:…. |
| Data gone after a redeploy | DATABASE_URL is pointing somewhere ephemeral, or media is on the VM filesystem instead of S3/R2. |
| Symptom | Cause and fix |
|---|---|
| Bootstrap password does not work | Those secrets are only read during the first-time install. Changing them later has no effect. Use the password-reset flow, or reset the user directly in the database. |
| A first-run setup page appears instead of a login | The schema installed without an admin user. Create the admin on that page, then set the secrets for future rebuilds. |
| Locked out after enabling TOTP | Use the recovery codes shown at setup time. If they are gone, the two-factor secret has to be cleared in the database. |
Monitor reports 403 | It is probing /api/health. Use /health. |
| Symptom | Cause and fix |
|---|---|
| Campaign runs but nothing arrives | Check Admin → Logs first — SMTP errors appear there verbatim. Then re-run Settings → SMTP → Test connection. |
| SMTP auth fails | For Resend the username is the literal string resend and the password is the API key — not your account email and password. |
| Mail lands in spam | In order of impact: DKIM/SPF/DMARC not passing; sending domain not verified with the provider; no plain-text part; no unsubscribe link; no postal address; brand-new domain with no sending history. Warm up gradually. |
| Links and images broken in received mail | The root URL setting is wrong. It must be the full public HTTPS address. |
| Far fewer recipients than expected | Double opt-in list with unconfirmed subscribers, or a segment query narrower than you thought. Check the count on the Subscribers page. |
| Provider rate-limit errors | Lower the message rate and concurrency in Settings → Performance. |
| Replies vanish | Reply-To is unset or points at an unmonitored address. |
| Bounces never recorded | Bounce processing is off, or nothing is feeding it. Enable it and wire the provider's webhook or a POP3 mailbox. |
| Symptom | Cause and fix |
|---|---|
| Certificate error on the custom domain | Cloudflare proxying is on. Set the A record to DNS-only until the platform has issued and renewed the certificate. |
| Domain never verifies | The A/TXT records do not match exactly what the Publishing pane shows. Re-copy them; watch for a trailing dot or an added subdomain. |
| Symptom | Cause and fix |
|---|---|
| Uploaded images vanish after a redeploy | Media provider is still filesystem. Switch to S3 and re-upload. |
| Images upload but do not load in email | The bucket's public custom media domain is not set or not reachable. Recipients fetch these from the public internet, not from listmonk. |
These are written to be pasted as-is into an agent working in this repo. They are deliberately specific — vague prompts produce vague changes to a system that sends mail to real people.
Upgrade listmonk to version X.Y.Z. Fetch the linux_amd64 digest from upstream's listmonk_X.Y.Z_checksums.txt, update both VERSION and EXPECTED_SHA256 in deploy/listmonk/build.sh, re-fetch upstream's static/public/templates/home.html for the new tag and re-apply our one-line guide link, then run the build and boot it locally against the dev database to confirm migrations apply cleanly. Do not deploy — report what changed and what the local boot showed.
The deployment is failing. Read the deployment logs, identify which boot step failed (schema check, first-time install, or the server), and explain the cause in plain terms. Check deploy/listmonk/start.mjs for the exact error string. Tell me the minimal fix — do not change anything yet.
Verify this listmonk deployment end-to-end: confirm /health returns 200, the login page loads over HTTPS on the custom domain, and the database still has its subscribers and lists after a restart. Report anything that does not check out.
Write a listmonk campaign template for me. It must contain exactly one {{ template "content" . }}, an {{ UnsubscribeURL }} link, a "view in browser" {{ MessageURL }} link, and a footer with our postal address. Keep it single-column, under 600px, table-based so it renders in Outlook, and readable with images blocked.
Review the plain-text version of this campaign. Rewrite it so it stands on its own without the HTML — no bare URLs longer than a line, no "click here", and the unsubscribe link present.
Write a listmonk subscriber SQL query for this segment: [describe it]. Use the subscribers table and the attribs JSONB column. Explain what it matches, then give me the query to paste into the Subscribers search box so I can check the count before I attach it to a campaign.
I am about to import this CSV into listmonk. Check the headers and a sample of rows for problems — malformed emails, duplicates, missing consent evidence, attributes that should be JSON but are not. Tell me what to fix before I import, and which list and subscription status to use.
Audit this listmonk instance for list hygiene: how many subscribers are unconfirmed, how many have hard-bounced, how many have never opened anything. Recommend what to blocklist or remove, and tell me the risk of sending to the list as it stands.
Audit this campaign before I send it: subject line, preheader, plain-text part, unsubscribe link, postal footer, image alt text, link count, and anything that commonly trips spam filters. Be blunt about what would hurt deliverability.
Here are the raw headers from a test email this instance sent. Tell me whether SPF, DKIM and DMARC passed, what the alignment is, and exactly which DNS record to fix if any of them failed.