<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Newsletter – Serverküche</title><link>https://serverkueche.de/en/tags/newsletter/</link><description>Newsletter – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>en-US</language><managingEditor>feedback@serverkueche.de (Serverküche)</managingEditor><webMaster>feedback@serverkueche.de (Serverküche)</webMaster><copyright>2026 Serverküche</copyright><lastBuildDate>Thu, 05 Nov 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/tags/newsletter/index.xml" rel="self" type="application/rss+xml"/><item><title>listmonk: Your Own Newsletter Server – Privacy-Friendly</title><link>https://serverkueche.de/en/tutorials/listmonk-newsletter/</link><pubDate>Thu, 05 Nov 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/listmonk-newsletter/</guid><description>listmonk 6.2 with Postgres 18 behind Traefik: a double opt-in list, a first campaign, the privacy switches – and a dry run before real mail goes out.</description><content:encoded><![CDATA[<p>Sending your own newsletter means the address list stays with you, not with a provider who mines it
&ldquo;to improve the service&rdquo;. listmonk makes that surprisingly comfortable – the hard part is not the
software but getting the mail delivered.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>We run <strong>listmonk 6.2.0</strong> with <strong>PostgreSQL 18.6</strong> behind Traefik. At the end you have a recipient
list with <strong>double opt-in</strong>, one campaign actually sent, and the privacy switches set so that
subscribers can export and delete their own data.</p>
<p>In between comes a step most guides skip: we send the first campaign to a <strong>local catcher</strong> before
a single mail goes out. A newsletter that lands in the spam folder on its first attempt costs you
reputation you then have to win back the hard way.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>The software is the easy part
  </p>
  <div class="prose-kitchen text-sm">listmonk sends mail – whether it arrives is decided by your mail server. SPF, DKIM and DMARC have
to be in place before you write to real recipients. How to do that is in
<a href="/en/tutorials/email-deliverability/">email deliverability</a>; without that groundwork even the
nicest newsletter ends up in spam.</div>
</div>
<h2 id="requirements">Requirements</h2>
<ul>
<li><a href="/en/tutorials/traefik-reverse-proxy/">Traefik as a reverse proxy</a> with a working Let&rsquo;s Encrypt
resolver and a domain</li>
<li>A <strong>mail server or SMTP account</strong> whose sender domain is covered by
<a href="/en/tutorials/email-deliverability/">SPF, DKIM and DMARC</a></li>
<li>Around 500 MB of memory for the application and database</li>
</ul>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20"
     data-track-content data-content-name="Affiliate-Box · /en/tutorials/listmonk-newsletter/" data-content-piece="VPS 1000 G12.5">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 1000 G12.5</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 128 GB SSD</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €14.50/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">listmonk is frugal – for a few thousand addresses the small VPS is plenty.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12.5-iv-24m-eu?ref=44083#vps-1000-g12.5-iv-12m-eu" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 1000 G12.5 · {page}"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Go to netcup →
</a>

  </div><div class="px-4 pb-4"><div class="not-prose my-3 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200"
     data-voucher-api="vps-1000" data-voucher-msg-empty="No code is available for this plan right now – use the €5.95 code." data-voucher-msg-limit="No more codes today – use the €5.95 code or try again tomorrow." data-voucher-msg-error="The code couldn&#39;t be fetched right now – use the €5.95 code."
     data-voucher-hint="Empty the cart first – the code adds the server itself.">
  <p class="flex flex-wrap items-center gap-x-2 gap-y-1"><span>💶 <span class="font-semibold">1 month free</span>:</span>
    <button type="button" data-voucher-reveal data-copy-title="Click to copy"
            class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">Show code</button>
    <button type="button" data-voucher-next hidden
            class="rounded border border-herb-500/40 px-2 py-0.5 text-xs text-herb-800 hover:bg-white dark:text-herb-400 dark:hover:bg-slate-800">another code</button>
  </p><p class="mt-1 text-xs text-slate-500 dark:text-slate-400" data-voucher-hint-target>
    Single use, valid for VPS 1000 G12.5.
    <a href="https://www.netcup.com/en/checkout/cart?ref=44083" rel="sponsored noopener" target="_blank"
       data-track-event="Affiliate|netcup: Gutschein einlösen|VPS 1000 G12.5 · {page}"
       class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">Redeem in the cart →</a>
  </p><p class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-herb-500/30 pt-2">
    <span>💶 <span class="font-semibold">€5.95 voucher</span> for new netcup customers:</span>
    <button type="button" data-voucher-code data-track-voucher="36nc17844976032"
            title="Click to copy" class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</button>
    <span class="text-xs text-slate-500 dark:text-slate-400">always valid · not for domains or VPS Lite</span>
  </p><p class="mt-1 text-xs"><a href="/en/netcup-voucher/" class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">All netcup vouchers →</a></p>
