Workload · Sending your own newsletter

Self-hosted listmonk: run your newsletter and mailing lists on a server of your own

To self-host listmonk you run its Docker image next to a PostgreSQL database on a server you control, then connect the email service that will actually deliver your campaigns; listmonk handles the lists, sign-ups, campaigns, unsubscribes and bounces. Here you set it up on a dedicated server with Ubuntu 24.04, keep it invisible from the internet until your own admin account exists, and only then give readers a web address with HTTPS.

The short version
  1. The project's own Docker Compose file: listmonk plus Postgres 17
  2. Nobody but you can reach it until your admin account is made
  3. Your mail provider sends; listmonk keeps lists and bounces tidy
  4. Nightly copies of the database, uploaded images and compose file
  5. Open source under the AGPL-3.0 licence

What you are putting on the server

You are setting up a newsletter platform you own: subscriber lists, sign-up and unsubscribe pages, campaigns, templates and bounce tracking. listmonk is a single Go program whose data store is PostgreSQL, per its README.

  • App (listmonk/listmonk:latest): the admin and the public pages, on port 9000.
  • Database (postgres:17-alpine): subscribers, lists, campaigns, accounts and most settings, in the listmonk-data volume.
  • uploads folder: images from the media library.
  • Your sending service: listmonk passes each message to SMTP servers you name.

The few settings needed before the admin opens (listen address, database connection) are environment variables in the compose file: LISTMONK_ plus the config.toml key, dots turned into double underscores, as in LISTMONK_db__host. Mounting a config.toml instead is shown on the installation page.

Do you need a big server?

No, for most lists. The docs publish no minimum CPU or memory, and the performance page says listmonk handles millions of subscribers on few resources. What grows is the database: as views and clicks pile up, dashboard counts slow down, and the fix offered is cached counts (Settings > Performance) plus an occasional VACUUM ANALYZE.

That makes one of the small cartridge servers in our dedicated server catalogue a sensible start, since cartridges are meant for one job per machine. Choose an everyday single-processor server when the same box will also hold a website or other databases; our PostgreSQL workload page helps if the database becomes the busy part.

Two limits. Rack servers and blades include the console screen and power button; among cartridges, a console comes with m510 and m710x models, while the others have a power switch alone, which is enough here since you work over SSH. And skip X-Gene ARM cartridges: Ubuntu 18.04 is their newest system, too old for the Docker version below.

A ready server is yours within 30 minutes of payment approval, while a made-to-order machine takes 4 to 24 hours when its parts are in our stock, longer when they must be bought in. It comes with one IPv4 address, IPv6 at no charge, and root access.

Get it running where only you can see it

Become root with sudo -i and paste the block on Ubuntu 24.04. It turns the firewall on with SSH allowed, installs Docker from Ubuntu's packages, and stops unless the Docker Engine version reads 28 or higher, since from 28 a port tied to 127.0.0.1 really stays on the server (moby#45610). It then fetches the official compose file and makes two edits of ours: the web port is rewritten to 127.0.0.1:9000:9000, and the stock database password (the word listmonk) is replaced by a random string once the file is locked to mode 600. Nothing starts unless both edits are confirmed, and it will not run over an existing install, where a new password would lock the app out of its database.

sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw && ufw allow OpenSSH && ufw --force enable &&
  apt-get update && apt-get install -y docker.io docker-compose-v2 curl openssl
engine=$(docker version 2>/dev/null | awk '/^Server/{s=1} s && /Version:/{sub(/.*Version: */,""); sub(/[.].*/,""); print; exit}')
yml=/srv/listmonk/docker-compose.yml
if [ "${engine:-0}" -lt 28 ]; then
  echo "STOP: Docker Engine ${engine:-missing} is below 28; nothing was started"
elif [ -e "$yml" ]; then
  echo "STOP: $yml is already there; not touching its database password"
else
  install -d -m 700 /srv/listmonk &&
  curl -fsSL -o "$yml" https://github.com/knadh/listmonk/raw/master/docker-compose.yml &&
  chmod 600 "$yml" && secret=$(openssl rand -hex 24) && [ -n "$secret" ] &&
  sed -i -e "s/&db-password listmonk$/\&db-password $secret/" \
    -e 's/- "9000:9000"/- "127.0.0.1:9000:9000"/' "$yml" &&
  grep -q "&db-password $secret" "$yml" && grep -q '"127.0.0.1:9000:9000"' "$yml" &&
  docker compose -f "$yml" up -d && sleep 20 &&
  ss -ltn | grep -q '127.0.0.1:9000 ' && ! ss -ltn | grep -Eq '(0\.0\.0\.0|\*|\[::\]):9000 ' &&
  echo "ok: listmonk answers on 127.0.0.1:9000 and nowhere else"
fi

Look for the ok line. Why: Docker publishes a port with no address on 0.0.0.0 and [::]; IPv4 traffic to it never meets ufw's rules, and IPv6 traffic doesn't either once IPv6 networking is enabled inside Docker itself. On start the official command creates tables only when missing (--install --idempotent), applies upgrades (--upgrade) and launches. cmd/install.go also seeds a new install with example lists, test subscribers and a demo campaign; delete them before your first real send.

