Operator guide

Everything you need to run this listmonk instance — the app itself, how it is deployed on Replit, and what to do when something breaks.

1. Orientation

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.

ThingWhere it livesSurvives a redeploy?
Subscribers, lists, campaigns, templates, settingsPostgreSQLYes
Uploaded media (images in campaigns)Cloudflare R2, via the S3 providerYes — only if S3 is configured
The listmonk binaryRe-downloaded and checksum-verified on every buildRebuilt each time
Anything written to the VM diskThe VM diskNo
The one rule: never point this instance at another app's database, and never leave media on the VM filesystem. Both look fine right up until a redeploy quietly loses data.

2. One-time setup checklist

Do these in order, in the admin UI, the first time the instance comes up.

  1. Change the bootstrap password and enable TOTP two-factor auth (Admin → your profile).
  2. Settings → General: set the root URL to the instance's real HTTPS address. Links, images and unsubscribe URLs in every email are built from this — get it wrong and emails ship broken links.
  3. Settings → SMTP: add the provider. For Resend: host smtp.resend.com, port 587, STARTTLS, username resend, password = a dedicated sending API key. Use the Test connection button before saving.
  4. Default sender and Reply-To: set both. A missing Reply-To sends replies into the void.
  5. Tracking: leave global open and click tracking disabled to start. Turn it on deliberately, later, if you actually need the numbers.
  6. Lists: create the lists you need as private, single opt-in unless you specifically want a public signup page or a double opt-in confirmation step. Write down each list's numeric ID — anything integrating over the API needs it.
  7. API user: Admin → Users. Create a dedicated user with a role limited to exactly what the integration needs (read/create/update subscribers, manage list membership, post bounces). Do not hand out a super-admin token. The token is shown once — store it immediately.
  8. Settings → Media: switch the provider to S3 and point it at a private Cloudflare R2 bucket with a public custom media domain. Enable R2 object versioning.
  9. Templates: make sure every campaign template contains an unsubscribe link and a physical/contact footer. See §4.

3. listmonk concepts that matter

Lists

Two axes, and they are independent:

If a campaign reports far fewer recipients than the list size, this is usually why.

Subscribers have two kinds of status

LevelValuesMeaning
Global (the person)enabled, blocklistedBlocklisted subscribers receive nothing, from any list, ever.
Per list (the subscription)unconfirmed, confirmed, unsubscribedHow this person relates to that one list.

Unsubscribing from one list does not blocklist someone globally. A spam complaint should.

Attributes and segmentation

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.

Campaign lifecycle

