# Can I self-host Mailchimp?

**YES, BUT** — it's called listmonk. ONE WEEKEND setup · ~4 hours to running · 1 GB RAM minimum · $20/mo you stop paying ($240/yr on the Standard plan).

listmonk authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/mailchimp/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install listmonk 6.2.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server. It goes inside every campaign they send, in the
unsubscribe link and the archive URL, and a hostname swapped out later starts its sending
reputation from zero.

Ask a second question in the same breath and wait for both answers: do they have an SMTP relay
account, with a host, a port, a username and a password. listmonk delivers nothing itself. It
hands finished messages to someone else's mail server, and step 7 waits on those four.

listmonk needs 1024 MB of RAM available and 5 GB free on /srv. Both images publish amd64 and
arm64. Measure all four first:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/listmonk /srv/listmonk/backups
sudo install -d -m 700 /srv/listmonk/postgres
sudo install -d -m 755 /srv/listmonk/uploads
ls -la /srv/listmonk
```

Assert: `ls -la` shows `backups` owned by the login user, `postgres` at mode `700` owned by
root, and `uploads` at `755`. Leave the last two alone: both images claim their own directory
on first start, PostgreSQL chowning its data to the uid it runs as and the listmonk entrypoint
chowning /listmonk to PUID:PGID, default 0:0.

## 3. Secrets

Two secrets: the PostgreSQL password and the Super Admin password, both generated here. Do not
print either, do not repeat them in your summary, and do not put them in any log line.

```bash
umask 077
cat > /srv/listmonk/.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
LISTMONK_ADMIN_USER=admin
LISTMONK_ADMIN_PASSWORD=$(openssl rand -base64 30)
EOF
chmod 600 /srv/listmonk/.env
umask 022
ls -l /srv/listmonk/.env
```

Assert: the file exists with mode `-rw-------`. Hex for the database password, which travels
inside a connection string; base64 for the admin one, which a human pastes into a login form.
Upstream reads `LISTMONK_ADMIN_USER` and `LISTMONK_ADMIN_PASSWORD` during the one-time install
pass, so the Super Admin exists the first time the container starts. Without them, the first
person to load the admin URL on a public hostname is handed a form that creates the Super
Admin. Step 7 says how to read the password.

## 4. compose.yml

```bash
cat > /srv/listmonk/compose.yml <<'EOF'
# listmonk · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://listmonk.app/docs/installation/
#   variable reference . https://listmonk.app/docs/configuration/
#   upgrade path ....... https://listmonk.app/docs/upgrade/
#   health route ....... https://github.com/knadh/listmonk/blob/v6.2.0/cmd/handlers.go
#
# Two services: listmonk and the PostgreSQL it keeps subscribers, campaigns and
# click records in. Upstream states Postgres 12 or newer is the only dependency.
# The three-phase command is upstream's own: --install --idempotent lays the
# schema down once on an empty database, --upgrade applies migrations when the
# image moves, the third invocation runs the server, and --config '' means "no
# TOML file, read the LISTMONK_ environment variables". The root URL and the
# SMTP relay are settings rows a human fills in from the admin UI. Digests read
# on 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    container_name: listmonk-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: listmonk
      POSTGRES_USER: listmonk
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/listmonk/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U listmonk -d listmonk"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  app:
    image: listmonk/listmonk:v6.2.0@sha256:f535d59e14991337a9f2d570273685378ae86b0d7698c3e00da444e3bc205286
    container_name: listmonk-app
    restart: unless-stopped
    env_file: /srv/listmonk/.env
    command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"]
    environment:
      # Every interface inside the container; the way in is the port below.
      LISTMONK_app__address: 0.0.0.0:9000
      LISTMONK_db__host: db
      LISTMONK_db__port: 5432
      LISTMONK_db__user: listmonk
      LISTMONK_db__database: listmonk
      LISTMONK_db__password: ${POSTGRES_PASSWORD}
      LISTMONK_db__ssl_mode: disable
      LISTMONK_db__max_open: 25
      LISTMONK_db__max_idle: 25
      LISTMONK_db__max_lifetime: 300s
      TZ: Etc/UTC
    volumes:
      # Images uploaded through Admin -> Media. The entrypoint chowns /listmonk
      # to PUID:PGID, default 0:0, so this ends up root-owned on the host.
      - /srv/listmonk/uploads:/listmonk/uploads
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8096.
      - "127.0.0.1:8096:9000"
    depends_on:
      db:
        condition: service_healthy
EOF
cd /srv/listmonk && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The `command:` line is upstream's three-phase form: pass one
creates the schema and is idempotent, a no-op on later boots; pass two applies migrations,
which is what makes step 9 three commands; pass three runs the server.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. The copy on line one matters: a syntax error takes down every other site.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-listmonk
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# listmonk · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://listmonk.app/docs/configuration/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That same hostname goes
# into listmonk's Root URL setting after the first login: unsubscribe links and
# archive URLs are built from that setting, not from the request. Upstream splits
# its routes into private admin paths (/admin/*, /api/*) and public ones
# (/subscription/*, /link/*, /campaign/*, /archive) that subscribers have to
# reach; both halves answer on this one hostname.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# SAMEORIGIN rather than DENY: the campaign editor previews a campaign
		# in an iframe served from this same origin.
		X-Frame-Options "SAMEORIGIN"
		# A subscription URL carries the subscriber's UUID in the path, so a
		# full Referer on an outbound click hands that UUID to a third party.
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8096 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8096
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-listmonk, reload, and report what it objected to. Caddy issues the
certificate on the first request and renews it, so there is nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's, and idempotent on a box Prompt Zero already configured.

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects, 443/tcp is the only way in, 443/udp is
HTTP/3. 8096 stays closed because compose binds it to 127.0.0.1, and 5432 because compose
publishes no host port at all. Nothing opens for mail: the relay connection is outbound, which
ufw already allows. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8096 or 5432.

