Introduction. #
So i wanted to add a newsletter system to the blog, so people could sign up to get notified/stay up to date when i post a new article on the blog or if i have any other stuff to share, like downtime/maintenance cycles. I decided to integrate a system called Listmonk which is also opensource and also can be used as a docker-image.
What is listmonk? #
So what is listmonk? listmonk is a an opensource newsletter/mailing-list solution that allows users to subscribe and to receive notifications/news in the form of emails. Its written in Golang and it uses a PostgreSQL database. I wanted to use it as it seemed like the perfect solution! Privacy focussed, opensource, selfhostable. After scrolling around on the internet a bit and chatting with claude AI i came to this solution.
Installation. #
So first we need to setup docker. I simply used a pre-made community LXC proxmox script tat automatically created a docker LXC for me. Then inside it i created a new directory: /opt/listmonk. Then i added a docker-compose.yml file and also an .env file which would store some environment variables. Also you need the SMTP credentials of your email provider.
Endpoint. #
So first we have to have an ednpoint/address where we can reach the application’s web-page. Now we can just type in the raw IP and port, but then it is only on the local network and we want to make the service available outside of the local network. SO becaue i own a domain i setup in my Nginx reverse proxy and VPS’s caddyfile that it would proxy “newsletter.309electronics.net” to the container’s real IP address and port 9000. So if you have not already or dont want to bother with typing in the direct IP and port, you should setup that first, but its not a must. I wont go into detail how to set that up, but there is enough information on the interwebs.
.env (environment file). #
So we also want to store some credentials in an .env file to contain them. Below is the .env file i use (ofcourse with sensitive credentials changed).
POSTGRES_USER=listmonk
POSTGRES_PASSWORD=yourepicpassword
POSTGRES_DB=listmonk
LISTMONK_ADMIN_USER=admin
LISTMONK_ADMIN_PASSWORD=yourepicpassword2Docker-compose.yml. #
So we need to create a docker-compose.yml file. This file basically is what defines and runs an application container/containers. It also is used to define the settings of that application container. So its basically the “configuration-file”. Below is my docker-compose file that i inspired a bit of the official listmonk docs and it includes all required stuff.
x-db-credentials: &db-credentials
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
services:
app:
image: listmonk/listmonk:latest
container_name: listmonk_app
restart: unless-stopped
ports:
- "9000:9000"
networks:
- listmonk
hostname: yourwebsite.net
depends_on:
- db
command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config '' "]
environment:
LISTMONK_app__address: 0.0.0.0:9000
LISTMONK_db__user: ${POSTGRES_USER}
LISTMONK_db__password: ${POSTGRES_PASSWORD}
LISTMONK_db__database: ${POSTGRES_DB}
LISTMONK_db__host: db
LISTMONK_db__port: 5432
LISTMONK_db__ssl_mode: disable
LISTMONK_db__max_open: 25
LISTMONK_db__max_idle: 25
LISTMONK_db__max_lifetime: 300s
TZ: Europe/Amsterdam # Change to your timezone
LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USER:-}
LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:-}
volumes:
- ./uploads:/listmonk/uploads:rw
- ./static:/listmonk/static:ro
db:
image: postgres:17-alpine
container_name: listmonk_db
restart: unless-stopped
networks:
- listmonk
environment:
<<: *db-credentials
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 6
volumes:
- type: volume
source: listmonk-data
target: /var/lib/postgresql/data
networks:
listmonk:
volumes:
listmonk-data:Running the container’s APP. #
So after the environment, acces end-point and docker-compose file are setup, lets run the container! Simply execute the below command. Make sure docker is installed first if you dont use a premade docker-lxc/os with docker pre-installed.
docker compose up -dConfiguring. #
after a few seconds you should be able to acces the listmonk login page by going to either the container’s ip and port 9000 or by the domain/endpoint you set up. Here you log in with the credentials you just set for admin. SO for me it was logging in with user ‘admin’ and password ‘mypassword123456foobar!’, but make sure you put in your own credentials (obviously).
General settings. #
So after you logged in you see the dashboard. First we need to setup SMTP. Go to: Settings > Settings > General
Here you find a bunch of settings you need to configure. First you need to configure a ‘default email’, which will be the Email that sends the information to subscribers. After that you can change the ‘root URL’ to the domain/address you host the listmonk service on. Then you can also add your own logos by adding them in the ‘Logo URL’ and “Favicon URL” boxes (if you store them in a CDN like i do).
CORS #
So now we also have to configure the Cross-Origin-Resource_sharing (CORS) parameters. These basically have to be set to the website you put the subscription form on. This makes it so you wont get that it denies connections coming from your website and its for security reasons. So navigate to: Settings > Settings > Security. Then add yourwebsite url to the CORS input box.
SMTP. #
Now we need to setup SMTP. Go to: Settings > Settings > SMTP.