Make yourself the admin first

A new listmonk has no users, so its login page shows a setup form: the first person to submit it is made Super Admin, holding all permissions (cmd/auth.go). With the port private, that can only be you.

  1. On your computer, open a tunnel with your usual login: ssh -L 9000:127.0.0.1:9000 -N yourname@server.example.
  2. Visit http://localhost:9000/admin and choose your e-mail, a username (three characters or more) and a password (eight or more).
  3. Settings > General > Root URL: the address readers will use, like https://letters.example.com, no slash at the end. Its default is http://localhost:9000 (see schema.sql), and every link listmonk puts in your mail, unsubscribe and opt-in links included, is built from this value (cmd/init.go).
  4. Under Settings > Media, type /listmonk/uploads as the folder for uploads; the comment in the compose file asks for exactly that; your backups include that folder too.

There is a second route in the docs: setting the two variables LISTMONK_ADMIN_USER / LISTMONK_ADMIN_PASSWORD when the stack first starts. We prefer the form: a password given on the command line ends up in your history, and readable in the container's settings until it is rebuilt.

Connect the service that actually sends your mail

listmonk hands each message to an SMTP server rather than sending it itself. Under Settings > SMTP, enter the host, port, login and encryption your email provider or relay gives you (STARTTLS is the common choice, says the settings hint) and send a test message. The configuration page adds that some providers, Google Workspace for one, want a real domain as HELO hostname, and that Retries sets how often a failed message is quietly retried before it is logged.

Bounce processing, which keeps a list healthy, is switched on under Settings > Bounces. Either give listmonk a POP3 mailbox used only for bounces (it deletes what it reads, so never your real inbox), set as Return-Path in the SMTP custom headers; or point the webhook of SES, SendGrid, Postmark, Azure ACS, Forward Email or Lettermint at a path such as /webhooks/service/postmark. As shipped (schema.sql), a single hard bounce, or a single complaint, puts the address on the blocklist, while soft bounces are just recorded.

Let readers in: an address and HTTPS

With your account made, open the doors. The configuration page lists which paths readers need and which (/admin, /api, /webhooks/bounce) are private.

  1. Create an A record for letters.example.com, plus an AAAA record if you use the server's IPv6 address, and wait until getent hosts letters.example.com shows it.
  2. Caddy runs straight on the host, and ufw filters it for IPv4 and IPv6 alike, so let it through: ufw allow 443/tcp && ufw allow 80/tcp.
  3. Install Caddy, packaged in Ubuntu 24.04, and point it at listmonk: apt-get install -y caddy installs it; then printf '%s\n' 'letters.example.com {' ' reverse_proxy 127.0.0.1:9000' '}' | tee /etc/caddy/Caddyfile > /dev/null && caddy validate --adapter caddyfile --config /etc/caddy/Caddyfile && systemctl restart caddy. Caddy fetches the certificate itself.

Everything passes through, admin login included, guarded by listmonk's password. If only you use the admin, you could answer 403 for /admin* and /api/* (except /api/public/*, the public list, subscription and captcha endpoints in cmd/handlers.go) and keep using the tunnel; that is our suggestion, not a docs requirement. Nginx Proxy Manager works too if you prefer clicking to editing files.

Keep a copy you can bring back

You manage the server; we keep the hardware and the network running, so the copies are yours. A useful copy holds a database dump, the uploads folder and the compose file, which carries the password your database was created with. The nightly script below refuses to call a failed dump a success (pipefail), checks both archives, and deletes copies older than three weeks only after a new one is good. Run bash /usr/local/sbin/listmonk-nightly-backup.sh once and look for ok, then copy /var/backups/listmonk elsewhere with rsync -a, leaving out --delete, which would copy a wipe-out too.

install -d -m 700 /var/backups/listmonk /var/log/listmonk-backup
install -m 700 /dev/null /usr/local/sbin/listmonk-nightly-backup.sh
cat > /usr/local/sbin/listmonk-nightly-backup.sh <<'EOF'
set -euo pipefail
umask 077
dc="docker compose -f /srv/listmonk/docker-compose.yml"
out=/var/backups/listmonk/$(date +%F-%H%M)
mkdir "$out"
$dc exec -T db pg_dump -U listmonk listmonk | gzip > "$out/postgres.sql.gz"
gzip -t "$out/postgres.sql.gz"
tar -C /srv/listmonk -czf "$out/uploads-and-compose.tar.gz" uploads docker-compose.yml
tar -tzf "$out/uploads-and-compose.tar.gz" > /dev/null
find /var/backups/listmonk -mindepth 1 -maxdepth 1 -name '20*' -mtime +21 -exec rm -rf {} +
echo "ok $out"
EOF
printf '%s\n' '40 2 * * * root umask 077; bash /usr/local/sbin/listmonk-nightly-backup.sh >> /var/log/listmonk-backup/cron.log 2>&1' > /etc/cron.d/listmonk-backup