## 7. Start and verify

```bash
cd /srv/listmonk
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health
curl -sS https://<DOMAIN>/admin/login | grep -o '<h2>Login</h2>'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/lists
```

Assert all four, and print what you received for each. The loop ends on `200`. The health call
prints `{"data":true}`. The grep prints `<h2>Login</h2>`, the first screen. The unauthenticated
API call prints `403`, the security assert here: the admin API is answering and refusing a
caller with no session. If any of the four misses, stop, run
`docker compose logs --tail 40 app` and `docker compose logs --tail 20 db`, and name the likely
cause. A grep printing nothing while the page contains `New user` means the database was not
empty when the app first started, so step 3's Super Admin was never created: run
`docker compose down`, `sudo rm -rf /srv/listmonk/postgres`, recreate it as in step 2, and run
this block again. A running container is not success.

STOP: tell the user to do these three things and wait. Do not continue until they confirm.

- Read the admin password with `sudo grep LISTMONK_ADMIN_PASSWORD /srv/listmonk/.env`, put it
  in their password manager, and log in at https://<DOMAIN>/admin/login as `admin`.
- Settings -> General: set Root URL to `https://<DOMAIN>` and the default from-address to one
  on a domain they control. Both ship as examples and both end up inside every message that
  leaves this server.
- Settings -> SMTP: the seeded first entry is switched on and points at `smtp.yoursite.com`
  with placeholder credentials. Replace its host, port, username and password with the relay's
  and use the Test connection button before saving.

listmonk reloads itself when settings are saved. Once the user confirms, check the three
values actually moved:

```bash
cd /srv/listmonk
docker compose exec -T db psql -U listmonk -d listmonk -tAc "SELECT key, value FROM settings WHERE key IN ('app.root_url', 'app.from_email')"
docker compose exec -T db psql -U listmonk -d listmonk -tAc "SELECT count(*) FROM settings, jsonb_array_elements(value) AS s WHERE key = 'smtp' AND (s->>'enabled')::boolean AND s->>'host' = 'smtp.yoursite.com'"
curl -sS https://<DOMAIN>/health
```

Assert: the first prints a root URL of `"https://<DOMAIN>"` and a from-address with no
`listmonk.yoursite.com` in it, the second prints `0`, the third prints `{"data":true}`.
Anything above `0` means an enabled SMTP entry still points at the placeholder host and every
campaign fails on send: a step-7 failure, not a step-10 mystery.

## 8. First backup and restore

Two artifacts: the database holds subscribers, lists, campaigns, settings and click history;
the file archive holds compose.yml, .env, uploads and the host's Caddyfile.

