Analytics cookies only (Google Analytics, PostHog). No ad networks, no cross-site tracking. See our privacy policy.
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-host listmonk, the open-source newsletter and mailing-list manager, on a VPS with a pinned Docker image, PostgreSQL, Caddy HTTPS, the right root URL and tested backups.
listmonk is a self-hosted newsletter and mailing-list manager. It is a single Go program with a Vue admin interface, backed by PostgreSQL, and licensed AGPL-3.0. It manages subscriber lists, campaigns, templates and transactional mail, and sends them through SMTP servers you configure. It is the self-hosted answer to the per-subscriber pricing of Mailchimp, and the part you operate is small: one container, one database.
One boundary is worth stating up front. listmonk does not deliver mail itself. It hands messages to an SMTP server or an email provider you choose, and the delivery reputation, bounce handling and sending limits belong to that provider. This guide gets the application running, secured and backed up; connecting a sending provider is the one step you must do yourself. If you are weighing it against a similar tool, our Keila vs listmonk comparison covers the trade-offs, and Keila has its own page.
listmonk's own footprint is small. When we installed the stack on a 2 vCPU, 8 GB test box and let it settle, it used about 66 MB of RAM at idle, and our listing puts the floor at 512 MB. PostgreSQL is the larger half of that. A 1 GB server leaves plenty of headroom for the OS and Caddy on top of it; we did not load-test large lists.
What grows is the database (subscribers, campaign history and click and view records) and the uploads volume. Sending speed is limited by your SMTP provider, not by the server. Pick a plan with disk you can expand.
Update the system and install the few tools this guide uses:
sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl openssl ufw dnsutils python3
If you are logged in as root, create a non-root sudo user and continue as that user:
adduser deploy
usermod -aG sudo deploy
Then open only SSH and the two ports the reverse proxy needs:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
On a Docker host a published container port goes around ufw, which is why the
Compose file below publishes listmonk on 127.0.0.1 only and leaves Caddy as the
public door. (The upstream file also publishes the web port on every interface.)
Paid link — we earn a commission if you shop through it.
If Docker is not on the server yet, the convenience script installs Docker Engine and the Compose plugin (the Docker and Compose on Ubuntu guide covers the details):
command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo docker compose version
Create a directory and a .env file. Set your hostname first (this guide uses
listmonk.example.com; replace it with yours). The database password and the
administrator password are generated here and never typed:
mkdir -p ~/listmonk && cd ~/listmonk
DOMAIN=listmonk.example.com
cat > .env <<EOF
DOMAIN=$DOMAIN
POSTGRES_PASSWORD=$(openssl rand -hex 16)
LISTMONK_ADMIN_USER=admin
LISTMONK_ADMIN_PASSWORD=$(openssl rand -hex 16)
EOF
chmod 600 .env
grep -c '=.\{5,\}' .env
The last line should print 4. Now the Compose file. It follows the official one
from the listmonk repository, with four changes: the image is pinned to a
release instead of latest, passwords come from .env instead of the shared
default listmonk, the web port is bound to loopback, and uploads live in a
named volume. Caddy is added as a third service:
cd ~/listmonk
cat > docker-compose.yml <<'EOF'
services:
app:
image: listmonk/listmonk:v6.2.0
restart: unless-stopped
ports:
- "127.0.0.1:9000:9000"
depends_on:
db:
condition: service_healthy
command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"]
environment:
LISTMONK_app__address: 0.0.0.0:9000
LISTMONK_db__user: listmonk
LISTMONK_db__password: ${POSTGRES_PASSWORD}
LISTMONK_db__database: listmonk
LISTMONK_db__host: db
LISTMONK_db__port: 5432
LISTMONK_db__ssl_mode: disable
LISTMONK_db__max_open: 25
LISTMONK_db__max_idle: 25
LISTMONK_db__max_lifetime: 300s
TZ: Etc/UTC
LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USER}
LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD}
volumes:
- listmonk-uploads:/listmonk/uploads
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: listmonk
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: listmonk
healthcheck:
test: ["CMD-SHELL", "pg_isready -U listmonk"]
interval: 10s
timeout: 5s
retries: 6
volumes:
- listmonk-data:/var/lib/postgresql/data
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
DOMAIN: ${DOMAIN}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on:
- app
volumes:
listmonk-data:
listmonk-uploads:
caddy_data:
EOF
cat > Caddyfile <<'EOF'
{$DOMAIN} {
encode zstd gzip
reverse_proxy app:9000
}
EOF
sudo docker compose config --quiet && echo "compose config valid"
The long command is the official one and does three things in order: installs
the database schema on an empty database (--install --idempotent does nothing
on later starts), applies any pending schema upgrades (--upgrade), then starts
the server. Because LISTMONK_ADMIN_USER and LISTMONK_ADMIN_PASSWORD are set
on that first start, the Super Admin account is created for you and there is
no open sign-up page for a stranger to claim.
Start the stack and wait for the web server. The loop is bounded, so a broken start fails instead of hanging:
cd ~/listmonk
sudo docker compose up -d
for i in $(seq 1 30); do
curl -sf -o /dev/null http://127.0.0.1:9000/admin/login && break
sleep 2
done
curl -s -o /dev/null -w 'admin login page: HTTP %{http_code}\n' http://127.0.0.1:9000/admin/login
sudo docker compose ps --format 'table {{.Service}}\t{{.Status}}'
Create an A record for your hostname (say listmonk.example.com) pointing
at the server's public IP, and confirm it resolves before Caddy tries to get a
certificate:
dig +short listmonk.example.com
Caddy is already running. It requests the certificate as soon as the name resolves and renews it on its own (see Automatic HTTPS with Caddy for how that works). Check that HTTPS answers, retrying while the certificate is issued:
cd ~/listmonk
. ./.env
out=000
for i in $(seq 1 24); do
out=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$DOMAIN/admin/login") && [ "${out%% *}" = "200" ] && break
sleep 5
done
echo "admin login page, status and certificate check: $out"
[ "${out%% *}" = "200" ] || false
A second number of 0 means the certificate verified.
This is the setting people miss. listmonk builds every link it puts in an email
(unsubscribe, view in browser, click and open tracking) from a root URL
setting, and a fresh install ships with http://localhost:9000. The site works,
the admin works, and the first campaign goes out with unsubscribe links pointing
at the reader's own computer. Set it before you import anyone.
You can do it in the admin under Settings, General, Root URL, or through the API, which is what this guide does so it can be repeated. First check the value listmonk has now. Signing in with the API means posting the login form with the nonce it embeds, and keeping the session cookie:
cd ~/listmonk
. ./.env
J=$(mktemp)
N=$(curl -s -c "$J" "https://$DOMAIN/admin/login" | sed -n 's/.*name="nonce" value="\([^"]*\)".*/\1/p')
curl -s -b "$J" -c "$J" -o /dev/null -d "nonce=$N" -d "next=/admin" \
--data-urlencode "username=$LISTMONK_ADMIN_USER" --data-urlencode "password=$LISTMONK_ADMIN_PASSWORD" \
"https://$DOMAIN/admin/login"
curl -s -b "$J" "https://$DOMAIN/api/settings" | python3 -c "import sys,json;print('root URL now:', json.load(sys.stdin)['data']['app.root_url'])"
cp "$J" ~/listmonk/.session
It prints http://localhost:9000. Now write the correct address back. The
settings endpoint takes the whole settings object, so the script reads it,
changes one key and sends it back; listmonk then reloads itself, which takes a
few seconds:
cd ~/listmonk
. ./.env
J=~/listmonk/.session
curl -s -b "$J" "https://$DOMAIN/api/settings" | ROOT="https://$DOMAIN" python3 -c "
import sys, json, os
d = json.load(sys.stdin)['data']
d['app.root_url'] = os.environ['ROOT']
json.dump(d, open('/tmp/listmonk-settings.json', 'w'))"
curl -s -b "$J" -X PUT -H 'Content-Type: application/json' --data @/tmp/listmonk-settings.json \
-o /dev/null -w 'settings update: HTTP %{http_code}\n' "https://$DOMAIN/api/settings"
rm -f /tmp/listmonk-settings.json
sleep 10
curl -s -b "$J" "https://$DOMAIN/api/settings" | python3 -c "import sys,json;print('root URL now:', json.load(sys.stdin)['data']['app.root_url'])"
The last line should print your https address. The .session file holds a
login cookie, so remove it when you are done with rm ~/listmonk/.session.
The installer seeds a few things you will want to clean up before real use. We
found two demo subscribers (john@example.com and anon@example.com), a draft
campaign named "Test campaign" and two sample lists. The default SMTP entry
points at smtp.yoursite.com and is enabled, and the default From address is
listmonk <noreply@listmonk.yoursite.com>. Until you replace those, any campaign
you send fails or goes out with a From address on a domain you do not own. Check
what is there:
cd ~/listmonk
. ./.env
J=~/listmonk/.session
curl -s -b "$J" "https://$DOMAIN/api/subscribers" | python3 -c "import sys,json;d=json.load(sys.stdin)['data'];print('subscribers:', [r['email'] for r in d['results']])"
curl -s -b "$J" "https://$DOMAIN/api/campaigns" | python3 -c "import sys,json;d=json.load(sys.stdin)['data'];print('campaigns:', [r['name'] for r in d['results']])"
Open https://listmonk.example.com/admin and sign in as admin with the
password from .env:
grep '^LISTMONK_ADMIN_PASSWORD=' ~/listmonk/.env
Then, in the admin:
Not every URL is private. The admin lives under /admin and /api, but the
subscription forms, tracked links, open-tracking pixel and /uploads must stay
reachable by your readers, so do not firewall the whole site behind a login at the
proxy.
State lives in the PostgreSQL database (subscribers, lists, campaigns, settings),
the uploads volume and your .env. Dump the database in PostgreSQL's custom
format and archive the uploads and the stack files. These files hold your
subscribers' email addresses, so they are created readable by their owner only:
cd ~/listmonk
( umask 077
sudo docker compose exec -T db pg_dump -U listmonk -Fc listmonk > ~/listmonk-db-$(date +%F).dump
sudo docker compose exec -T app tar czf - -C /listmonk/uploads . > ~/listmonk-uploads-$(date +%F).tar.gz
tar czf ~/listmonk-stack-$(date +%F).tar.gz docker-compose.yml Caddyfile .env )
ls -lh ~/listmonk-db-*.dump ~/listmonk-uploads-*.tar.gz ~/listmonk-stack-*.tar.gz
Copy these off the server and run them from cron. A backup you have never restored is a guess, so prove the dump loads. This restores it into a throwaway PostgreSQL container and compares the subscriber count with the live database:
cd ~/listmonk
sudo docker rm -f listmonk-restore-test >/dev/null 2>&1
sudo docker run -d --name listmonk-restore-test -e POSTGRES_PASSWORD=restore-test -e POSTGRES_DB=listmonk postgres:17-alpine >/dev/null
for i in $(seq 1 40); do
sudo docker exec listmonk-restore-test pg_isready -U postgres -d listmonk >/dev/null 2>&1 && break
sleep 2
done
sleep 3
sudo docker exec -i listmonk-restore-test pg_restore -U postgres -d listmonk --no-owner < ~/listmonk-db-$(date +%F).dump
LIVE=$(sudo docker compose exec -T db psql -U listmonk -d listmonk -tAc "select count(*) from subscribers")
RESTORED=$(sudo docker exec listmonk-restore-test psql -U postgres -d listmonk -tAc "select count(*) from subscribers")
echo "subscribers live: $LIVE, restored: $RESTORED"
sudo docker rm -f listmonk-restore-test >/dev/null
[ "$LIVE" = "$RESTORED" ] && [ "$LIVE" -ge 1 ]
The image tag is the version. Back up first (the previous section), then change
the tag in docker-compose.yml to the release you want:
cd ~/listmonk
sed -i 's|listmonk/listmonk:v6.2.0|listmonk/listmonk:NEW_VERSION|' docker-compose.yml
Then pull and recreate:
cd ~/listmonk
sudo docker compose pull
sudo docker compose up -d
sudo docker compose logs app --tail 5
The start command runs ./listmonk --upgrade on every start, so a new image
migrates the schema by itself; the log line no upgrades to run means the
database was already current. Read the
release notes first, and treat a
downgrade as a restore: a database upgraded by a newer version should not be used
with an older one, so you roll back by restoring the pre-upgrade dump together
with the old tag.
Unsubscribe or tracking links point at localhost. The root URL was never
set. Follow the Set the root URL section; links already sent keep the old address.
Pointing an uptime monitor at listmonk. Use /health, which listmonk
serves without a sign-in. /admin/login also returns a 200 when the web server
is up, and the HTTPS check above works too. Authenticated /api/... paths need
credentials, so a monitor without them will be refused.
Campaigns stay in "running" and nothing arrives. Open Settings, SMTP and
use Test connection. Check the app log with
sudo docker compose logs app --tail 50 for authentication or connection errors,
and confirm your host does not block the port you chose.
Caddy never gets a certificate. Confirm dig +short listmonk.example.com
returns the server's IP and that your provider's network firewall (not just
ufw) allows inbound 80 and 443.
Changing the admin password in .env has no effect. listmonk's documentation
says to set those variables only the first time: they create the Super Admin on
the first start. Change the password later from inside the admin interface, and
update your own notes, not the file.
You are done when you can: load https://listmonk.example.com/admin with a valid
certificate, sign in as admin, see your https address as the root URL, send a
test message through your SMTP provider, and restore a database dump into a
throwaway container, as above.
From here, build a list and a subscription form, set up your campaign template, and send a first small campaign to yourself before importing a real audience. For ranked server options, see Best VPS for self-hosting. We confirmed the install, the HTTPS certificate, the login, the root-URL change and the backup restore in our own run. We did not send a campaign through an SMTP provider, so run the test connection and a self-send before you rely on it.