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
+1
View File
@@ -0,0 +1 @@
.render/
+16
View File
@@ -0,0 +1,16 @@
MIT No Attribution
Copyright 2026 Eric Meehan
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+67
View File
@@ -0,0 +1,67 @@
# ansible-role-listmonk
Deploys [listmonk](https://listmonk.app) v6.2.0, the eom.dev newsletter, to Kubernetes.
It fits in with the rest of eom.dev:
- **Login** goes through Dex (`listmonk` static client) with the same "eom.dev account" button as the other services. Only e-mail addresses listed in `listmonk_oidc_admins` can sign in, so being a member is not enough to get into the admin UI. A password login for the `admin` user is kept as a fallback.
- **Mail** goes out through `postfix.eom.dev:587` as the LDAP account `news`, the same way Gitea, Nextcloud and PeerTube send mail.
- **Look**: the public pages (subscribe, archive, preferences, unsubscribe) use the Catppuccin Mocha/Latte palette from `dex.eom.dev`, along with the eom.dev logo and favicon.
- **Cluster conventions**: `ca-issuer` TLS, `nfs-client` volumes, and anti-affinity that keeps the pods off the ingress-nginx node.
## What it creates
| Object | Purpose |
| --- | --- |
| `Secret/listmonk` | DB password and bootstrap admin credentials |
| `StatefulSet/listmonk-db` | PostgreSQL 17 (alpine, uid 70) on `nfs-client` |
| `Deployment/listmonk` | Runs `--install --idempotent`, then `--upgrade`, then serves on :9000 |
| `PVC/listmonk-uploads` | Media uploaded for campaigns |
| `Ingress/listmonk` | `https://listmonk.eom.dev` |
listmonk keeps most of its configuration in the `settings` table, not in a config file. The role writes those settings with an idempotent SQL script (`templates/configure.sql.j2`) that it runs in the database pod. The script covers SMTP, Dex, theme, captcha, privacy, the Dex admin users and the lists in `listmonk_lists`. On the first run it also removes listmonk's sample data. listmonk is restarted only when a row actually changes.
Settings the role manages are reset on every run. Change them in `defaults/main.yml` or `listmonk_settings_extra`, not in the UI. Everything else, including templates, campaigns and subscribers, stays under the UI's control.
## Required variables (Vault)
| Variable | Notes |
| --- | --- |
| `listmonk_db_password` | 16+ chars. Only used when the volume is first initialised |
| `listmonk_admin_password` | 8+ chars, for the `admin` fallback login |
| `listmonk_smtp_password` | Password of the `news` LDAP account |
| `listmonk_oidc_client_secret` | Must match the `listmonk` client in Dex |
The eom.dev playbook maps them from the Vault names `listmonk_postgresql_password`, `listmonk_admin_password`, `listmonk_smtp_password` and `listmonk_dex_client_secret`.
## One-time setup outside this role
1. In LDAP, create the posix account `news` with `mail: news@eom.dev` and add it to `cn=Postfix Users,ou=Postfix,ou=Services,dc=eom,dc=dev`. Its password is `listmonk_smtp_password`.
2. Add the `listmonk` client to Dex (`ericomeehan.dex/playbooks/deploy-eom.yml`) and run that play.
## Embedding a signup form
listmonk accepts a plain HTML POST, so any eom.dev page can include a form:
```html
<form method="post" action="https://listmonk.eom.dev/subscription/form">
<input type="hidden" name="nonce">
<input type="email" name="email" required placeholder="you@example.com">
<input type="hidden" name="l" value="LIST-UUID">
<button type="submit">Subscribe</button>
</form>
```
Copy the list UUID from **Lists → eom.dev → Forms** in the admin UI. The form uses double opt-in, so subscribers have to confirm by e-mail. The page at `https://listmonk.eom.dev/subscription/form` adds an Altcha proof-of-work captcha.
## Testing
```sh
ansible-playbook -i tests/inventory tests/render.yml # renders into .render/
```
This version was tested against `listmonk/listmonk:v6.2.0` and `postgres:17.11-alpine`. The SQL changes 12 settings, 1 user, 1 list and the sample data on the first run, and nothing on the second.
## License
MIT-0
+105
View File
@@ -0,0 +1,105 @@
# SPDX-License-Identifier: MIT-0
---
listmonk_name: listmonk
listmonk_namespace: listmonk
listmonk_create_namespace: true
# null uses normal kubeconfig discovery, including KUBECONFIG.
listmonk_kubeconfig: null
listmonk_kube_context: null
listmonk_wait_timeout: 600
listmonk_image: listmonk/listmonk:v6.2.0
listmonk_postgres_image: postgres:17.11-alpine
listmonk_image_pull_policy: IfNotPresent
listmonk_host: listmonk.eom.dev
listmonk_root_url: "https://{{ listmonk_host }}"
listmonk_ingress_class_name: nginx
listmonk_ingress_annotations:
cert-manager.io/cluster-issuer: ca-issuer
nginx.ingress.kubernetes.io/proxy-body-size: 16m
listmonk_tls_secret_name: "{{ listmonk_name }}-tls"
listmonk_storage_class: nfs-client
listmonk_db_storage_size: 2Ti
listmonk_uploads_storage_size: 2Ti
# Required secrets (Vault). The database password is only read on first
# initialisation of the volume; change it in Postgres before changing it here.
listmonk_db_password: ""
# Break-glass password login for the "admin" super admin. Day-to-day login is Dex.
listmonk_admin_user: admin
listmonk_admin_password: ""
# Mail goes out through postfix.eom.dev as an LDAP account in "Postfix Users",
# the same pattern Gitea, Nextcloud and PeerTube use. SPOOF_PROTECTION requires
# the From address to belong to that account.
listmonk_smtp_host: postfix.eom.dev
listmonk_smtp_port: 587
listmonk_smtp_tls_type: STARTTLS
listmonk_smtp_username: news
listmonk_smtp_password: ""
listmonk_smtp_max_conns: 4
listmonk_from_email: "eom.dev <news@eom.dev>"
# Log in with Dex. Users are matched by e-mail and must already exist in
# listmonk, so being an eom.dev member alone grants nothing.
listmonk_oidc_enabled: true
listmonk_oidc_provider_url: https://dex.eom.dev/
listmonk_oidc_provider_name: eom.dev account
listmonk_oidc_client_id: listmonk
listmonk_oidc_client_secret: ""
# Accounts allowed to log in through Dex as Super Admin.
listmonk_oidc_admins:
- username: eric
name: Eric Meehan
email: eric@eom.dev
listmonk_site_name: eom.dev
listmonk_logo_url: https://dex.eom.dev/theme/logo.svg
listmonk_favicon_url: https://www.eom.dev/static/favicon.svg
listmonk_notify_emails:
- eric@eom.dev
# Lists are created by name if missing and never deleted. Existing lists keep
# any edits made in the UI except for type, optin and description.
listmonk_lists:
- name: eom.dev
type: public
optin: double
tags: [newsletter]
description: >-
One short email per premiere, plus the monthly essay. Nothing else.
# Drop the sample lists, subscribers and campaign created by --install.
listmonk_remove_sample_data: true
# Extra settings rows (key: JSON value) merged over the role's own.
listmonk_settings_extra: {}
listmonk_resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 512Mi
listmonk_postgres_resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
# Keep off the node that runs the ingress controller, like the other services.
listmonk_affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- topologyKey: kubernetes.io/hostname
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
labelSelector:
matchLabels:
app.kubernetes.io/component: controller
app.kubernetes.io/name: ingress-nginx
+9
View File
@@ -0,0 +1,9 @@
/* eom.dev: Catppuccin accent in the admin UI, light theme kept for readability. */
:root {
--primary: #8839ef;
--link: #8839ef;
}
.navbar.is-dark, .navbar { border-bottom: 3px solid #8839ef; }
.button.is-primary, .tag.is-primary { background-color: #8839ef; }
.button.is-primary:hover { background-color: #7287fd; }
a { color: #8839ef; }
+73
View File
@@ -0,0 +1,73 @@
/* eom.dev: Catppuccin Mocha / Latte, matching dex.eom.dev. */
:root {
color-scheme: dark;
--base: #1e1e2e;
--mantle: #181825;
--surface: #313244;
--border: #585b70;
--text: #cdd6f4;
--muted: #a6adc8;
--accent: #cba6f7;
--accent-hover: #b4befe;
--on-accent: #11111b;
--error: #f38ba8;
--focus: #fab387;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--base: #eff1f5;
--mantle: #e6e9ef;
--surface: #fff;
--border: #9ca0b0;
--text: #4c4f69;
--muted: #5c5f77;
--accent: #8839ef;
--accent-hover: #7287fd;
--on-accent: #fff;
--error: #d20f39;
--focus: #fe640b;
}
}
html, body {
background: var(--base);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.6;
}
a { color: var(--accent); }
a:hover { color: var(--accent-hover); }
h1, h2, h3, h4, label { color: var(--text); }
.small, .lists .description, .archive .date, footer.container, footer a { color: var(--muted); }
.wrap {
background: var(--mantle);
border: 1px solid var(--border);
border-radius: 1.25rem;
box-shadow: 0 12px 40px rgb(0 0 0 / 10%);
}
.header { border-bottom: 1px solid var(--border); }
.header .logo img { max-width: 240px; max-height: 72px; }
input[type="text"], input[type="email"], input[type="password"], select {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: 0.6rem;
}
input:focus, select:focus, .button:focus-visible {
border-color: var(--focus);
outline: 2px solid var(--focus);
outline-offset: 1px;
}
input::placeholder { color: var(--muted); }
.button {
background: var(--accent);
color: var(--on-accent);
border: 1px solid var(--accent);
border-radius: 0.6rem;
font-weight: 650;
}
.button:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--on-accent); }
.button.button-outline { background: transparent; color: var(--accent); }
.button.button-outline:hover { background: var(--surface); color: var(--accent-hover); }
.error { color: var(--error); }
.pg-page.pg-selected { background: var(--accent); color: var(--on-accent); }
+14
View File
@@ -0,0 +1,14 @@
# SPDX-License-Identifier: MIT-0
---
galaxy_info:
role_name: listmonk
author: Eric Meehan
description: Deploy the eom.dev newsletter (listmonk) to Kubernetes with Dex login and postfix.eom.dev delivery.
license: MIT-0
min_ansible_version: "2.16"
galaxy_tags:
- kubernetes
- listmonk
- newsletter
- email
dependencies: []
+13
View File
@@ -0,0 +1,13 @@
# SPDX-License-Identifier: MIT-0
# eom.dev deployment. Copy of software-infrastructure/listmonk.yaml.
- name: Deploy listmonk
hosts: alpha-control-plane
become: true
vars_files:
- ../secrets.yaml
roles:
- role: ericomeehan.listmonk
# listmonk_admin_password and listmonk_smtp_password come from Vault as-is.
vars:
listmonk_db_password: "{{ listmonk_postgresql_password }}"
listmonk_oidc_client_secret: "{{ listmonk_dex_client_secret }}"
+90
View File
@@ -0,0 +1,90 @@
# SPDX-License-Identifier: MIT-0
---
- name: Validate required secrets
ansible.builtin.assert:
that:
- listmonk_db_password | length >= 16
- listmonk_admin_password | length >= 8
- listmonk_smtp_password | length > 0
- not listmonk_oidc_enabled | bool or listmonk_oidc_client_secret | length > 0
fail_msg: >-
Set listmonk_db_password (16+ chars), listmonk_admin_password (8+),
listmonk_smtp_password and listmonk_oidc_client_secret in Vault.
quiet: true
- name: Create namespace
kubernetes.core.k8s:
kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}"
context: "{{ listmonk_kube_context | default(omit, true) }}"
state: present
definition:
apiVersion: v1
kind: Namespace
metadata:
name: "{{ listmonk_namespace }}"
when: listmonk_create_namespace | bool
- name: Store credentials
kubernetes.core.k8s:
kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}"
context: "{{ listmonk_kube_context | default(omit, true) }}"
state: present
apply: true
definition: "{{ lookup('ansible.builtin.template', 'secret.yml.j2') | from_yaml }}"
no_log: true
- name: Deploy PostgreSQL
kubernetes.core.k8s:
kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}"
context: "{{ listmonk_kube_context | default(omit, true) }}"
state: present
apply: true
definition: "{{ lookup('ansible.builtin.template', 'postgres.yml.j2') | from_yaml_all | list }}"
wait: true
wait_timeout: "{{ listmonk_wait_timeout }}"
- name: Deploy listmonk
kubernetes.core.k8s:
kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}"
context: "{{ listmonk_kube_context | default(omit, true) }}"
state: present
apply: true
definition: "{{ lookup('ansible.builtin.template', 'listmonk.yml.j2') | from_yaml_all | list }}"
wait: true
wait_timeout: "{{ listmonk_wait_timeout }}"
# Settings, Dex admins and lists live in listmonk's database, not its config.
- name: Converge database-held configuration
kubernetes.core.k8s_exec:
kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}"
context: "{{ listmonk_kube_context | default(omit, true) }}"
namespace: "{{ listmonk_namespace }}"
pod: "{{ listmonk_name }}-db-0"
container: postgres
command: >-
sh -c 'echo {{ lookup('ansible.builtin.template', 'configure.sql.j2') | b64encode }}
| base64 -d | psql -U listmonk -d listmonk -qtA'
register: listmonk_configure
changed_when: listmonk_configure.stdout_lines | map('int') | sum > 0
no_log: true
- name: Restart listmonk to load the new settings
kubernetes.core.k8s:
kubeconfig: "{{ listmonk_kubeconfig | default(omit, true) }}"
context: "{{ listmonk_kube_context | default(omit, true) }}"
state: patched
kind: Deployment
namespace: "{{ listmonk_namespace }}"
name: "{{ listmonk_name }}"
definition:
spec:
template:
metadata:
annotations:
kubectl.kubernetes.io/restartedAt: "{{ now(utc=true).isoformat() }}"
wait: true
wait_condition:
type: Available
status: "True"
wait_timeout: "{{ listmonk_wait_timeout }}"
when: listmonk_configure is changed
+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 }}
+1
View File
@@ -0,0 +1 @@
localhost
+35
View File
@@ -0,0 +1,35 @@
# SPDX-License-Identifier: MIT-0
# Renders every template with throwaway values; no cluster access.
# ansible-playbook tests/render.yml -e out=/tmp/listmonk-render
- name: Render listmonk role templates
hosts: localhost
connection: local
gather_facts: false
vars:
role_path: "{{ playbook_dir }}/.."
out: "{{ playbook_dir }}/../.render"
tasks:
- name: Load role defaults
ansible.builtin.include_vars: "{{ role_path }}/defaults/main.yml"
- name: Throwaway secrets
ansible.builtin.set_fact:
listmonk_db_password: render-only-db-password
listmonk_admin_password: render-only
listmonk_smtp_password: render-only
listmonk_oidc_client_secret: render-only
- name: Output directory
ansible.builtin.file:
path: "{{ out }}"
state: directory
mode: "0700"
- name: Render
ansible.builtin.template:
src: "{{ role_path }}/templates/{{ item }}.j2"
dest: "{{ out }}/{{ item }}"
mode: "0600"
loop: [secret.yml, postgres.yml, listmonk.yml, configure.sql]
- name: Manifests parse as YAML
ansible.builtin.assert:
that: lookup('ansible.builtin.file', out ~ '/' ~ item) | from_yaml_all | list | length > 0
quiet: true
loop: [secret.yml, postgres.yml, listmonk.yml]