draftscheduledrunningpausedfinished (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.

Bounces

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.

API

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.

4. Templates and placeholders

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:

ExpressionWhat it gives you
{{ .Subscriber.Email }}Recipient email
{{ .Subscriber.Name }} / .FirstName / .LastNameName parts
{{ .Subscriber.Attribs.city }}A custom attribute
{{ .Subscriber.UUID }}Stable per-subscriber ID
{{ .Campaign.Subject }} / .Name / .FromEmailCampaign 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
Every marketing template needs both an {{ 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.

5. Sending a campaign safely

  1. Write it, pick the template and the list(s).
  2. Check the plain-text version — listmonk can generate it, but auto-generated text from heavy HTML is often unreadable. Mailbox providers penalise HTML-only mail.
  3. Send a test to yourself at two or three different providers (Gmail, Outlook, and one other). Check: does it land in Inbox or Spam? Do images load? Does the unsubscribe link work? Does replying reach a real mailbox?
  4. Verify SPF, DKIM and DMARC pass on the received message — view the raw headers and look for dkim=pass, spf=pass, dmarc=pass.
  5. Only then start the real send. Watch the first minute of the progress counter; if errors are climbing, pause it.

6. How this runs on Replit

The deployment is two files in the repo, and nothing else:

FileJob
deploy/listmonk/build.shDownloads 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.mjsTurns 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.

7. Settings it should run with

SettingValueWhy
Deployment typeReserved VM, web serverlistmonk runs resident campaign workers. Autoscale would suspend mid-send and can run several instances at once.
Build commandbash deploy/listmonk/build.sh
Run commandnode deploy/listmonk/start.mjs
PortWhatever PORT suppliesThe launcher binds 0.0.0.0:$PORT automatically.
DatabaseA dedicated PostgreSQLNever share another app's database.

Secrets

NameRequiredNotes
DATABASE_URLAlwaysThis deployment's own database.
LISTMONK_ADMIN_USERFirst boot onlyBootstrap super-admin username.
LISTMONK_ADMIN_PASSWORDFirst boot onlyChange it in the UI after first login. Leave the secret set.
LISTMONK_DB_SSL_MODEOptionalOverrides SSL mode. Precedence: this → ?sslmode= in DATABASE_URLrequire.
PORTSupplied by ReplitDefaults to 5000 locally.

Custom domain

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.

8. Upgrading listmonk

  1. Back up the database first. Non-negotiable — schema migrations are one-way.
  2. Get the new digest from the release's checksums file, linux_amd64 row:
    https://github.com/knadh/listmonk/releases/download/vX.Y.Z/listmonk_X.Y.Z_checksums.txt
  3. In build.sh, change both VERSION and EXPECTED_SHA256. Changing one without the other fails the build — that is the safety net working.
  4. If the public homepage override is still in use, re-fetch upstream's home.html for the new tag and re-apply the one-line guide link.
  5. Rehearse against a restored copy of the backup before touching production.
  6. Redeploy. Migrations run automatically on boot.

9. Backups

10. Troubleshooting

Deploy and boot

SymptomCause 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_URLSecret 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 buildThe 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 unreachableSomething 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 redeployDATABASE_URL is pointing somewhere ephemeral, or media is on the VM filesystem instead of S3/R2.

Login and access

SymptomCause and fix
Bootstrap password does not workThose 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 loginThe schema installed without an admin user. Create the admin on that page, then set the secrets for future rebuilds.
Locked out after enabling TOTPUse the recovery codes shown at setup time. If they are gone, the two-factor secret has to be cleared in the database.
Monitor reports 403It is probing /api/health. Use /health.

Email delivery

SymptomCause and fix
Campaign runs but nothing arrivesCheck Admin → Logs first — SMTP errors appear there verbatim. Then re-run Settings → SMTP → Test connection.
SMTP auth failsFor Resend the username is the literal string resend and the password is the API key — not your account email and password.
Mail lands in spamIn 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 mailThe root URL setting is wrong. It must be the full public HTTPS address.
Far fewer recipients than expectedDouble opt-in list with unconfirmed subscribers, or a segment query narrower than you thought. Check the count on the Subscribers page.
Provider rate-limit errorsLower the message rate and concurrency in Settings → Performance.
Replies vanishReply-To is unset or points at an unmonitored address.
Bounces never recordedBounce processing is off, or nothing is feeding it. Enable it and wire the provider's webhook or a POP3 mailbox.

Domain and TLS

SymptomCause and fix
Certificate error on the custom domainCloudflare proxying is on. Set the A record to DNS-only until the platform has issued and renewed the certificate.
Domain never verifiesThe A/TXT records do not match exactly what the Publishing pane shows. Re-copy them; watch for a trailing dot or an added subdomain.

Media

SymptomCause and fix
Uploaded images vanish after a redeployMedia provider is still filesystem. Switch to S3 and re-upload.
Images upload but do not load in emailThe bucket's public custom media domain is not set or not reachable. Recipients fetch these from the public internet, not from listmonk.

11. Prompts for your AI agent

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.

Deployment and upgrades

Upgrade

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.

Diagnose a failing deploy

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 a healthy deploy

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.

Content and templates

New template

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.

Plain-text version

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.

Subscribers and data

Segment query

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.

Import review

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.

List hygiene

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.

Deliverability

Pre-send audit

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.

Header analysis

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.

Two things worth telling any agent up front: this instance sends mail to real people, so nothing gets sent as a "test" against a real list; and the database is the only copy of the data, so schema changes and bulk deletes want a backup first.