The two containers and where state lives
The docs give no CPU or memory figure. The installation page asks for PostgreSQL 12 or newer when you run the binary; the compose file ships postgres:17-alpine. The performance page warns that dashboard and list counts slow down as subscribers, views and clicks accumulate, and offers Settings > Performance > cache slow database queries plus a periodic VACUUM ANALYZE.
| Piece | In the official file | Holds |
|---|---|---|
| App | listmonk/listmonk:latest, container listmonk_app, port 9000 | Nothing durable |
| Database | postgres:17-alpine, listmonk_db, on 127.0.0.1:5432 | Volume listmonk-data: subscribers, lists, campaigns, users, settings |
| Media | bind mount ./uploads to /listmonk/uploads | Media library files |
| Low-level config | LISTMONK_* variables | Listen address, database host and credentials |
A config.toml key maps to an environment variable as LISTMONK_ plus its path, periods written as double underscores: [db] password is LISTMONK_db__password. The compose command passes --config '', so the container reads variables only. Everything else is set in the admin Settings screen and stored in Postgres.
Choosing the machine
With no official sizing, Postgres sets the pace: as the database grows, its memory and disk matter more than core count (our reasoning, not a docs figure). For one organisation's newsletter, a Moonshot cartridge from the dedicated server range fits, since the range offers cartridges for one service per machine. Take a single-socket rack server when other databases share the box; PostgreSQL on a dedicated server covers tuning it.
Console scope: racked single-, dual- and quad-socket servers have an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC), as do the m510 and m710x cartridges; other cartridges have power control only. Not suitable: X-Gene ARM cartridges, whose newest image is Ubuntu 18.04.
Ready servers go live about 30 minutes once payment is approved; built-to-order lines take 4 to 24 hours. IPv6 is added at checkout at no charge. The servers are ours, on our own network (AS19624), in the five cities on our data centers page.
Installing on loopback
As root (sudo -i) on Ubuntu 24.04: ufw goes on first with SSH allowed on both address families, Docker and Compose v2 come from Ubuntu's archive, and nothing starts unless the Engine is 28 or newer, the release that made 127.0.0.1-only publishing truly local (moby#45610). Two changes of ours to the official file: the app mapping becomes "127.0.0.1:9000:9000", and the database password listmonk becomes a random one, written only after the file is mode 600. The block refuses to run when the file exists, since a new password would not match the initialised database, and starts the containers only if both edits are found.
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
major=$(docker version 2>/dev/null | awk '/^Server/{s=1} s && /Version:/{sub(/.*Version: */,""); sub(/[.].*/,""); print; exit}')
f=/opt/listmonk/docker-compose.yml
if [ "${major:-0}" -lt 28 ]; then
echo "STOP: Docker Engine ${major:-missing} is older than 28, nothing started"
elif [ -e "$f" ]; then
echo "STOP: $f exists, refusing to replace its database password"
else
install -d -m 700 /opt/listmonk &&
curl -fsSL -o "$f" https://github.com/knadh/listmonk/raw/master/docker-compose.yml &&
chmod 600 "$f" && pw=$(openssl rand -hex 24) && [ -n "$pw" ] &&
sed -i -e "s/&db-password listmonk$/\&db-password $pw/" \
-e 's/- "9000:9000"/- "127.0.0.1:9000:9000"/' "$f" &&
grep -q "&db-password $pw" "$f" && grep -q '"127.0.0.1:9000:9000"' "$f" &&
docker compose -f "$f" up -d && sleep 20 &&
ss -ltn | grep -q '127.0.0.1:9000 ' && ! ss -ltn | grep -Eq '(0\.0\.0\.0|\*|\[::\]):9000 ' &&
echo "ok: Engine $major, listmonk on 127.0.0.1:9000 only"
fi
It must end with ok. The reason for the mapping: a port with no address is published on 0.0.0.0 and [::], and published traffic skips ufw on IPv4, and on IPv6 too when Docker's own IPv6 networking is on. The unchanged start command runs --install --idempotent --yes (schema only on an empty database), --upgrade --yes, then the server. Per cmd/install.go the install adds sample lists, subscribers and a campaign; delete the samples before sending.
Super Admin and Root URL, over the tunnel
While no user exists, /admin serves a first-time form, and whoever submits it becomes Super Admin with every permission (cmd/auth.go). So it happens before anything is public.
- From your workstation, as yourself:
ssh -N -L 9000:127.0.0.1:9000 you@server.example. - Open http://localhost:9000/admin; enter an e-mail, a username of 3+ characters and a password of 8+.
- Settings > General > Root URL:
https://news.example.com, no trailing slash. It defaults to http://localhost:9000 (schema.sql), and cmd/init.go builds unsubscribe, opt-in and tracked-link URLs from it. - Settings > Media: upload path
/listmonk/uploads, as the compose file's comment asks.
The installation page also accepts LISTMONK_ADMIN_USER and LISTMONK_ADMIN_PASSWORD on the first start. We skip that: the password would be kept by your shell's history file and by the container environment (docker inspect) until the container is recreated.
Outgoing mail and bounce handling
listmonk does not deliver mail itself. Settings > SMTP takes one or more servers with the host, port, credentials and TLS mode your relay or email provider documents; several can be routed by From address. The configuration page covers Retries (silent re-sends over other pooled connections) and the HELO hostname, which some providers, Google Workspace among them, need to be a real FQDN. Send the test message from that screen first.
Bounce processing stays off until enabled under Settings > Bounces. The bounce page offers a dedicated POP3 mailbox, never a real inbox since listmonk deletes what it reads, set as Return-Path through the SMTP custom headers; or a provider webhook such as /webhooks/service/ses, with SES, Azure ACS, SendGrid, Postmark, Forward Email and Lettermint supported. Unmatched bounces count as soft. Defaults in schema.sql: two soft bounces do nothing; one hard bounce or complaint blocklists.
Opening it to the internet with Caddy
The configuration page lists /subscription/*, /link/*, /campaign/*, /public/*, /uploads/* and the provider webhooks as public routes, and /admin/*, /api/* and /webhooks/bounce as private ones.
- DNS: an A record for news.example.com, plus AAAA when you serve it over the server's IPv6 address. Continue once
getent hosts news.example.comanswers. - Firewall: Caddy is an ordinary host process, so ufw blocks it on both families until
ufw allow 80,443/tcp. - Caddy, in Ubuntu 24.04's archive: `apt-get install -y caddy && printf '%s\n' 'news.example.com {' ' @private {' ' path /admin* /api/* /webhooks/bounce' ' not path /api/public/*' ' }' ' respond @private 403' ' reverse_proxy 127.0.0.1:9000' '}' > /etc/caddy/Caddyfile && caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy`.
The @private matcher is our addition from that table: admin and API stay on the tunnel, /api/public/* (public lists, subscription and captcha in cmd/handlers.go) stays open. Remove it if colleagues need the admin in a browser, and rely on listmonk's login.
Backups and a restore you can undo
A full set is the dump, the uploads and the compose file, which holds the password the data volume was initialised with. pipefail stops a failed pg_dump passing as an archive; both archives are tested; sets past 14 days are pruned only after a new one passes. Test with bash /usr/local/sbin/listmonk-backup.sh (last line ok), then pull /var/backups/listmonk elsewhere with plain rsync -a, never --delete.
install -d -m 700 /var/backups/listmonk /var/log/listmonk-backup
install -m 700 /dev/null /usr/local/sbin/listmonk-backup.sh
cat > /usr/local/sbin/listmonk-backup.sh <<'EOF'
set -euo pipefail
umask 077
c="docker compose -f /opt/listmonk/docker-compose.yml"
d=/var/backups/listmonk/$(date +%F-%H%M)
mkdir "$d"
$c exec -T db pg_dump -U listmonk listmonk | gzip > "$d/db.sql.gz"
gzip -t "$d/db.sql.gz"
tar -C /opt/listmonk -czf "$d/files.tar.gz" uploads docker-compose.yml
tar -tzf "$d/files.tar.gz" > /dev/null
find /var/backups/listmonk -mindepth 1 -maxdepth 1 -name '20*' -mtime +14 -exec rm -rf {} +
echo "ok $d"
EOF
printf '%s\n' '15 3 * * * root umask 077; bash /usr/local/sbin/listmonk-backup.sh >> /var/log/listmonk-backup/run.log 2>&1' > /etc/cron.d/listmonk-backup
The restore uses the upgrade page's method (drop and recreate the public schema, load the dump) with guards of ours: it tests the set, stops the app, saves and tests the current state, and refuses if an earlier move-aside remains. Create it as root with install -m 700 /dev/null /usr/local/sbin/listmonk-restore.sh, paste, and pass a set such as /var/backups/listmonk/2026-10-24-0315. To undo, delete /opt/listmonk/uploads.previous (the pre-restore set holds those files too) and rerun with the pre-restore set; once satisfied, delete uploads.previous instead.
# /usr/local/sbin/listmonk-restore.sh; as root: bash /usr/local/sbin/listmonk-restore.sh SET_DIR
set -euo pipefail
umask 077
src=$(realpath "${1:?name the backup set directory}")
c="docker compose -f /opt/listmonk/docker-compose.yml"
keep=/var/backups/listmonk/pre-restore-$(date +%F-%H%M)
if [ -e "$keep" ] || [ -e /opt/listmonk/uploads.previous ]; then
echo "STOP: $keep or /opt/listmonk/uploads.previous already exists" >&2; exit 1
fi
gzip -t "$src/db.sql.gz"
tar -tzf "$src/files.tar.gz" > /dev/null
$c stop app
mkdir "$keep"
$c exec -T db pg_dump -U listmonk listmonk | gzip > "$keep/db.sql.gz"
tar -C /opt/listmonk -czf "$keep/files.tar.gz" uploads docker-compose.yml
gzip -t "$keep/db.sql.gz"
tar -tzf "$keep/files.tar.gz" > /dev/null
echo "current state saved and verified in $keep"
$c exec -T db psql -v ON_ERROR_STOP=1 -U listmonk -d listmonk \
-c 'DROP SCHEMA public CASCADE;' -c 'CREATE SCHEMA public;'
gunzip < "$src/db.sql.gz" | $c exec -T db psql -q -v ON_ERROR_STOP=1 -U listmonk -d listmonk
mv /opt/listmonk/uploads /opt/listmonk/uploads.previous
tar -C /opt/listmonk -xzf "$src/files.tar.gz" uploads
$c up -d app
echo "restored from $src; to undo: rm -rf /opt/listmonk/uploads.previous, then run again with $keep"
On a new server, place the docker-compose.yml from the backup set (files.tar.gz) in /opt/listmonk (mode 700 directory, 600 file) before Postgres ever starts, since it takes its password from there when initialising. Run the install block (it stops at the existing file), docker compose -f /opt/listmonk/docker-compose.yml up -d, create the backup directory with install -d -m 700 /var/backups/listmonk, copy the set into it (e.g. /var/backups/listmonk/2026-10-24-0315), and restore from that path. Per the upgrade page, never load a database upgraded by a newer listmonk into an older one.
Updates, licence and sending rules
Back up first, as the upgrade page says; our chained form of its commands: bash /usr/local/sbin/listmonk-backup.sh && docker compose -f /opt/listmonk/docker-compose.yml pull && docker compose -f /opt/listmonk/docker-compose.yml up -d. The start command then applies migrations. latest tracks stable releases, and the v7 beta sits on the beta tag; pin a release such as listmonk/listmonk:v6.2.0 to hold one. Rolling back means restoring the pre-upgrade dump under the older tag.
Licence: LICENSE in knadh/listmonk is the GNU AGPL v3, as the README says. Our general reading, not legal advice: unmodified use for your own lists adds no duty; handing copies to others, modified or not, obliges you to hand over its source under AGPL-3.0 as well; and when users reach your modified build across a network, its source must be offered to them.
Bulk mail, a general note and not legal advice: mail only people who opted in, keep the unsubscribe link (the UnsubscribeURL template tag) in every campaign, and honour removals. Double opt-in lists send nothing until the subscriber confirms, and Settings > Privacy adds a List-Unsubscribe header by default. Rules vary by country.
Questions
Is listmonk a mail server?
No. It manages subscribers, lists, templates and campaigns, and hands each message to the SMTP servers in Settings > SMTP. Use a relay or provider you trust, and feed its bounces back through a webhook or a dedicated POP3 mailbox.
Binary or Docker?
Both are documented. The binary route is ./listmonk --new-config, edit config.toml, ./listmonk --install, then run it against a PostgreSQL 12+ you manage. The compose file bundles Postgres and runs install and upgrade on every start.
Where do admin accounts live since v4?
In the database, managed under Settings > Users. The upgrade page says an old admin_username and admin_password in TOML are imported by the upgrade and should then be removed; API integrations need an API user created there.