SSD Nodes Learn 8GB RAM — $66/yr
Guides Matt ConnorBy Matt Connor

Listmonk: self-host your newsletter on a VPS

Install Listmonk on Ubuntu 24.04 with Postgres, config.toml, a systemd unit and TLS, then connect SMTP and understand what deliverability really costs.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, July 30, 2026.

What a self-hosted newsletter on Listmonk needs

Listmonk is a self-hosted newsletter and mailing list manager: one Go binary, one PostgreSQL database, one config file, one systemd unit. A small VPS runs it without effort, because Listmonk stores subscribers and queues campaigns but never delivers the mail itself. It hands each message to an SMTP (simple mail transfer protocol) server, so your delivery rate is decided by that server's reputation and not by this software.

This guide installs Listmonk v6.2.0, the current release as of July 2026, on Ubuntu 24.04. You need a VPS with a public IP address, a domain name you control, and PostgreSQL 12 or newer. The install takes about an hour. The sending reputation takes weeks, and that part is covered near the end.

Install PostgreSQL and create the database

Ubuntu 24.04 ships PostgreSQL 16 in its own repository, well past what Listmonk needs.

sudo apt update
sudo apt install -y postgresql curl
sudo systemctl enable --now postgresql

Create the role and the database in one psql session. -v ON_ERROR_STOP=1 makes psql exit on the first failed statement, so a typo does not leave you with half a setup that looks finished.

sudo -u postgres psql -v ON_ERROR_STOP=1 <<'SQL'
CREATE USER listmonk WITH PASSWORD 'pick-a-long-random-password';
CREATE DATABASE listmonk OWNER listmonk;
SQL

OWNER listmonk is not decoration. The schema install creates tables, types, indexes and functions, so the role must own the database. Point Listmonk at a database owned by another role and the install stops with permission denied, even after you have run GRANT CONNECT.

Confirm the database exists before you go further.

sudo -u postgres psql -tAc "SELECT datname FROM pg_database WHERE datname='listmonk';"

That prints listmonk. An empty line means the CREATE statement never ran, so read the psql output again.

Install the Listmonk binary

Listmonk publishes a static binary per architecture. Check yours first, because an amd64 binary on an ARM VPS is a file the kernel refuses to execute.

dpkg --print-architecture
cd /tmp
curl -fsSLO https://github.com/knadh/listmonk/releases/download/v6.2.0/listmonk_6.2.0_linux_amd64.tar.gz
tar -xzf listmonk_6.2.0_linux_amd64.tar.gz
sudo install -m 755 listmonk /usr/bin/listmonk
listmonk --version

On an ARM VPS swap amd64 for arm64 in the file name. listmonk --version printing a version string is your first proof that the binary matches the machine.

Generate config.toml and lock it down

--new-config writes config.toml into the current working directory. That is why the cd sits inside the sh -c, and not before sudo.

sudo install -d -m 750 /etc/listmonk
sudo sh -c 'cd /etc/listmonk && listmonk --new-config'

The generated file is short. Under [app], address = "localhost:9000" binds the HTTP server to loopback only, so the admin panel is not reachable from the internet until you put a reverse proxy in front of it. Leave that line alone. Under [db] you get host = "localhost", port = 5432, user = "listmonk", database = "listmonk" and ssl_mode = "disable". Those defaults already match the database you created, so the only line you must change is the password.

ssl_mode = "disable" is correct while Postgres listens on loopback on the same box, because that traffic never leaves the machine. Move the database to another host and set it to require, or the password crosses the network in cleartext.

Edit the password line under [db] so it matches the role, then create the service account and take the file away from every other login.

sudo useradd --system --home-dir /var/lib/listmonk --create-home --shell /usr/sbin/nologin listmonk
sudo chown -R root:listmonk /etc/listmonk
sudo chmod 640 /etc/listmonk/config.toml

Now the service account can read the file and nobody else can.

sudo -u listmonk cat /etc/listmonk/config.toml > /dev/null && echo readable
stat -c '%U:%G %a' /etc/listmonk/config.toml

The first command prints readable. The second prints root:listmonk 640. Any other unprivileged account trying the same cat gets Permission denied, which is the point: this file holds your database password in cleartext, and a server usually has more than one login on it. The same reasoning applies to every service you run, so read least privilege service users once and apply it everywhere.

Create the schema with --install

--install builds the tables and seeds the default settings. Set the first admin login with environment variables, so the account exists before the panel is ever reachable.

