Initial listmonk role: eom.dev newsletter on Kubernetes

listmonk v6.2.0 with PostgreSQL 17, Dex login for listed admins,
delivery through postfix.eom.dev, Catppuccin public pages, Altcha
captcha and a double opt-in "eom.dev" list. Database-held settings
are converged by an idempotent SQL script, tested against v6.2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 20:17:30 -05:00
co-authored by Claude Opus 5.5
commit 22a7ffe676
15 changed files with 801 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
{#- Converges listmonk's database-held configuration. Every statement prints
the number of rows it changed, so the play can report "changed" and only
restart listmonk when something actually moved. -#}
{%- macro q(s) -%}$lmv${{ s }}$lmv${%- endmacro -%}
{%- set settings = {
'app.site_name': listmonk_site_name,
'app.root_url': listmonk_root_url,
'app.from_email': listmonk_from_email,
'app.logo_url': listmonk_logo_url,
'app.favicon_url': listmonk_favicon_url,
'app.notify_emails': listmonk_notify_emails,
'app.enable_public_subscription_page': true,
'app.enable_public_archive': true,
'app.send_optin_confirmation': true,
'privacy.individual_tracking': false,
'privacy.unsubscribe_header': true,
'privacy.record_optin_ip': false,
'security.captcha': {
'altcha': {'enabled': true, 'complexity': 300000},
'hcaptcha': {'enabled': false, 'key': '', 'secret': ''},
},
'security.oidc': {
'enabled': listmonk_oidc_enabled | bool,
'provider_url': listmonk_oidc_provider_url,
'provider_name': listmonk_oidc_provider_name,
'client_id': listmonk_oidc_client_id,
'client_secret': listmonk_oidc_client_secret,
'auto_create_users': false,
'default_user_role_id': none,
'default_list_role_id': none,
},
'upload.provider': 'filesystem',
'upload.filesystem.upload_path': '/listmonk/uploads',
'upload.filesystem.upload_uri': '/uploads',
'smtp': [{
'enabled': true,
'host': listmonk_smtp_host,
'port': listmonk_smtp_port | int,
'auth_protocol': 'login',
'username': listmonk_smtp_username,
'password': listmonk_smtp_password,
'hello_hostname': listmonk_host,
'max_conns': listmonk_smtp_max_conns | int,
'idle_timeout': '15s',
'wait_timeout': '5s',
'max_msg_retries': 2,
'msg_retry_delay': '10ms',
'tls_type': listmonk_smtp_tls_type,
'tls_skip_verify': false,
'email_headers': [],
'from_addresses': [],
}],
'appearance.public.custom_css': lookup('ansible.builtin.file', role_path ~ '/files/public.css'),
'appearance.admin.custom_css': lookup('ansible.builtin.file', role_path ~ '/files/admin.css'),
} | combine(listmonk_settings_extra) -%}
\set ON_ERROR_STOP on
BEGIN;
WITH desired(key, value) AS (VALUES
{% for k, v in settings.items() %}
({{ q(k) }}, {{ q(v | to_json) }}::jsonb){{ ',' if not loop.last }}
{% endfor %}
), changed AS (
UPDATE settings s SET value = d.value, updated_at = NOW()
FROM desired d
WHERE s.key = d.key AND s.value IS DISTINCT FROM d.value
RETURNING s.key
)
SELECT count(*) FROM changed;
{% for u in listmonk_oidc_admins %}
WITH ins AS (
INSERT INTO users (username, password_login, email, name, type, user_role_id, status)
SELECT {{ q(u.username) }}, false, {{ q(u.email | lower) }}, {{ q(u.name) }}, 'user', r.id, 'enabled'
FROM roles r WHERE r.name = 'Super Admin' AND r.type = 'user'
ON CONFLICT DO NOTHING
RETURNING id
)
SELECT count(*) FROM ins;
{% endfor %}
{% for l in listmonk_lists %}
WITH ins AS (
INSERT INTO lists (uuid, name, type, optin, tags, description)
SELECT gen_random_uuid(), {{ q(l.name) }}, {{ q(l.type | default('public')) }}::list_type,
{{ q(l.optin | default('double')) }}::list_optin,
ARRAY[{% for t in l.tags | default([]) %}{{ q(t) }}{{ ',' if not loop.last }}{% endfor %}]::varchar(100)[],
{{ q(l.description | default('') | trim) }}
WHERE NOT EXISTS (SELECT 1 FROM lists WHERE name = {{ q(l.name) }})
RETURNING id
)
SELECT count(*) FROM ins;
WITH upd AS (
UPDATE lists SET type = {{ q(l.type | default('public')) }}::list_type,
optin = {{ q(l.optin | default('double')) }}::list_optin,
description = {{ q(l.description | default('') | trim) }},
updated_at = NOW()
WHERE name = {{ q(l.name) }}
AND (type, optin, description) IS DISTINCT FROM
({{ q(l.type | default('public')) }}::list_type, {{ q(l.optin | default('double')) }}::list_optin, {{ q(l.description | default('') | trim) }})
RETURNING id
)
SELECT count(*) FROM upd;
{% endfor %}
{% if listmonk_remove_sample_data | bool %}
WITH del AS (
DELETE FROM campaigns
WHERE archive_slug = 'welcome-to-listmonk' AND status = 'draft' AND 'test-campaign' = ANY(tags)
RETURNING id
)
SELECT count(*) FROM del;
WITH del AS (
DELETE FROM subscribers WHERE email IN ('john@example.com', 'anon@example.com') RETURNING id
)
SELECT count(*) FROM del;
WITH del AS (
DELETE FROM lists l
WHERE l.name IN ('Default list', 'Opt-in list') AND l.tags = ARRAY['test']::varchar(100)[]
AND NOT EXISTS (SELECT 1 FROM subscriber_lists sl WHERE sl.list_id = l.id)
RETURNING id
)
SELECT count(*) FROM del;
{% endif %}
COMMIT;
+147
View File
@@ -0,0 +1,147 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ listmonk_name }}-uploads
namespace: {{ listmonk_namespace }}
labels:
app.kubernetes.io/name: listmonk
app.kubernetes.io/instance: {{ listmonk_name }}
spec:
accessModes: [ReadWriteOnce]
storageClassName: {{ listmonk_storage_class }}
resources:
requests:
storage: {{ listmonk_uploads_storage_size }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ listmonk_name }}
namespace: {{ listmonk_namespace }}
labels:
app.kubernetes.io/name: listmonk
app.kubernetes.io/instance: {{ listmonk_name }}
app.kubernetes.io/component: app
spec:
replicas: 1
# One writer: listmonk's campaign manager is not safe to run twice.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/instance: {{ listmonk_name }}
app.kubernetes.io/component: app
template:
metadata:
labels:
app.kubernetes.io/name: listmonk
app.kubernetes.io/instance: {{ listmonk_name }}
app.kubernetes.io/component: app
annotations:
checksum/secret: {{ lookup('ansible.builtin.template', 'secret.yml.j2') | hash('sha256') }}
spec:
affinity: {{ listmonk_affinity | to_json }}
containers:
- name: listmonk
image: {{ listmonk_image }}
imagePullPolicy: {{ listmonk_image_pull_policy }}
# --config '' : configuration comes from the environment only.
# --install --idempotent only installs into an empty database;
# --upgrade runs migrations after an image bump.
command:
- sh
- -c
- >-
./listmonk --install --idempotent --yes --config '' &&
./listmonk --upgrade --yes --config '' &&
exec ./listmonk --config ''
env:
- name: TZ
value: America/New_York
- name: LISTMONK_app__address
value: 0.0.0.0:9000
- name: LISTMONK_db__host
value: {{ listmonk_name }}-db
- name: LISTMONK_db__port
value: "5432"
- name: LISTMONK_db__user
value: listmonk
- name: LISTMONK_db__database
value: listmonk
- name: LISTMONK_db__ssl_mode
value: disable
envFrom:
- secretRef:
name: {{ listmonk_name }}
ports:
- name: http
containerPort: 9000
startupProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
failureThreshold: 60
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
periodSeconds: 30
timeoutSeconds: 5
resources: {{ listmonk_resources | to_json }}
volumeMounts:
- name: uploads
mountPath: /listmonk/uploads
volumes:
- name: uploads
persistentVolumeClaim:
claimName: {{ listmonk_name }}-uploads
---
apiVersion: v1
kind: Service
metadata:
name: {{ listmonk_name }}
namespace: {{ listmonk_namespace }}
labels:
app.kubernetes.io/name: listmonk
app.kubernetes.io/instance: {{ listmonk_name }}
spec:
selector:
app.kubernetes.io/instance: {{ listmonk_name }}
app.kubernetes.io/component: app
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ listmonk_name }}
namespace: {{ listmonk_namespace }}
labels:
app.kubernetes.io/name: listmonk
app.kubernetes.io/instance: {{ listmonk_name }}
annotations: {{ listmonk_ingress_annotations | to_json }}
spec:
ingressClassName: {{ listmonk_ingress_class_name }}
tls:
- hosts:
- {{ listmonk_host }}
secretName: {{ listmonk_tls_secret_name }}
rules:
- host: {{ listmonk_host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ listmonk_name }}
port:
name: http
+90
View File
@@ -0,0 +1,90 @@
apiVersion: v1
kind: Service
metadata:
name: {{ listmonk_name }}-db
namespace: {{ listmonk_namespace }}
labels:
app.kubernetes.io/name: listmonk
app.kubernetes.io/instance: {{ listmonk_name }}
app.kubernetes.io/component: database
spec:
clusterIP: None
selector:
app.kubernetes.io/instance: {{ listmonk_name }}
app.kubernetes.io/component: database
ports:
- name: postgres
port: 5432
targetPort: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ listmonk_name }}-db
namespace: {{ listmonk_namespace }}
labels:
app.kubernetes.io/name: listmonk
app.kubernetes.io/instance: {{ listmonk_name }}
app.kubernetes.io/component: database
spec:
serviceName: {{ listmonk_name }}-db
replicas: 1
selector:
matchLabels:
app.kubernetes.io/instance: {{ listmonk_name }}
app.kubernetes.io/component: database
template:
metadata:
labels:
app.kubernetes.io/name: listmonk
app.kubernetes.io/instance: {{ listmonk_name }}
app.kubernetes.io/component: database
spec:
# postgres:*-alpine runs as uid/gid 70. Running unprivileged skips the
# entrypoint's chown, which NFS with root_squash would refuse.
securityContext:
runAsUser: 70
runAsGroup: 70
fsGroup: 70
affinity: {{ listmonk_affinity | to_json }}
containers:
- name: postgres
image: {{ listmonk_postgres_image }}
imagePullPolicy: {{ listmonk_image_pull_policy }}
env:
- name: POSTGRES_USER
value: listmonk
- name: POSTGRES_DB
value: listmonk
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ listmonk_name }}
key: POSTGRES_PASSWORD
ports:
- name: postgres
containerPort: 5432
readinessProbe:
exec:
command: [pg_isready, -U, listmonk, -d, listmonk]
periodSeconds: 10
livenessProbe:
exec:
command: [pg_isready, -U, listmonk, -d, listmonk]
initialDelaySeconds: 30
periodSeconds: 30
resources: {{ listmonk_postgres_resources | to_json }}
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
storageClassName: {{ listmonk_storage_class }}
resources:
requests:
storage: {{ listmonk_db_storage_size }}
+14
View File
@@ -0,0 +1,14 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ listmonk_name }}
namespace: {{ listmonk_namespace }}
labels:
app.kubernetes.io/name: listmonk
app.kubernetes.io/instance: {{ listmonk_name }}
type: Opaque
stringData:
POSTGRES_PASSWORD: {{ listmonk_db_password | to_json }}
LISTMONK_db__password: {{ listmonk_db_password | to_json }}
LISTMONK_ADMIN_USER: {{ listmonk_admin_user | to_json }}
LISTMONK_ADMIN_PASSWORD: {{ listmonk_admin_password | to_json }}