Here you fill in things like your email provider’s host address (because i use mailbox.org mine is smtp.mailbox.org but your might be different). Then you can also set ‘auth protocol’ to “login” and then fill in your email provider’s SMTP credentials. Then you need to set ‘TLS’ to “ssl/tls” after that you can set the port to the right one. For all credentials, check with your email provider.
And as last you can click the “test connection” button in the down right which opens a dialog where you can put in an email address to send a test message to to verify if SMTP is working. It should also automatically populate with the email you set as ‘default email’ which will be the sender email.
Adding a list. #
So after SMTP works you can start adding a list. This list will be to which subscribers subscribe. So navigate to: Lists > All Lists. Here you see a default list. You can remove that one and then click in the top right the “+ New” button. This opens a dialog where you can enter the name of your list (how it will be called for subscribers). Then set “Type” to ‘public’ so its public and set “opt-in” to double opt-in or single opt-in (depends also on laws or what you prefer). Then tags can be optional. Then you can also write a small description of the list and then click ‘save’.
Adding our email to the subscribers list. #
SO lets add our email to the subscribers list. Navigate to: Subscribers > All Subscribers. Here you can remove the default/example subscribers. After that click on the “+New” button in top right and you should see this dialog below.

Here you put in your email address and you can give yourself a name. After you entered it you select the lists box and here you can add your freshly created list as this makes it so you are added to the list. Then click on save. Then click on the entry you added and click on the “Send opt-in confirmation” button which will send you a confirmation email to get added to the list. If you have not recieved the mail, your mail could have flagged it as spam and it would be in the spam folder.
In the mail click the confirm button and you are now added to your own subscribers list.
Setting up a campaign #
SO now navigate to: Campaigns > All Campaigns and here you see a couple default campaigns. You can remove them or not, its up to you. You can again click the topright button to create a new campaign. Give it a name and subject, then you can select your “from address” which is just the email you configured as default. Also you should select your list and then click ‘continue’. After that you go to a “content” page and inside here you can write a test message. Then you can can go to the top right and click on “Start campaign” which will send the message/mail to all people added to the subscribers page. And then see if you get the mail. If that works you are already done with most of the work!
Setting it up on the hugo blog. #
SO lets now set it up so we get it on the blog page or your own hugo website. So i made a folder called “shortcodes” in the layouts folder in the root of the hugo files directory. And inside layouts/shortcodes i created a file called: Subscribe.html. Below is the file.
<div id="subscribe-widget" style="margin: 2rem 0; padding: 1.25rem 1.5rem; border: 1px solid rgba(128,128,128,0.2); border-radius: 0.75rem;">
<p style="margin: 0 0 0.25rem; font-weight: 600;">Stay in the loop</p>
<p style="margin: 0 0 1rem; opacity: 0.7; font-size: 0.9rem;">Get notified when a new post goes up. No spam, unsubscribe anytime. Please read the <a href="https://blog.309electronics.net/privacy-policy/">Privacy policy</a> for more information.</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.5rem;">
<input type="text" id="sub-name" placeholder="Your name (optional)"
style="flex: 1; min-width: 200px; padding: 0.5rem 0.75rem; border: 1px solid rgba(128,128,128,0.3); border-radius: 0.5rem; background: transparent; font-size: 0.95rem;" />
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<input type="email" id="sub-email" placeholder="your@email.com"
style="flex: 1; min-width: 200px; padding: 0.5rem 0.75rem; border: 1px solid rgba(128,128,128,0.3); border-radius: 0.5rem; background: transparent; font-size: 0.95rem;" />
<button onclick="handleSubscribe()" id="sub-btn"
style="padding: 0.5rem 1.25rem; border-radius: 0.5rem; border: none; cursor: pointer; font-size: 0.95rem;">
Subscribe
</button>
</div>
<p id="sub-msg" style="display:none; margin: 0.75rem 0 0; font-size: 0.9rem;"></p>
<p style="margin: 1rem 0 0; opacity: 0.7; font-size: 0.9rem; display: flex; align-items: center; gap: 6px;">
<img src="https://yournewsletter-domain/admin/static/favicon.png" alt="Listmonk" style="width: 16px; height: 16px;">
<span>Powered by <a href="https://listmonk.app">Listmonk</a></span>
</p>
</div>
<script>
const LIST_UUID = '7dc7a5a0-33ec-46de-93c7-9b5c40bc139a';
const SUBSCRIPTION_API = 'https://yournewsletter-domain/api/public/subscription';
const STATIC_ASSET_URL = 'https://yournewsletter-domain/admin/static/favicon.png';
const unavailableHTML = `
<p style="margin: 0 0 0.25rem; font-weight: 600;">Stay in the loop</p>
<p style="margin: 0; opacity: 0.7; font-size: 0.9rem; color: red;">⚠️ ERROR: Cant connect to the subscription service API. Subscription service is unavailable at the moment. System could be temporarily down for maintenance/due to an error on our side, please check back again after some time. Otherwise <a href="mailto:webmaster@yourdomain.net" style="text-decoration: underline;">contact the webmaster</a> </p>
`;
function showServiceFailure() {
const widget = document.getElementById('subscribe-widget');
widget.style.borderColor = 'rgba(255,180,0,0.3)';
widget.style.background = 'rgba(255,180,0,0.05)';
widget.innerHTML = unavailableHTML;
}
function checkBackendStatus() {
const imgCheck = new Image();
const timeoutId = setTimeout(() => {
imgCheck.src = "";
showServiceFailure();
}, 3500);
imgCheck.onload = function() {
clearTimeout(timeoutId);
};
imgCheck.onerror = function() {
clearTimeout(timeoutId);
showServiceFailure();
};
imgCheck.src = STATIC_ASSET_URL + '?t=' + new Date().getTime();
}
async function handleSubscribe() {
const name = document.getElementById('sub-name').value.trim();
const email = document.getElementById('sub-email').value.trim();
const msg = document.getElementById('sub-msg');
const btn = document.getElementById('sub-btn');
if (!email) return;
btn.disabled = true;
btn.textContent = 'Sending...';
const payload = { email: email, list_uuids: [LIST_UUID] };
if (name) payload.name = name;
try {
const res = await fetch(SUBSCRIPTION_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
msg.style.display = 'block';
if (res.ok) {
msg.style.color = 'green';
msg.textContent = '✓ Check your inbox to confirm your subscription! If you have not received it please check your Spam folder.';
document.getElementById('sub-name').value = '';
document.getElementById('sub-email').value = '';
setTimeout(() => {
btn.disabled = false;
btn.textContent = 'Subscribe';
msg.textContent = '';
msg.style.display = 'none';
}, 4500);
} else if (res.status >= 400 && res.status < 500) {
msg.style.color = 'red';
msg.textContent = 'ERROR: Your email adress syntax is incorrect. Please check and try again. Should be: you@email_provider.com';
setTimeout(() => {
btn.disabled = false;
btn.textContent = 'Subscribe';
msg.textContent = '';
msg.style.display = 'none';
}, 4500);
} else {
showServiceFailure();
}
} catch (e) {
showServiceFailure();
}
}
checkBackendStatus();
</script>change the lines below to your own stuff.
<img src="https://yournewsletter-domain/admin/static/favicon.png" alt="Listmonk" style="width: 16px; height: 16px;">
<span>Powered by <a href="https://listmonk.app">Listmonk</a></span>
</p>
</div>
<script>
const LIST_UUID = '7dc7a5a0-33ec-46de-93c7-9b5c40bc139a';
const SUBSCRIPTION_API = 'https://yournewsletter-domain/api/public/subscription';
const STATIC_ASSET_URL = 'https://yournewsletter-domain/admin/static/favicon.png';
const unavailableHTML = `
<p style="margin: 0 0 0.25rem; font-weight: 600;">Stay in the loop</p>
<p style="margin: 0; opacity: 0.7; font-size: 0.9rem; color: red;">⚠️ ERROR: Cant connect to the subscription service API. Subscription service is unavailable at the moment. System could be temporarily down for maintenance/due to an error on our side, please check back again after some time. Otherwise <a href="mailto:webmaster@yourdomain.net" style="text-decoration: underline;">contact the webmaster</a> </p>you can find the UUID of the list you created by going to your list, clicking on it and in the top bove the name you can see a faint “UUID: abcdefghijklmnop” (or whatever its set to) and when you hover over it you can copy the ID. Then fill in that List-ID.
Then you can add the code down below to either your _index.md or other files/pages you want to have the subscription form on. Because i dont want to put ther subscription form on this page, i had to take it apart sort of so it would not render as HTML. You got to take: “< subscribe >” and put it inbetween “{{}}”. And thats it!
Modifying the default templates. #
So if we want to modify the looks and feel of the opt-in email, we can sadly not easily drag and drop in a custom template, we actually need to modify the default templates as they are baked into the app, but listmonk does allow specifying a custom templates directory.
So first you need to get into the shell of your server hosting the listmonk container. Then you need to apply a change to the docker-compose.yml file to pass in an additional commandline argument. So first navigate to “/opt/listmonk” or whatever directory you setup where your docker-compose file is in. Then you need to take down the container, execute the below command.
docker compose downThis stops the container and the App. Now open docker-compose.yml in a text editor like vim or nano (your preference) and find the below string.
command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"]and change it to
command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config '' --static-dir=/listmonk/static"]What this does is it specifies to listmonk that it uses the /listmonk/static (internal directory in the container) to which it mounted the outside directory (in the os running docker) ’listmonk/static’. Now you need to populate the map, execute the commands below.
mkdir -p /opt/listmonk/static
wget O - https://github.com/knadh/listmonk/archive/master.tar.gz | tar xz -C listmonk/static --strip=2 "listmonk-master/static"This clones the default files from the listmonk github and places them in the map. Now you can edit them to your liking, just know they are in HTML so you need to have a bit of HTML skills or use AI.
The map “email-templates” contains all templates that will be sent in emails. To change the opt-in email template, edit the file ‘subscriber-optin.html’. Below is my template but you can edit it to your likings. Just make sure to leave in the custom variables as listmonk needs some of those.
{{ define "subscriber-optin" }}
{{ template "header" . }}
<div style="background:#1a1a1a;padding:14px 20px;margin:-30px -30px 24px -30px;border-radius:5px 5px 0 0;">
<p style="margin:0;font-size:16px;font-weight:600;color:#ffffff;">309electronics</p>
<p style="margin:2px 0 0;font-size:12px;color:#888888;">blog.309electronics.net</p>
</div>
<p>Dear {{ if .Subscriber.FirstName }}{{ .Subscriber.FirstName }}{{ else }}there{{ end }},</p>
<p>You have signed up for the following list(s):</p>
<ul>
{{ range $i, $l := .Lists }}
{{ if eq .Type "public" }}
<li><strong>{{ .Name }}</strong></li>
{{ else }}
<li><em>(private list)</em></li>
{{ end }}
{{ end }}
</ul>
<p>This is the list that users are added to when they sign up for the newsletter on <a href="https://foobar.yourwebsite.com">foobar.yourwebsite.com</a>. Anyone on this list will be notified when a new article is posted, or about planned downtime/maintenance and other useful updates. It's fully self-hosted, private, secure, and free of charge for as long as I can keep it that way.</p>
<p>It's double opt-in — not only because of GDPR, but because I genuinely value user consent. I don't like forcing users into anything, and I'm not a fan of companies selling user data or bombarding people with ads just to access basic services. Please read the <a href="https://blog.309electronics.net/privacy-policy">privacy policy</a> on the blog if you're curious about how privacy is handled here. Check the 'modified' date at the top to make sure you have the most current version.</p>
<p>If you signed up and received this email, click the button below to confirm your subscription and get added to the list:</p>
<p>
<a href="{{ .OptinURL }}" class="button">Confirm subscription →</a>
</p>
<p>Really grateful to have you onboard! You'll be the first to know when new posts go up, and you'll get a heads-up before any planned downtime so you're not caught off guard by a 503. You can unsubscribe at any time with no consequences or hidden contracts — just click unsubscribe whenever you need to. You're also always welcome to email the <a href="mailto:webmaster@yourwebsite.com"> webmaster</a> directly with any questions.</p>
<hr style="border:none;border-top:1px solid #eee;margin:24px 0;">
<p><strong>I did not sign up for this...</strong></p>
<p>If you didn't sign up, someone else may have entered your email address — possibly a family member, or a bad actor. Check with people you know first. If that's not the case, your address may have been leaked via a data breach at a data broker or third party. Either way, if you don't want to subscribe, simply don't click the confirm button and ignore or delete this email. Nothing will happen and you won't receive anything further.</p>
<p>Have a nice day,<br>
<strong>Your_real_name</strong> — nickname</p>
{{ template "footer" }}
{{ end }}And again edit the strings i changed to some random foobar stuff. While i still am not a fan of vibecoding, i did use Claude AI to generate the templates for me. I do know HTML but i dont want to spend long making sure everything is the right size and the formatting/looks are good and using AI to generate basic templates is something i dont mind.
After applying the changes, you can restart the docker container. If you have not already shut it down, shut it down first.
docker compose downand after that spin it up again.
docker compose up -dAnd here is how my finished opt-in email template looks.

End #
So thats it for now! I took the blog offline in the meantime to work on this and today (Sunday the 17th of May) is the day i will set the blog and systems online after tetsing.