sudo -u listmonk env LISTMONK_ADMIN_USER=admin \
  LISTMONK_ADMIN_PASSWORD='another-long-random-password' \
  listmonk --config /etc/listmonk/config.toml --install --yes

--yes answers the confirmation prompt. Read that prompt once before you automate it, because --install is the first time installer and it drops an existing Listmonk schema. Running it a second time on a live database destroys your subscribers. In any script that might run twice, use --install --idempotent --yes, which does nothing when the tables are already present. Schema changes shipped in a new release are applied with --upgrade, never with --install.

Check the result from the database side rather than from the browser.

sudo -u postgres psql -d listmonk -c '\dt'
sudo -u postgres psql -d listmonk -tAc "SELECT username FROM users;"

The first lists the Listmonk tables, among them subscribers, lists, campaigns, templates and bounces. The second prints admin. An empty result from the second means the environment variables never reached the process, so the panel will ask you to create the first user in the browser instead.

Run Listmonk under systemd

Write /etc/systemd/system/listmonk.service.

[Unit]
Description=Listmonk newsletter and mailing list manager
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=listmonk
Group=listmonk
WorkingDirectory=/var/lib/listmonk
ExecStart=/usr/bin/listmonk --config /etc/listmonk/config.toml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true

[Install]
WantedBy=multi-user.target

WorkingDirectory matters because Listmonk resolves relative paths, including a filesystem media upload path, against it. After=postgresql.service only orders the start, it does not wait for Postgres to accept connections, so Restart=on-failure covers the case where Listmonk starts a moment too early and cannot connect.

sudo systemctl daemon-reload
sudo systemctl enable --now listmonk
ss -ltnp | grep 9000
curl -sI http://127.0.0.1:9000/

ss should show 127.0.0.1:9000 in the LISTEN state. curl returning any HTTP status line means the server is answering. curl failing with Connection refused means the process died during startup, and journalctl -u listmonk -n 50 --no-pager will say why. Note that enable --now is the half that survives a reboot: a hand-started process is gone after the next kernel upgrade.

Put nginx and TLS in front

Listmonk speaks plain HTTP on loopback, so nginx terminates TLS (transport layer security) and forwards the request.