Restoring follows the upgrade page's recipe (clear out the public schema and feed the dump back in) inside safety steps of ours: the copy is tested, listmonk stopped, today's data dumped and checked before anything is removed, and it refuses to start while an uploads.old folder remains. As root, create it with touch /usr/local/sbin/listmonk-restore-copy.sh && chmod 700 /usr/local/sbin/listmonk-restore-copy.sh, paste the lines, and run it on a folder like /var/backups/listmonk/2026-11-02-0240. Changed your mind? Move the old uploads out of the way first, for example mv /srv/listmonk/uploads.old /root/uploads.old-$(date +%F-%H%M), because the script will not start while that folder exists; then run it on the before-restore folder it printed. Happy with the result? Delete uploads.old.

# /usr/local/sbin/listmonk-restore-copy.sh; run as root: bash /usr/local/sbin/listmonk-restore-copy.sh BACKUP_DIR
set -euo pipefail
umask 077
from=$(realpath "${1:?give the backup directory}")
dc="docker compose -f /srv/listmonk/docker-compose.yml"
before=/var/backups/listmonk/before-restore-$(date +%F-%H%M)
if [ -e "$before" ] || [ -e /srv/listmonk/uploads.old ]; then
  echo "STOP: $before or /srv/listmonk/uploads.old exists; deal with it first" >&2; exit 1
fi
gzip -t "$from/postgres.sql.gz"
tar -tzf "$from/uploads-and-compose.tar.gz" > /dev/null
$dc stop app
mkdir "$before"
$dc exec -T db pg_dump -U listmonk listmonk | gzip > "$before/postgres.sql.gz"
tar -C /srv/listmonk -czf "$before/uploads-and-compose.tar.gz" uploads docker-compose.yml
gzip -t "$before/postgres.sql.gz"
tar -tzf "$before/uploads-and-compose.tar.gz" > /dev/null
echo "today's data is saved and checked in $before"
$dc exec -T db psql -v ON_ERROR_STOP=1 -U listmonk -d listmonk \
  -c 'DROP SCHEMA public CASCADE;' -c 'CREATE SCHEMA public;'
gunzip < "$from/postgres.sql.gz" | $dc exec -T db psql -q -v ON_ERROR_STOP=1 -U listmonk -d listmonk
mv /srv/listmonk/uploads /srv/listmonk/uploads.old
tar -C /srv/listmonk -xzf "$from/uploads-and-compose.tar.gz" uploads
$dc up -d app
echo "back to $from; to reverse: move /srv/listmonk/uploads.old out of the way, then run this on $before"

Moving to a fresh server? Take docker-compose.yml out of uploads-and-compose.tar.gz in your backup and put it into /srv/listmonk (folder mode 700, file mode 600) before the database first starts there, because Postgres adopts the password it finds then. Paste the setup block (it installs Docker, then stops at the existing file), bring it up via docker compose -f /srv/listmonk/docker-compose.yml up -d, make a home for your copies with install -d -m 0700 /var/backups/listmonk, put the backup folder inside it (say /var/backups/listmonk/2026-11-02-0240) and point the restore script at that folder. Restore only into the same listmonk version or newer: the upgrade page warns against using an upgraded database with an older release.

Staying current, the licence, and inbox manners

Updating is one line, backup first as the upgrade page insists: bash /usr/local/sbin/listmonk-nightly-backup.sh && docker compose -f /srv/listmonk/docker-compose.yml pull app db && docker compose -f /srv/listmonk/docker-compose.yml up -d. The start command applies database changes. The latest tag follows stable releases, and v7 is in beta on the beta tag. To decide for yourself when a new version arrives, put a tag like listmonk/listmonk:v6.2.0 in the image line. To return to an older version, restore the backup you made before upgrading and start the older tag.

listmonk is licensed under the GNU AGPL v3 (the repository's LICENSE file and README). This is how we read it, and it is not legal advice: using it unchanged for your own newsletter asks nothing of you; giving copies to others, changed or not, means giving them the source too, under the same licence; and if you change the code and people use your changed version over the network, you have to offer them that source.

About bulk email in general, and not as legal advice: write only to people who said yes, put an unsubscribe link in every campaign (templates have an UnsubscribeURL tag), and act on unsubscribes. With double opt-in, a new reader gets no campaigns before confirming, and a List-Unsubscribe header is on by default (Settings > Privacy). Laws differ by country; check the ones your readers live under.

Questions people ask

Do I have to run my own mail server too?
No. listmonk only needs SMTP details for a service that sends on your behalf, such as your email provider or a relay you already pay for. Hook up that provider's bounce webhook, or a bounce-only POP3 mailbox, so bad addresses end up on the blocklist instead of being mailed again.
Can I bring my current subscribers with me?
Yes. listmonk imports a CSV file, optionally zipped, either to subscribe people to chosen lists or to blocklist them, and can overwrite or skip records that already exist. The same import is available through its API for repeat syncs.
How do I update a Docker install later?
Take a backup, pull the new images and bring the containers up again; the start command upgrades the database for you. Going back is a restore of the backup from before the update, not just an older tag.

Give your newsletter a server of its own

Choose a small cartridge or an everyday server, put Ubuntu 24.04 on it in the control panel, then work down this page.

Get a server for your newsletter

Read next