</div></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-the-stack">Step 1: The stack</h3>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p /opt/listmonk/uploads <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /opt/listmonk</span></span></code></pre></div>
</div>
<p>Credentials first. The administrator&rsquo;s username and password are created from these variables on
the <strong>very first</strong> start – after that listmonk ignores them:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">umask</span> <span class="m">077</span>
</span></span><span class="line"><span class="cl"><span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;POSTGRES_PASSWORD=</span><span class="k">$(</span>openssl rand -base64 <span class="m">24</span> <span class="p">|</span> tr -d <span class="s1">&#39;=+/&#39;</span> <span class="p">|</span> cut -c1-24<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;LISTMONK_ADMIN_USER=YOUR_USERNAME&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;LISTMONK_ADMIN_PASSWORD=YOUR_STRONG_PASSWORD&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span> &gt; .env</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># /opt/listmonk/compose.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18.6-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">listmonk-db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">listmonk</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${POSTGRES_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">listmonk</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">pgdata:/var/lib/postgresql</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">listmonk-internal]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U listmonk&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">listmonk/listmonk:v6.2.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">listmonk</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;sh&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-c&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;./listmonk --install --idempotent --yes --config &#39;&#39; &amp;&amp; ./listmonk --upgrade --yes --config &#39;&#39; &amp;&amp; ./listmonk --config &#39;&#39;&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">LISTMONK_app__address</span><span class="p">:</span><span class="w"> </span><span class="m">0.0.0.0</span><span class="p">:</span><span class="m">9000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">LISTMONK_db__host</span><span class="p">:</span><span class="w"> </span><span class="l">db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">LISTMONK_db__port</span><span class="p">:</span><span class="w"> </span><span class="m">5432</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">LISTMONK_db__user</span><span class="p">:</span><span class="w"> </span><span class="l">listmonk</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">LISTMONK_db__password</span><span class="p">:</span><span class="w"> </span><span class="l">${POSTGRES_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">LISTMONK_db__database</span><span class="p">:</span><span class="w"> </span><span class="l">listmonk</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">LISTMONK_db__ssl_mode</span><span class="p">:</span><span class="w"> </span><span class="l">disable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">LISTMONK_ADMIN_USER</span><span class="p">:</span><span class="w"> </span><span class="l">${LISTMONK_ADMIN_USER}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">LISTMONK_ADMIN_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${LISTMONK_ADMIN_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">TZ</span><span class="p">:</span><span class="w"> </span><span class="l">Europe/Berlin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./uploads:/listmonk/uploads:rw</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">listmonk-internal, proxy]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.enable=true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.listmonk.rule=Host(`YOUR_DOMAIN`)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.listmonk.entrypoints=websecure</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.listmonk.tls.certresolver=le</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.services.listmonk.loadbalancer.server.port=9000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pgdata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">listmonk-internal</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">internal</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>The <code>command</code> line looks convoluted but does exactly three sensible things in order:
<code>--install --idempotent</code> creates the database schema if it is missing and does nothing otherwise;
<code>--upgrade</code> runs the pending migrations when a new image arrives; then the actual service starts.
The <code>--config ''</code> is not a typo but the instruction &ldquo;use <strong>no</strong> configuration file, only the
environment variables&rdquo;.</p>
<p>The database sits in its own network with <code>internal: true</code> – unreachable both from outside and from
other containers in the <code>proxy</code> network.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>PostgreSQL 18: the mount has moved
  </p>
  <div class="prose-kitchen text-sm">Older guides mount the database under <code>/var/lib/postgresql/**data**</code>. With PostgreSQL 18 the
container then refuses to start – even with an empty volume, because the check hangs on the mount
and not on the content. The mount belongs one level up, at <code>/var/lib/postgresql</code>.</div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d
</span></span><span class="line"><span class="cl">docker compose logs app <span class="p">|</span> grep -E <span class="s2">&#34;superadmin|setup complete|upgrade|server started&#34;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">install.go:93: creating superadmin user &#39;YOUR_USERNAME&#39;
</span></span><span class="line"><span class="cl">install.go:116: setup complete
</span></span><span class="line"><span class="cl">main.go:165: running upgrade...
</span></span><span class="line"><span class="cl">upgrade.go:74: no upgrades to run. Database is up to date.
</span></span><span class="line"><span class="cl">⇨ http server started on [::]:9000</span></span></code></pre></div>
</div>
<p>The three stages from the <code>command</code> line are easy to spot: installation, upgrade check, service
start.</p>
<h3 id="step-2-sign-in-set-the-address-clear-out-the-sample-data">Step 2: Sign in, set the address, clear out the sample data</h3>
<p>At <code>https://YOUR_DOMAIN/admin</code> you sign in with the credentials from the <code>.env</code>.</p>
<p><figure class="my-6"><img src="/en/tutorials/listmonk-newsletter/lm-dashboard_hu_c764e81285d97366.webp" srcset="/en/tutorials/listmonk-newsletter/lm-dashboard_hu_b35f9321b517e94f.webp 480w, /en/tutorials/listmonk-newsletter/lm-dashboard_hu_c764e81285d97366.webp 768w, /en/tutorials/listmonk-newsletter/lm-dashboard_hu_a502cd8bde9b4632.webp 1200w, /en/tutorials/listmonk-newsletter/lm-dashboard_hu_761460a3479f89f9.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/listmonk-newsletter/lm-dashboard_hu_2d505ad442f24ee2.webp"
    alt="The listmonk dashboard with figures for lists, campaigns and subscribers." title="The dashboard after signing in – the numbers still come from the sample data"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The dashboard after signing in – the numbers still come from the sample data</figcaption></figure></p>
<p>First go to <strong>Settings → General</strong> and fill in two things:</p>
<ul>
<li><strong>Root URL</strong> – <code>https://YOUR_DOMAIN</code>, without a trailing slash. Out of the box it says
<code>http://localhost:9000</code>, and listmonk builds <strong>every link in every mail</strong> from this value: the
double opt-in confirmation link, the unsubscribe link, &ldquo;View in browser&rdquo;. If the default stays,
your recipients get links that lead nowhere – and nobody can confirm their subscription.</li>
<li><strong>Default <code>from</code> email</strong> – the sender, for example <code>Kitchen Letter &lt;newsletter@your-domain.com&gt;</code>. The
address has to match the domain you set up SPF, DKIM and DMARC for.</li>
</ul>
<p>Save; listmonk then reloads briefly.</p>
<p>On first start listmonk also creates <strong>sample data</strong>: two lists (&ldquo;Default list&rdquo; and &ldquo;Opt-in
list&rdquo;), two made-up subscribers (<code>john@example.com</code>, <code>anon@example.com</code>) and a campaign called
&ldquo;Test campaign&rdquo;. Clear that out before you enter real addresses – otherwise the sample addresses
will end up in your first real campaign at some point. Delete the sample subscribers under
<strong>Subscribers</strong>, the lists under <strong>Lists</strong> and the campaign under <strong>Campaigns</strong>.</p>
<h3 id="step-3-smtp--catch-first-send-later">Step 3: SMTP – catch first, send later</h3>
<p>Under <strong>Settings → SMTP</strong> you enter the mail server. Before you put your real credentials there, an
intermediate step pays off: a <strong>local catcher</strong> that accepts every mail and forwards none. That way
you see what listmonk actually sends without burning reputation.</p>
<p>Add a container to the stack temporarily:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">mailpit</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">axllent/mailpit:v1.33.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">listmonk-mailpit</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">listmonk-internal]</span></span></span></code></pre></div>
</div>
<p>In the SMTP settings you then point at <code>mailpit</code>, port <code>1025</code>, <strong>Auth protocol: None</strong> and
<strong>TLS: Off</strong>:</p>
<p><figure class="my-6"><img src="/en/tutorials/listmonk-newsletter/lm-smtp_hu_978217d4fdaa64b8.webp" srcset="/en/tutorials/listmonk-newsletter/lm-smtp_hu_dee5ae0a80cd270.webp 480w, /en/tutorials/listmonk-newsletter/lm-smtp_hu_978217d4fdaa64b8.webp 768w, /en/tutorials/listmonk-newsletter/lm-smtp_hu_cf61865eb42e8e0.webp 1200w, /en/tutorials/listmonk-newsletter/lm-smtp_hu_a32ddd7f858c283d.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/listmonk-newsletter/lm-smtp_hu_1a286c6aa4382063.webp"
    alt="The listmonk SMTP settings with host, port, authentication, TLS and retry options." title="The SMTP page – still pointed at the local catcher here"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The SMTP page – still pointed at the local catcher here</figcaption></figure></p>
<p>For real operation you then enter your mail server&rsquo;s hostname, port <strong>587</strong>, <strong>Auth protocol:
PLAIN</strong> or <strong>LOGIN</strong>, your credentials and <strong>TLS: STARTTLS</strong>. The shortcuts above the fields
(Gmail, Amazon SES, Mailgun …) pre-fill the values for well-known providers.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>Skip TLS verification stays off
  </p>
  <div class="prose-kitchen text-sm">For the local catcher the option is irrelevant; in real operation it is dangerous. Without
certificate checking you will not notice someone sitting between listmonk and your mail server –
and your SMTP credentials travel over that connection. Switch it off as soon as you move to the
real server.</div>
</div>
<h3 id="step-4-a-list-with-double-opt-in">Step 4: A list with double opt-in</h3>
<p>Under <strong>Lists → New</strong> you create the recipient list. Two settings decide the legal side:</p>
<ul>
<li><strong>Type: Private</strong> – the list does not appear in public subscription forms unless you want it to.
You need &ldquo;Public&rdquo; for a signup form on your own website.</li>
<li><strong>Opt-in: Double</strong> – after signing up a confirmation mail goes out, and only the click inside it
turns the entry into a confirmed subscriber.</li>
</ul>
<p><strong>Double opt-in is effectively mandatory in Germany.</strong> It is also your evidence: listmonk records
when someone confirmed. Without that record you have nothing to show when a complaint arrives.</p>
<h3 id="step-5-the-first-campaign">Step 5: The first campaign</h3>
<p>Under <strong>Campaigns → New</strong> you set name, subject, list and format. As a content type listmonk offers
a rich-text editor and <strong>Markdown</strong>, among others – the latter is often the better choice for a
plain text newsletter, because less HTML means less spam suspicion.</p>
<p>In the body you work with placeholders:</p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># Issue 1
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Hello {{ .Subscriber.FirstName }},
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">this week: pumpkin soup, onion tart and a glass of new wine.</span></span></code></pre></div>
</div>
<p><code>{{ .Subscriber.FirstName }}</code> is replaced per recipient at send time – a subscriber named
&ldquo;Leserin&rdquo; receives &ldquo;Hello Leserin,&rdquo;.</p>
<p>You do not have to add an unsubscribe link here: the template listmonk wraps the text in (&ldquo;Default
campaign template&rdquo;, under <strong>Campaigns → Templates</strong>) puts &ldquo;Unsubscribe&rdquo; and &ldquo;View in browser&rdquo; in
the footer of every mail. The individual link behind it is <code>{{ UnsubscribeURL }}</code>, and it <strong>belongs
in every mail</strong>.</p>
<p>Do not rely on the software for that: if you build your own template and forget the link, listmonk
still saves and sends it without complaint. The <code>List-Unsubscribe</code> header from the privacy settings
does travel along, but it does not replace the visible unsubscribe link in the body. So write
<code>{{ UnsubscribeURL }}</code> into every template of your own, and you cannot forget it.</p>
<p>One click on <strong>Start</strong> sends the campaign. The overview then shows what happened:</p>
<p><figure class="my-6"><img src="/en/tutorials/listmonk-newsletter/lm-campaigns-list_hu_bd9ea242d6f4dbcf.webp" srcset="/en/tutorials/listmonk-newsletter/lm-campaigns-list_hu_f2cab6c26b2f8e2a.webp 480w, /en/tutorials/listmonk-newsletter/lm-campaigns-list_hu_bd9ea242d6f4dbcf.webp 768w, /en/tutorials/listmonk-newsletter/lm-campaigns-list_hu_6d96c1efaf25a99b.webp 1200w, /en/tutorials/listmonk-newsletter/lm-campaigns-list_hu_bb5c2f26a826ded7.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/listmonk-newsletter/lm-campaigns-list_hu_1afc0ba759d2dc17.webp"
    alt="The listmonk campaign overview with a finished campaign and the statistic 1 of 1 sent." title="After sending: status Finished and the number of delivered messages"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">After sending: status Finished and the number of delivered messages</figcaption></figure></p>
<p>Now look into the catcher: are the sender, the subject including umlauts and the resolved
placeholder right? Do the footer links point to <code>https://YOUR_DOMAIN/…</code> and not to <code>localhost</code>?
Only once that is right do you switch SMTP to the real mail server. The mails sit in Mailpit, which
only hangs in the internal network; you fetch them through its API, for example the latest one:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose <span class="nb">exec</span> app wget -qO- http://mailpit:8025/api/v1/message/latest</span></span></code></pre></div>
</div>
<p>Afterwards take the <code>mailpit</code> block out of the Compose file again and restart the stack with
<code>docker compose up -d --remove-orphans</code>.</p>
<p><figure class="my-6"><img src="/en/tutorials/listmonk-newsletter/lm-campaign_hu_3bd79ebc4194face.webp" srcset="/en/tutorials/listmonk-newsletter/lm-campaign_hu_54e639f433c8a61b.webp 480w, /en/tutorials/listmonk-newsletter/lm-campaign_hu_3bd79ebc4194face.webp 768w, /en/tutorials/listmonk-newsletter/lm-campaign_hu_dc98b987d731f69c.webp 1200w, /en/tutorials/listmonk-newsletter/lm-campaign_hu_277ffe36060fff50.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/listmonk-newsletter/lm-campaign_hu_56ae8721ec4ec825.webp"
    alt="The detail view of a listmonk campaign with subject, list, format and content." title="The campaign view with subject, recipient list and content"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The campaign view with subject, recipient list and content</figcaption></figure></p>
<h3 id="step-6-the-privacy-switches">Step 6: The privacy switches</h3>
<p>Under <strong>Settings → Privacy</strong> sits what makes the difference between &ldquo;self-hosted&rdquo; and
&ldquo;privacy-friendly&rdquo;. Every switch is a conscious decision:</p>
<ul>
<li><strong>Disable tracking</strong> – turns off counting of opens and clicks entirely. A newsletter without a
tracking pixel needs no consent for it and no paragraph in your privacy policy.</li>
<li><strong>Individual subscriber tracking</strong> – links opens and clicks to the individual person. It is off
by default: listmonk then keeps counting, but only in aggregate. That is the usable middle ground
if you want to know whether an issue landed.</li>
<li><strong>Include <code>List-Unsubscribe</code> header</strong> – leave this on. Mail clients then show an unsubscribe
button directly, and large providers treat that as a positive signal.</li>
<li><strong>Allow exporting</strong> and <strong>Allow wiping</strong> – subscribers can fetch the data stored about them and
delete themselves completely. That covers the access and erasure rights without you having to
answer an email. Both are on by default.</li>
<li><strong>Record opt-in IP address</strong> – additionally stores the IP address someone confirmed from. That
strengthens your evidence but is one more piece of personal data; off by default.</li>
</ul>
<p>My recommendation: per-person tracking off, <code>List-Unsubscribe</code> on, export and erasure on – which is
also the shipping state, so all you have to do is leave it alone. That
answers the most common requests before they are made.</p>
<h2 id="when-it-doesnt-work">When it doesn&rsquo;t work</h2>
<div class="troubleshoot not-prose">
<p><strong>The container restarts in a loop and reports <code>error loading flags: flag needs an argument: --config</code>.</strong> While copying the <code>command</code> line the two single quotes after <code>--config</code> were lost – which happens easily when the command travels through several shell layers. Check what actually arrives with <code>docker inspect listmonk --format '{{json .Config.Cmd}}'</code>: there has to be an empty argument after every <code>--config</code>. When in doubt, write the Compose file with an editor directly on the server instead of generating it through nested quoting.</p>
<p><strong>listmonk no longer starts and reports <code>error reading SMTP config: decoding failed</code>.</strong> An invalid value is stored in the settings – a duration without a unit, for instance. The awkward part: the web interface is then unreachable, so you cannot fix it there. The settings live in the <code>settings</code> table; <code>docker compose exec db psql -U listmonk -d listmonk</code> gets you in and lets you <code>UPDATE</code> the <code>smtp</code> key back to a valid block. Then <code>docker compose restart app</code>.</p>
<p><strong>The database container does not start and talks about <code>pg_ctlcluster</code>.</strong> That is the PostgreSQL 18 change: the mount must no longer point at <code>/var/lib/postgresql/data</code> but at <code>/var/lib/postgresql</code>. For an existing database, moving it is not enough – there you need <code>pg_dump</code> with the old major version and an import into the new one.</p>
<p><strong>The campaign stays on <code>running</code> and nothing is sent.</strong> Then listmonk cannot reach the SMTP server. <code>docker compose logs app | grep -i smtp</code> shows why; often it is the wrong port (587 instead of 465 or the other way round), a wrong TLS setting, or a provider blocking outbound port 25. The local catcher from step 3 helps narrow it down: if sending there works, the problem is not listmonk.</p>
<p><strong>The mails arrive but land in spam.</strong> That is not a listmonk problem. Check that SPF, DKIM and DMARC are right for the sender domain and that the sender address matches the domain you send through – how to do that is in <a href="/en/tutorials/email-deliverability/">email deliverability</a>. Also avoid image-only HTML, and send to a small list first rather than writing to ten thousand addresses at once.</p>

</div>
<p class="mt-3 text-sm text-slate-500 dark:text-slate-400">
  Your problem is not listed?
  <a href="/en/errors/" class="font-medium text-paprika-700 hover:underline dark:text-paprika-400">Search all error messages →</a>
</p>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<p><strong>Updates</strong> are a tag change; the migration is handled automatically by the <code>--upgrade</code> stage in
<code>command</code> at startup:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> /opt/listmonk
</span></span><span class="line"><span class="cl">docker compose <span class="nb">exec</span> -T db pg_dump -U listmonk listmonk &gt; before-update-<span class="k">$(</span>date +%F<span class="k">)</span>.sql
</span></span><span class="line"><span class="cl">sed -i <span class="s1">&#39;s/listmonk:v6.2.0/listmonk:vNEW_VERSION/&#39;</span> compose.yaml
</span></span><span class="line"><span class="cl">docker compose pull <span class="o">&amp;&amp;</span> docker compose up -d
</span></span><span class="line"><span class="cl">docker compose logs app <span class="p">|</span> grep -i upgrade</span></span></code></pre></div>
</div>
<p>The dump beforehand is mandatory: if a migration goes wrong, your entire recipient list is in it.
And <strong>do not touch the PostgreSQL major version at the same time</strong> – that always needs a dump and
an import.</p>
<p><strong>Two things need backing up, plus the credentials:</strong></p>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">pg_dump output             # subscribers, lists, campaigns, opt-in records
</span></span><span class="line"><span class="cl">/opt/listmonk/uploads/     # images from the media section
</span></span><span class="line"><span class="cl">/opt/listmonk/.env         # database password</span></span></code></pre></div>
</div>
<p>The dump matters especially here because it holds the <strong>confirmation timestamps</strong> of your double
opt-ins. Lose those and you have no evidence that someone consented. Daily via a systemd timer and
off into your <a href="/en/tutorials/restic-backups/">Restic backup</a>.</p>
<p><strong>Ongoing upkeep</strong> is minimal, but two habits pay off: after each campaign, look at the bounce
count and remove hard bounces from the list – dead addresses cost reputation. And do not keep the
list artificially alive: whoever unsubscribes gets deleted, not kept &ldquo;for later&rdquo;.</p>
<p><strong>Realistic effort:</strong> setting it up costs an evening, running it a few minutes per issue. The part
that really needs attention is deliverability – and that is decided before the first send, not
after.</p>
]]></content:encoded></item></channel></rss>