server {
    listen 443 ssl;
    server_name lists.example.com;

    client_max_body_size 25m;

    location / {
        proxy_pass http://127.0.0.1:9000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

client_max_body_size has to be raised because subscriber imports and media uploads are file posts, and nginx rejects anything over 1 MB by default with 413 Request Entity Too Large. Issue the certificate with certbot, which also writes the listen 443 ssl lines and the redirect from port 80 for you: the steps are in the Let's Encrypt certificate guide for nginx. Open ports 80 and 443, and leave 9000 closed, since the proxy reaches it over loopback. If the firewall is still untouched, start with ufw firewall basics.

Then open the admin panel and set the root URL under Settings to https://lists.example.com. A fresh install carries http://localhost:9000, and Listmonk writes that value into every unsubscribe link and media URL it puts in an email. Send a campaign before you change it and each recipient gets links pointing at their own machine. They fail for the reader, and to a spam filter they look like a sender who cannot configure their own domain.

Connect SMTP, which is not in config.toml

Search config.toml for an SMTP section and you will not find one. The mail settings live in the database, in the settings table, and you edit them in the admin panel under Settings and SMTP. That is why the generated file stays so short, and it is also why an SMTP change needs no restart.

There are two honest options for the SMTP server itself. Run your own, which puts the reputation entirely in your hands and is a real project on its own: running your own mail server with Mailcow covers what that involves. Or point Listmonk at a transactional relay and let someone else own the IP reputation.

Either way, use port 587 with STARTTLS, or port 465 with implicit TLS. Do not plan on outbound port 25. Most VPS providers block it by default on new accounts, and a blocked port 25 looks exactly like a hung connection, because the packets are dropped rather than refused, so the client waits for a timeout instead of failing fast.

Test it before you trust it. Create a list, add your own address as a subscriber, and send a one recipient campaign. Open the received message and read the full headers. The Authentication-Results header added by the receiving side tells you whether SPF and DKIM passed.

Deliverability is the whole job

Listmonk builds the message, tracks the list, and hands the mail over. Every decision about whether that mail reaches an inbox is made by the receiving provider, using the sending IP address and the sending domain. A new VPS IP has no history at all, and no history is treated as mildly suspicious by every large mailbox provider.

Four things are not optional:

  • An SPF (sender policy framework) TXT record naming the host allowed to send for your domain.
  • A DKIM (domainkeys identified mail) key published as a TXT record, with the signing done by the mail server rather than by Listmonk.
  • A DMARC (domain based message authentication, reporting and conformance) record, which tells receivers what to do when the first two fail.
  • A bounce mailbox that Listmonk reads, so addresses that reject mail leave the list instead of being retried forever.

Then send slowly at first. A domain that has never sent mail and suddenly delivers ten thousand messages in an hour has the exact shape of a compromised account, so it gets filtered as one. Start with your most engaged subscribers and raise the volume over days.

Every template also needs a working unsubscribe link. In a Listmonk template that is {{ UnsubscribeURL }}, and the campaign body lands where {{ template "content" . }} sits, which must appear exactly once per template. A campaign with no unsubscribe link earns spam complaints instead of unsubscribes, and complaints are the fastest way to lose a sending reputation you spent weeks building.

Backups, and what a restore actually needs

Two things must leave the box: the database dump and config.toml. Add the media directory if you upload images into campaigns.

sudo -u postgres pg_dump -Fc listmonk > listmonk-$(date +%F).dump

That dump holds subscribers, campaigns, templates and every setting, including the SMTP credentials, so encrypt it and keep it off this server. Scheduling that is a solved problem: see encrypted restic backups to remote storage. config.toml is a few lines but holds the database password, so treat it the same way.

Upgrades follow one order. Stop the service, take a dump, replace the binary in /usr/bin, run listmonk --config /etc/listmonk/config.toml --upgrade, then start the service. Schema migrations only run forward, so that dump is your only route back.

Why does Listmonk fail to start?

Read the journal first with journalctl -u listmonk -n 50 --no-pager. Nearly every startup failure is one line in the [db] block.

pq: password authentication failed for user "listmonk" means the password in [db] does not match the Postgres role. The pq prefix is the Postgres driver reporting the server's rejection, so the config was read correctly and the credentials were wrong. Reset the role with sudo -u postgres psql -c "ALTER USER listmonk WITH PASSWORD 'new-password';" and put the identical string in the file.

pq: database "listmonk" does not exist means the database value in [db] does not name a real database. sudo -u postgres psql -l lists what is actually on the server, including the spelling you used by mistake.

permission denied during --install means the role can connect but does not own the database, so it cannot create tables in it. Fix it with sudo -u postgres psql -c "ALTER DATABASE listmonk OWNER TO listmonk;" and run the install again.

The service never starts and the journal names the config file. A process running as listmonk cannot open a config.toml left as root:root with mode 600. stat -c '%U:%G %a' /etc/listmonk/config.toml should print root:listmonk 640, and the directory above it should be root:listmonk 750.

The panel works but no mail arrives. That is not a startup problem. Check Settings and SMTP first, then the campaign's own log in the admin panel, which records the error the mail server returned for each attempt.

FAQ

Do I need my own mail server to use Listmonk?

No. Listmonk is not a mail server. It needs SMTP credentials for a server that accepts your mail and delivers it, which can be a transactional relay or a mail server you run yourself. Set those credentials under Settings and SMTP in the admin panel, not in config.toml, because the mail settings live in the database. Use port 587 with STARTTLS or port 465 with implicit TLS, since most VPS providers block outbound port 25 on new accounts.

The root URL setting is still at its install default of http://localhost:9000. Listmonk writes that value into unsubscribe links and media URLs at the moment a campaign is sent. Open Settings in the admin panel, set the root URL to your real HTTPS address, and save. Messages already delivered cannot be corrected, so send yourself a test campaign and click the unsubscribe link in it before you mail a real list.

Will running --install again wipe my subscribers?

Yes. --install is the first time installer and it drops the existing Listmonk schema, and --yes removes the prompt that would have warned you. In any script that might run twice, use --install --idempotent --yes, which does nothing when the tables already exist. To apply the schema changes in a new release, stop the service, take a pg_dump, then run --upgrade.

Why does Listmonk say password authentication failed for user listmonk?

The password in the [db] block of /etc/listmonk/config.toml does not match the Postgres role of the same name. The journal line is pq: password authentication failed for user "listmonk", and pq is the Postgres driver passing on the server's rejection, which means the config file was found and read. Reset the role's password with sudo -u postgres psql -c "ALTER USER listmonk WITH PASSWORD 'new-password';", write the identical string into the config file, then run sudo systemctl restart listmonk.