```bash
cd /srv/listmonk
docker compose exec -T db pg_dump -U listmonk -d listmonk | gzip > /srv/listmonk/backups/listmonk-db-$(date +%F).sql.gz
sudo tar -czf /srv/listmonk/backups/listmonk-files-$(date +%F).tar.gz -C /srv/listmonk compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh /srv/listmonk/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. Tell the user what they will not guess:
both carry live credentials. The archive holds .env; the dump holds the settings table, where
the SMTP relay's credential lives. Treat both like a password-manager export.

A backup on the same disk is not a backup. Run this from the user's machine:

```bash
mkdir -p ~/backups/listmonk
scp vps:/srv/listmonk/backups/* ~/backups/listmonk/
```

To restore: `docker compose down`, `sudo rm -rf /srv/listmonk/postgres`, recreate it as in step
2, untar the file archive back into /srv/listmonk, start the database with
`docker compose up -d db`, wait 30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db psql -U listmonk -d listmonk`, then `docker compose up -d`. Say what
is at stake: the consent record for every subscriber, who opted in and when, is in that
database, and a list restored from nothing is a list they may no longer mail.

## 9. Updating later

New versions are listed at https://github.com/knadh/listmonk/releases. Upstream's instruction
is to back up the database before every upgrade, so run step 8 first, then edit the image line
in /srv/listmonk/compose.yml to the new tag and its digest:

```bash
cd /srv/listmonk
docker compose pull
docker compose up -d
docker compose logs --tail 30 app
```

The `--upgrade` pass applies migrations on the way up. Watch that log until it settles, then
re-run step 7's health check.

## 10. What will probably go wrong

Mail, and not listmonk. I had the app up, a list made and a test campaign written inside half
an hour, then watched the send sit at zero delivered behind a green container and a cheerful
dashboard. The relay was refusing the connection and the campaign screen never says so: the
error is in Settings -> Logs, several screens from where the problem looks like it is. Upstream
warns that some hosting providers block outbound SMTP ports 25 and 465, which is a support
ticket rather than a setting. Send one campaign to a list holding only the user's own address
before anyone else is imported, and read Settings -> Logs when nothing arrives.

## 11. Out of scope

- Do not import a subscriber list until a test campaign has arrived. A list imported into an
  instance that cannot send is a list that gets imported twice.
- Do not configure bounce processing or a bounce mailbox. That wants a second mailbox with its
  own POP credentials and is an install-sized job of its own.
- Do not enable OIDC single sign-on. It needs an identity provider registered somewhere else.
- Do not move media uploads to S3. The uploads directory from step 2 is the choice here and it
  is inside the backup.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing listmonk 6.2.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.

Two things to settle before step 1. `<DOMAIN>` goes inside every campaign you send, in the
unsubscribe link and the archive URL, and a hostname you swap out later starts its sending
reputation from zero. And listmonk delivers no mail itself: it hands finished messages to
somebody else's SMTP server, so have a relay account ready with a host, a port, a username and
a password. Step 7 stops and waits for those four.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `1024` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. An IP that is not your
server's usually means a proxying CDN sits in front of the record; turn that off for this
hostname, because listmonk builds tracking and unsubscribe URLs on it and a second hop in front
of them is a second thing that can break a link inside somebody's inbox.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/listmonk /srv/listmonk/backups
sudo install -d -m 700 /srv/listmonk/postgres
sudo install -d -m 755 /srv/listmonk/uploads
ls -la /srv/listmonk
```

You should see: `backups` owned by you, `postgres` at mode `drwx------` owned by root, and
`uploads` at `drwxr-xr-x`.

If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own
data directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. `uploads` ends up owned by root too, because the listmonk entrypoint
chowns /listmonk to PUID:PGID and that defaults to 0:0. That is why step 8 uses `sudo tar`.

## 3. Secrets

Two secrets: the PostgreSQL password and the Super Admin password. Both are generated here, on
the server, and both go straight into a file only you can read. Hex for the database one,
because it travels inside a connection string; base64 for the admin one, because you will paste
it into a login form.

```bash
umask 077
cat > /srv/listmonk/.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
LISTMONK_ADMIN_USER=admin
LISTMONK_ADMIN_PASSWORD=$(openssl rand -base64 30)
EOF
chmod 600 /srv/listmonk/.env
umask 022
ls -l /srv/listmonk/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Read the admin
password once with `sudo grep LISTMONK_ADMIN_PASSWORD /srv/listmonk/.env` and put it in your
password manager. Your username is `admin`.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/listmonk/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
both values, which is fine before the database exists and a problem afterwards: PostgreSQL
keeps the password it was created with, so a changed one against an existing volume shows up as
an authentication failure in the listmonk log rather than as anything about passwords.

Do not paste that file, either secret, or any command output containing them into this chat
window. The same goes for the SMTP relay password you enter in step 7: it lives in the
database, and a database dump you paste here is a credential you have handed to a third party.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/listmonk/compose.yml <<'EOF'
# listmonk · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://listmonk.app/docs/installation/
#   variable reference . https://listmonk.app/docs/configuration/
#   upgrade path ....... https://listmonk.app/docs/upgrade/
#   health route ....... https://github.com/knadh/listmonk/blob/v6.2.0/cmd/handlers.go
#
# Two services: listmonk and the PostgreSQL it keeps subscribers, campaigns and
# click records in. Upstream states Postgres 12 or newer is the only dependency.
# The three-phase command is upstream's own: --install --idempotent lays the
# schema down once on an empty database, --upgrade applies migrations when the
# image moves, the third invocation runs the server, and --config '' means "no
# TOML file, read the LISTMONK_ environment variables". The root URL and the
# SMTP relay are settings rows a human fills in from the admin UI. Digests read
# on 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    container_name: listmonk-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: listmonk
      POSTGRES_USER: listmonk
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/listmonk/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U listmonk -d listmonk"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  app:
    image: listmonk/listmonk:v6.2.0@sha256:f535d59e14991337a9f2d570273685378ae86b0d7698c3e00da444e3bc205286
    container_name: listmonk-app
    restart: unless-stopped
    env_file: /srv/listmonk/.env
    command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"]
    environment:
      # Every interface inside the container; the way in is the port below.
      LISTMONK_app__address: 0.0.0.0:9000
      LISTMONK_db__host: db
      LISTMONK_db__port: 5432
      LISTMONK_db__user: listmonk
      LISTMONK_db__database: listmonk
      LISTMONK_db__password: ${POSTGRES_PASSWORD}
      LISTMONK_db__ssl_mode: disable
      LISTMONK_db__max_open: 25
      LISTMONK_db__max_idle: 25
      LISTMONK_db__max_lifetime: 300s
      TZ: Etc/UTC
    volumes:
      # Images uploaded through Admin -> Media. The entrypoint chowns /listmonk
      # to PUID:PGID, default 0:0, so this ends up root-owned on the host.
      - /srv/listmonk/uploads:/listmonk/uploads
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8096.
      - "127.0.0.1:8096:9000"
    depends_on:
      db:
        condition: service_healthy
EOF
cd /srv/listmonk && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/listmonk/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/listmonk/compose.yml` and paste again in one go. The long `command:` line is
upstream's three-phase form and all of it matters: pass one creates the schema and is
idempotent, so it does nothing on later boots; pass two applies database migrations, which is
what makes step 9 three commands; pass three runs the server.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-listmonk
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# listmonk · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://listmonk.app/docs/configuration/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That same hostname goes
# into listmonk's Root URL setting after the first login: unsubscribe links and
# archive URLs are built from that setting, not from the request. Upstream splits
# its routes into private admin paths (/admin/*, /api/*) and public ones
# (/subscription/*, /link/*, /campaign/*, /archive) that subscribers have to
# reach; both halves answer on this one hostname.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# SAMEORIGIN rather than DENY: the campaign editor previews a campaign
		# in an iframe served from this same origin.
		X-Frame-Options "SAMEORIGIN"
		# A subscription URL carries the subscriber's UUID in the path, so a
		# full Referer on an outbound click hands that UUID to a third party.
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8096 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8096
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-listmonk /etc/caddy/Caddyfile`, reload,
and paste again. One thing in that block is worth knowing before a subscriber complains: the
admin half of listmonk (/admin and /api) and the public half (/subscription, /link, /campaign,
/archive) answer on the same hostname by design, because the links inside your campaigns point
at the public half and they have to resolve for strangers.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8096` or `5432`.

If you do not: delete anything for `8096` or `5432` with `sudo ufw delete allow 8096`. 8096 is
bound to 127.0.0.1 by the compose file and 5432 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp answers the ACME challenge and redirects,
443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default. Nothing here
opens a mail port: the connection to your relay is outbound, and ufw allows outbound already.
`Status: inactive` is a different problem, and `sudo ufw enable` puts it back.

## 7. Start and verify

```bash
cd /srv/listmonk
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/health
curl -sS https://<DOMAIN>/admin/login | grep -o '<h2>Login</h2>'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/lists
```

You should see, in order: the loop reaching `200`, then `{"data":true}`, then
`<h2>Login</h2>`, then `403`.

If you do not: the `403` is the one worth understanding. It means the admin API is up and
refusing a call with no session, so seeing it is good news. If the grep prints nothing, open
https://<DOMAIN>/admin/login in a browser and look at the heading. `New user` there means the
database was not empty when the app first started, so the Super Admin from step 3 was never
created; `docker compose down`, `sudo rm -rf /srv/listmonk/postgres`, recreate that directory
as in step 2, and run this block again. If the loop never reaches `200`, run `docker compose
logs --tail 20 db` first, because a database that never reports healthy is step 2 done wrong,
and `docker compose logs --tail 40 app` second.

The first screen is https://<DOMAIN>/admin/login, and it shows the heading `Login` with a
username and a password field. Log in as `admin` with the password from step 3, then do these
three things, because nothing else can do them for you:

- Settings -> General: set Root URL to `https://<DOMAIN>`. It ships as `http://localhost:9000`,
  and every unsubscribe link, archive URL and tracking pixel in an outgoing campaign is built
  from it.
- Settings -> General: set the default from-address to one on a domain you control. It ships as
  an example address.
- Settings -> SMTP: the seeded first entry is switched on and points at `smtp.yoursite.com`
  with placeholder credentials. Replace its host, port, username and password with your
  relay's, and press Test connection before you save.

listmonk reloads itself when settings are saved. Then confirm the three values actually moved:

```bash
cd /srv/listmonk
docker compose exec -T db psql -U listmonk -d listmonk -tAc "SELECT key, value FROM settings WHERE key IN ('app.root_url', 'app.from_email')"
docker compose exec -T db psql -U listmonk -d listmonk -tAc "SELECT count(*) FROM settings, jsonb_array_elements(value) AS s WHERE key = 'smtp' AND (s->>'enabled')::boolean AND s->>'host' = 'smtp.yoursite.com'"
curl -sS https://<DOMAIN>/health
```

You should see: a root URL of `"https://<DOMAIN>"`, a from-address with no
`listmonk.yoursite.com` in it, then `0`, then `{"data":true}`.

If you do not: a count above `0` means an enabled SMTP entry still points at the placeholder
host, and every campaign you send will fail. Go back to Settings -> SMTP and save it properly.
A root URL still reading `http://localhost:9000` means the General page was not saved; it is
the single most expensive thing to get wrong here, because the mistake only shows up in
somebody else's inbox. A running container is not success.

## 8. First backup and restore

Two artifacts. The database holds subscribers, campaigns, settings and click history; the file
archive holds compose.yml, .env, the uploads and the host's Caddyfile, which is the file that
puts the site on your hostname.

```bash
cd /srv/listmonk
docker compose exec -T db pg_dump -U listmonk -d listmonk | gzip > /srv/listmonk/backups/listmonk-db-$(date +%F).sql.gz
sudo tar -czf /srv/listmonk/backups/listmonk-files-$(date +%F).tar.gz -C /srv/listmonk compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh /srv/listmonk/backups/
```

You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline,
because `pg_dump` snapshots a running database consistently. Both files carry live credentials:
.env is in the archive, and your SMTP relay's password is inside the settings table in the
dump. Keep them where you would keep a password-manager export.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/listmonk
scp vps:/srv/listmonk/backups/* ~/backups/listmonk/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/listmonk/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the `vps` alias Prompt Zero created
lives.

Now prove the restore, today, while the only thing at risk is an empty list:

```bash
cd /srv/listmonk
docker compose down
sudo rm -rf /srv/listmonk/postgres
sudo install -d -m 700 /srv/listmonk/postgres
docker compose up -d db
sleep 30
gunzip -c /srv/listmonk/backups/listmonk-db-$(date +%F).sql.gz | docker compose exec -T db psql -U listmonk -d listmonk
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/health
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `{"data":true}` from the last
command, and your settings still in place when you reload the admin page.

If you do not: `role "listmonk" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand the stakes before you
skip this step: the consent record for every subscriber, who opted in and when, is in that
database, and a list restored from nothing is a list you are no longer allowed to mail.

## 9. Updating later

New versions are listed at https://github.com/knadh/listmonk/releases. Upstream's instruction
is to back up the database before every upgrade, so run step 8 first, then edit the `image:`
line in /srv/listmonk/compose.yml to the new tag and its digest.

```bash
cd /srv/listmonk
docker compose pull
docker compose up -d
docker compose logs --tail 30 app
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
health check from step 7 before you call the update done, and log in as well, because a service
answering on /health can still be failing on something the admin UI needs.

## 10. What will probably go wrong

Mail, and not listmonk. I had the app up, a list made and a test campaign written inside half
an hour, then watched the send sit at zero delivered behind a green container and a cheerful
dashboard. The relay was refusing the connection and the campaign screen never says so: the
error is in Settings -> Logs, several screens from where the problem looks like it is.
Upstream warns that some hosting providers block outbound SMTP ports 25 and 465, which is a
support ticket rather than a setting. Send one campaign to a list holding only your own
address before anyone else is imported, and read Settings -> Logs when nothing arrives.

## 11. Out of scope

- Do not import a subscriber list until a test campaign has arrived. A list imported into an
  instance that cannot send is a list that gets imported twice.
- Do not configure bounce processing or a bounce mailbox. That wants a second mailbox with its
  own POP credentials and is an install-sized job of its own.
- Do not enable OIDC single sign-on. It needs an identity provider registered somewhere else.
- Do not move media uploads to S3. The uploads directory from step 2 is the choice here and it
  is inside the backup.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install listmonk 6.2.0 and the PostgreSQL it stores subscribers in, under ~/selfhost/listmonk,
answering at http://localhost:8096.

## 1. Preflight

Say this before step 2 runs; it decides whether they want this install at all. This listmonk
can still send through a relay, but the unsubscribe link in every message is built from this
machine's address, and http://localhost:8096 means "your own computer" to the recipient. The
honest use here is a list they build and draft on plus campaigns they send to themselves;
mailing strangers hands them a message they cannot unsubscribe from.

Ask this too: do they have an SMTP relay account, with a host, port, username and password?
listmonk delivers nothing itself, and step 7 waits on those four.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. listmonk plus PostgreSQL needs 1024 MB of
RAM available and 5 GB free on the home disk; both images publish amd64 and arm64. If available
RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/listmonk/uploads ~/selfhost/listmonk/backups
ls -la ~/selfhost/listmonk
```

Assert: `ls -la` shows `uploads` and `backups`, both owned by the user. There is no `data`
folder: everything that matters is a row in PostgreSQL, which step 5 keeps in a Docker-managed
volume, so no ownership fix is needed here.

## 4. Secrets

Two secrets: the PostgreSQL password and the Super Admin password. Generate both here, print
neither, keep both out of your summary and out of any log line.

```bash
umask 077
cat > ~/selfhost/listmonk/.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
LISTMONK_ADMIN_USER=admin
LISTMONK_ADMIN_PASSWORD=$(openssl rand -base64 30)
EOF
chmod 600 ~/selfhost/listmonk/.env
umask 022
ls -l ~/selfhost/listmonk/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these run the same
on all three. Upstream reads `LISTMONK_ADMIN_USER` and `LISTMONK_ADMIN_PASSWORD`
during the one-time install pass, so the Super Admin exists the first time the container
starts. On Windows those mode bits are advisory: NTFS does not enforce them, and the user's own
account is the real boundary.

## 5. compose.yml

```bash
cat > ~/selfhost/listmonk/compose.yml <<'EOF'
# listmonk · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://listmonk.app/docs/installation/
#   variable reference . https://listmonk.app/docs/configuration/
#   upgrade path ....... https://listmonk.app/docs/upgrade/
#   health route ....... https://github.com/knadh/listmonk/blob/v6.2.0/cmd/handlers.go
#
# Two services on the computer you are sitting at, every path relative to
# ~/selfhost/listmonk/. The database is a named volume, not a bind mount,
# because the PostgreSQL image chowns its data directory to its own uid, which
# a home-directory bind mount cannot allow on Windows. The command is
# upstream's three-phase form; --config '' means "env vars, no TOML file".
# Digests read on 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    container_name: listmonk-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: listmonk
      POSTGRES_USER: listmonk
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - listmonk-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U listmonk -d listmonk"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  app:
    image: listmonk/listmonk:v6.2.0@sha256:f535d59e14991337a9f2d570273685378ae86b0d7698c3e00da444e3bc205286
    container_name: listmonk-app
    restart: unless-stopped
    env_file: ./.env
    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__host: db
      LISTMONK_db__port: 5432
      LISTMONK_db__user: listmonk
      LISTMONK_db__database: listmonk
      LISTMONK_db__password: ${POSTGRES_PASSWORD}
      LISTMONK_db__ssl_mode: disable
      LISTMONK_db__max_open: 25
      LISTMONK_db__max_idle: 25
      LISTMONK_db__max_lifetime: 300s
      TZ: Etc/UTC
    volumes:
      # Admin -> Media uploads. The entrypoint chowns /listmonk to 0:0 by
      # default, so on Linux this folder ends up root-owned but readable.
      - ./uploads:/listmonk/uploads
    ports:
      # Loopback only: no other device on the wifi can reach 8096.
      - "127.0.0.1:8096:9000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  listmonk-pgdata:
EOF
cd ~/selfhost/listmonk && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Two services, one port, one named volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so pages needing crypto still work.
- No firewall rule, because nothing is published beyond loopback.

8096 is bound to 127.0.0.1: not the user's phone, not a laptop on the wifi, not the internet.
Mail still leaves, because that connection is outbound, which is why step 1's unsubscribe link
is the limit here and not the sending.

```bash
grep -n '127.0.0.1' ~/selfhost/listmonk/compose.yml
```

Assert: one line, `- "127.0.0.1:8096:9000"`. PostgreSQL publishes no host port at all.

## 7. Start and verify

```bash
cd ~/selfhost/listmonk
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8096/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8096/health
curl -sS http://localhost:8096/admin/login | grep -o '<h2>Login</h2>'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8096/api/lists
```

Assert all four, and print what you received for each: the loop ends on `200`; the health call
prints `{"data":true}`; the grep prints `<h2>Login</h2>`, the first screen; the unauthenticated
API call prints `403`, the admin API refusing a caller with no session. If any miss, stop, run
`docker compose logs --tail 40 app` and `--tail 20 db`, and name the cause. A grep printing
nothing while the page shows `New user` means the database was not empty on first start: run
`docker compose down -v` and this block again. `port is already allocated` means something else
holds 8096 (`lsof -nP -iTCP:8096`, `netstat -ano | findstr :8096` on Windows). A running
container is not success.

STOP: tell the user to do these three things and wait. Do not continue until they confirm.

- Read the admin password with `grep LISTMONK_ADMIN_PASSWORD ~/selfhost/listmonk/.env`, save it
  in their password manager, log in at http://localhost:8096/admin/login as `admin`.
- Settings -> General: set Root URL to `http://localhost:8096` and the default from-address to
  one on a domain they control. Both ship as examples and both go into every message sent.
- Settings -> SMTP: the seeded first entry is on and points at `smtp.yoursite.com` with
  placeholder credentials. Replace its host, port, username and password with the relay's, and
  use the Test connection button before saving.

Settings reload on save. Once they confirm, check the values moved:

```bash
cd ~/selfhost/listmonk
docker compose exec -T db psql -U listmonk -d listmonk -tAc "SELECT key, value FROM settings WHERE key IN ('app.root_url', 'app.from_email')"
docker compose exec -T db psql -U listmonk -d listmonk -tAc "SELECT count(*) FROM settings, jsonb_array_elements(value) s WHERE key='smtp' AND (s->>'enabled')::bool AND s->>'host'='smtp.yoursite.com'"
```

Assert: the first prints a root URL of `"http://localhost:8096"` and a from-address with no
`listmonk.yoursite.com` in it, and the second prints `0`.

## 8. First backup and restore

Two artifacts: the database holds subscribers, campaigns, settings and click history; the
archive holds the config and the uploads.

```bash
cd ~/selfhost/listmonk
docker compose exec -T db pg_dump -U listmonk -d listmonk | gzip > ~/selfhost/listmonk/backups/listmonk-db-$(date +%F).sql.gz
tar -C ~/selfhost/listmonk -czf ~/selfhost/listmonk/backups/listmonk-files-$(date +%F).tar.gz compose.yml .env uploads
ls -lh ~/selfhost/listmonk/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. Both carry live credentials: .env in the
archive, and the SMTP relay's own credential in the dumped settings table. Treat them like a
password-manager export.

Both sit on the same disk as the data, which is not a backup, and on a laptop the disk and the
machine fail together. Ask the user for a destination off this computer, a sync folder or a USB
stick, and copy both there with `cp`. In Git Bash a Windows drive is written `/d/Backups`, not
`D:\Backups`. Assert: the user confirms both files are listed there.

To restore, in this order. `cd ~/selfhost/listmonk`, untar the file archive there first, so
compose.yml and .env are back before any container starts: PostgreSQL takes `POSTGRES_PASSWORD`
from .env the moment it initialises an empty volume. Then `docker compose down -v`, the one
place `-v` belongs, `docker compose up -d db`, wait 30 seconds for healthy, pipe
`gunzip -c` on the `.sql.gz` into `docker compose exec -T db psql -U listmonk -d listmonk`,
then `docker compose up -d` and re-run step 7's check. The consent record for every subscriber
lives in that database, and a list restored from nothing is a list they may no longer mail.

## 9. Updating later

New versions are at https://github.com/knadh/listmonk/releases. Upstream says to back up the
database before every upgrade, so run step 8 first, then edit the image line in
~/selfhost/listmonk/compose.yml:

```bash
cd ~/selfhost/listmonk
docker compose pull
docker compose up -d
docker compose logs --tail 30 app
```

The `--upgrade` pass migrates on the way up. Watch that log, then re-run step 7's check.

## 10. What will probably go wrong

I scheduled a campaign for nine in the morning, closed the lid, and found it still at zero sent
at lunchtime. Nothing was broken. A campaign here moves only while the machine is awake and the
Docker daemon is up, and `restart: unless-stopped` acts only once that daemon runs, so a reboot
leaves nothing on 8096 until Docker Desktop starts. Turn on its start-at-login setting, and
after a reboot run `cd ~/selfhost/listmonk && docker compose up -d` before concluding anything
is wrong.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not send a campaign to anybody but the user. The unsubscribe link resolves only on this
  computer, so a recipient cannot use it.
- Do not configure bounce processing or OIDC. Each wants a second account somewhere else.
- Do not move media uploads to S3. The uploads folder from step 3 is the choice here.
````

## docker-compose.yml

```yaml
# listmonk · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://listmonk.app/docs/installation/
#   variable reference . https://listmonk.app/docs/configuration/
#   upgrade path ....... https://listmonk.app/docs/upgrade/
#   health route ....... https://github.com/knadh/listmonk/blob/v6.2.0/cmd/handlers.go
#
# Two services: listmonk and the PostgreSQL it keeps subscribers, campaigns and
# click records in. Upstream states Postgres 12 or newer is the only dependency.
# The three-phase command is upstream's own: --install --idempotent lays the
# schema down once on an empty database, --upgrade applies migrations when the
# image moves, the third invocation runs the server, and --config '' means "no
# TOML file, read the LISTMONK_ environment variables". The root URL and the
# SMTP relay are settings rows a human fills in from the admin UI. Digests read
# on 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    container_name: listmonk-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: listmonk
      POSTGRES_USER: listmonk
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/listmonk/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U listmonk -d listmonk"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  app:
    image: listmonk/listmonk:v6.2.0@sha256:f535d59e14991337a9f2d570273685378ae86b0d7698c3e00da444e3bc205286
    container_name: listmonk-app
    restart: unless-stopped
    env_file: /srv/listmonk/.env
    command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"]
    environment:
      # Every interface inside the container; the way in is the port below.
      LISTMONK_app__address: 0.0.0.0:9000
      LISTMONK_db__host: db
      LISTMONK_db__port: 5432
      LISTMONK_db__user: listmonk
      LISTMONK_db__database: listmonk
      LISTMONK_db__password: ${POSTGRES_PASSWORD}
      LISTMONK_db__ssl_mode: disable
      LISTMONK_db__max_open: 25
      LISTMONK_db__max_idle: 25
      LISTMONK_db__max_lifetime: 300s
      TZ: Etc/UTC
    volumes:
      # Images uploaded through Admin -> Media. The entrypoint chowns /listmonk
      # to PUID:PGID, default 0:0, so this ends up root-owned on the host.
      - /srv/listmonk/uploads:/listmonk/uploads
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8096.
      - "127.0.0.1:8096:9000"
    depends_on:
      db:
        condition: service_healthy
```

## compose.local.yml

```yaml
# listmonk · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://listmonk.app/docs/installation/
#   variable reference . https://listmonk.app/docs/configuration/
#   upgrade path ....... https://listmonk.app/docs/upgrade/
#   health route ....... https://github.com/knadh/listmonk/blob/v6.2.0/cmd/handlers.go
#
# Two services on the computer you are sitting at, every path relative to
# ~/selfhost/listmonk/. The database is a named volume, not a bind mount,
# because the PostgreSQL image chowns its data directory to its own uid, which
# a home-directory bind mount cannot allow on Windows. The command is
# upstream's three-phase form; --config '' means "env vars, no TOML file".
# Digests read on 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    container_name: listmonk-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: listmonk
      POSTGRES_USER: listmonk
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - listmonk-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U listmonk -d listmonk"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  app:
    image: listmonk/listmonk:v6.2.0@sha256:f535d59e14991337a9f2d570273685378ae86b0d7698c3e00da444e3bc205286
    container_name: listmonk-app
    restart: unless-stopped
    env_file: ./.env
    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__host: db
      LISTMONK_db__port: 5432
      LISTMONK_db__user: listmonk
      LISTMONK_db__database: listmonk
      LISTMONK_db__password: ${POSTGRES_PASSWORD}
      LISTMONK_db__ssl_mode: disable
      LISTMONK_db__max_open: 25
      LISTMONK_db__max_idle: 25
      LISTMONK_db__max_lifetime: 300s
      TZ: Etc/UTC
    volumes:
      # Admin -> Media uploads. The entrypoint chowns /listmonk to 0:0 by
      # default, so on Linux this folder ends up root-owned but readable.
      - ./uploads:/listmonk/uploads
    ports:
      # Loopback only: no other device on the wifi can reach 8096.
      - "127.0.0.1:8096:9000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  listmonk-pgdata:
```

## Caddyfile

```text
# listmonk · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://listmonk.app/docs/configuration/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That same hostname goes
# into listmonk's Root URL setting after the first login: unsubscribe links and
# archive URLs are built from that setting, not from the request. Upstream splits
# its routes into private admin paths (/admin/*, /api/*) and public ones
# (/subscription/*, /link/*, /campaign/*, /archive) that subscribers have to
# reach; both halves answer on this one hostname.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# SAMEORIGIN rather than DENY: the campaign editor previews a campaign
		# in an iframe served from this same origin.
		X-Frame-Options "SAMEORIGIN"
		# A subscription URL carries the subscriber's UUID in the path, so a
		# full Referer on an outbound click hands that UUID to a third party.
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8096 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8096
}
```

## install.sh

```bash
#!/usr/bin/env bash
# listmonk · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=news.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://listmonk.app/docs/installation/
#   https://listmonk.app/docs/configuration/
#   https://listmonk.app/docs/upgrade/
#   https://github.com/knadh/listmonk/blob/v6.2.0/cmd/handlers.go
#
# Two secrets are generated here, on this machine: the PostgreSQL password and
# the Super Admin password. Both go into /srv/listmonk/.env with mode 600 and
# neither is ever printed.
#
# This script stops where a human has to take over. listmonk cannot send mail
# until you enter an SMTP relay's host, port, username and password in
# Settings -> SMTP, and it will put http://localhost:9000 inside every campaign
# until you set the Root URL in Settings -> General. Both are browser steps and
# the closing summary spells them out.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/listmonk}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. news.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; listmonk plus PostgreSQL wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
sudo install -d -m 755 "$APP_DIR/uploads"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex for the database password, which travels inside a connection string, and
# base64 for the Super Admin password, which a human pastes into a login form.
# Upstream reads LISTMONK_ADMIN_USER and LISTMONK_ADMIN_PASSWORD during the
# one-time install pass, so the account exists before the login page is ever
# public. Read them later with
#   sudo grep -E 'POSTGRES_PASSWORD|LISTMONK_ADMIN_PASSWORD' /srv/listmonk/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		POSTGRES_PASSWORD=$(openssl rand -hex 32)
		LISTMONK_ADMIN_USER=admin
		LISTMONK_ADMIN_PASSWORD=$(openssl rand -base64 30)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-listmonk"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8096 nor 5432 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8096 and 5432 stay closed"
	echo "==> nothing opens for mail: the relay connection is outbound"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The compose command runs upstream's three phases in order: install the schema
# once on an empty database, apply migrations, then serve.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/health"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/health answered ${code:-nothing}. Check: docker compose logs --tail 40 app"

curl -sS "https://${DOMAIN_HOST}/health" | grep -q '"data":true' \
	|| die "/health answered 200 without data true. Check: docker compose logs --tail 40 app"

# The first screen. `New user` in place of `Login` means the database was not
# empty on first start, so the Super Admin from step 3 was never created.
curl -sS "https://${DOMAIN_HOST}/admin/login" | grep -q '<h2>Login</h2>' \
	|| die "the login page did not show the Login heading. If it shows 'New user', the database was not empty: docker compose down; sudo rm -rf ${APP_DIR}/postgres; re-run this script."

# The admin API must refuse an unauthenticated call. Upstream returns 403 for a
# missing or invalid session.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/lists" || true)"
[ "$unauth" = "403" ] || die "an unauthenticated API call returned ${unauth}, not 403. Stop and investigate."

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db pg_dump -U listmonk -d listmonk | gzip > "$APP_DIR/backups/listmonk-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/listmonk-files-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/listmonk-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	listmonk is answering at https://${DOMAIN_HOST}/admin/login

	  1. Your Super Admin username is admin. The password is in $APP_DIR/.env,
	     mode 600. Read it with
	       sudo grep LISTMONK_ADMIN_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here.
	  2. This install cannot send yet, and that is the part only you can finish.
	     Log in, then:
	       Settings -> General : set Root URL to https://${DOMAIN_HOST}. It ships
	         as http://localhost:9000, and every unsubscribe link, archive URL
	         and tracking pixel in a campaign is built from it.
	       Settings -> General : set the default from-address to one on a domain
	         you control. It ships as an example address.
	       Settings -> SMTP    : the seeded first entry is switched on and points
	         at smtp.yoursite.com with placeholder credentials. Replace its host,
	         port, username and password with your relay's, and press Test
	         connection before saving.
	  3. Confirm those landed, from this machine:
	       cd $APP_DIR && docker compose exec -T db psql -U listmonk -d listmonk -tAc "SELECT key, value FROM settings WHERE key IN ('app.root_url', 'app.from_email')"
	  4. First backup written to $APP_DIR/backups: a database dump and a file
	     archive. Both carry live credentials, and both are on the same disk as
	     the data, which is not a backup. Copy them somewhere else tonight.
	  5. Send one campaign to a list holding only your own address before you
	     import anybody else's. When nothing arrives, read Settings -> Logs.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/mailchimp/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
