Originally published at meysam.io/blog
When Medium hid subscriber counts in April 2025, I had already solved that problem six months earlier by moving my newsletter to self-hosted infrastructure. Here's how I run a newsletter for 1,500+ subscribers on a €12/month server.
On April 2025, Medium hid everyone's subscriber counts.
I wasn't surprised. I was prepared.
Because six months earlier, I'd already moved my newsletter off platforms I don't control. Not because I predicted Medium's move specifically, but because I'd learned the hard way: if you're building in public and your audience is on someone else's platform, you're building on rented land.
When Medium announced the change, I checked my email list. 1,500+ subscribers. All mine. Real email addresses I can reach directly, regardless of what any platform decides to do tomorrow.
That peace of mind? Worth the setup effort.
Why This Matters (And Why I'm Sharing This Now)
Look, I get it. Self-hosting sounds intimidating. Kubernetes sounds like overkill. "Just use ConvertKit or Buttondown," your brain says. "They're made for this."
They are. And they're great. Until they're not.
Until they change pricing.
Until they add features you don't want but have to pay for.
Until they get acquired and shut down.
Until they decide your content violates their new policies.
I'm not against using SaaS tools. I use plenty. But for your audience—the people who chose to hear from you—I believe you should own that relationship.
The calculation is simple:
- Managed newsletter service: $30-100/month (and rising with subscribers)
 - Self-hosted Listmonk: €12/month (flat, forever, unlimited subscribers)
 
That's not the only reason though. It's about control. It's about data. It's about not waking up one day to find the rules changed while you weren't looking.
What You're Actually Building
Listmonk is an open-source newsletter and mailing list manager. Think Mailchimp or ConvertKit, but you run it on your own server.
What you get:
- Full control of your subscriber data
 - Unlimited subscribers (you only pay for server costs)
 - No platform risk
 - Modern UI (actually good)
 - API for automation
 - Campaign analytics
 - Subscriber segmentation
 - Template customization
 
What you're giving up:
- Someone else handling the servers
 - One-click setup
 - Built-in deliverability reputation (use a good SMTP service!?)
 - Hand-holding support
 
If that trade-off makes sense to you, keep reading.
Prerequisites: Are You Ready for This?
This guide assumes you:
- Know what Docker/containers are (even if you've never deployed one)
 - Can SSH into a server without panicking
 - Are comfortable copying and pasting commands
 - Have ~2 hours to dedicate to setup
 - Have €12/month for hosting
 
You don't need:
- To be a DevOps expert
 - To understand every line of YAML
 - To have Kubernetes experience (I'll explain what matters)
 - To know Go or VueJS (Listmonk's languages)
 
If you're thinking "I barely know Docker"—that's fine. I'll walk you through it. The whole point is making this accessible.
The Architecture (Without the Jargon)
Here's what we're building:
- A small server (Hetzner CCX13: 2 vCPUs, 8GB RAM, €12/month)
 - K3s (lightweight Kubernetes—it's easier than you think)
 - Listmonk (your newsletter app)
 - PostgreSQL (database for subscribers/campaigns)
 - Automated backups (to Hetzner Object Storage)
 
Why Kubernetes for a newsletter app? Valid question.
Because once you have K3s running, adding other self-hosted tools is trivial. Want analytics? Add Plausible. Want a CRM? Add Twenty. Want uptime monitoring? Add Uptime Kuma.
It's infrastructure that scales with you, not just for this one app.
But we'll start simple.
Part 1: Get a Server Running
Step 1: Buy the Server (5 minutes)
- Go to console.hetzner.cloud
 - Create an account (if you don't have one)
 - Create a new project (call it "newsletter" or whatever)
 - Click "Add Server"
 
Server specs:
- Location: Choose closest to your audience (I use Nuremberg, Germany)
 - Image: Ubuntu 24.04
 - Type: CCX13 (2 vCPU, 8GB RAM, 80GB SSD)
 - Networking: Public IPv4 + IPv6
 - 
SSH Key: Add your public key (generate one if needed: 
ssh-keygen -t ed25519) - 
Name: 
newsletter-1or similar 
Cost: €12.09/month
Click "Create & Buy Now."
Wait 60 seconds. You now have a server.
Step 2: Initial Server Setup (5 minutes)
SSH into your server:
ssh root@<your-server-ip>
Update packages and set up firewall:
# Update system
apt update && apt upgrade -y
# Install required tools
apt install -y curl wget git ufw
# Configure firewall
ufw allow 22/tcp   # SSH
ufw allow 80/tcp   # HTTP
ufw allow 443/tcp  # HTTPS
ufw allow 6443/tcp # Kubernetes API
ufw enable
That's it for basic server setup.
Part 2: Install K3s (10 minutes)
K3s is Kubernetes, but stripped down to essentials. Perfect for single-server setups.
# Install K3s
curl -s https://get.k3s.io | \
  INSTALL_K3S_CHANNEL=stable \
  INSTALL_K3S_EXEC="--secrets-encryption" \
  sh -
# Verify it's running
k3s kubectl get nodes
You should see your node in "Ready" status.
Set up kubectl access (so you don't need to type k3s before every command):
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
echo "export KUBECONFIG=/etc/rancher/k3s/k3s.yaml" >> ~/.bashrc
Test it:
kubectl get nodes
If you see your node listed, K3s is running. You now have a Kubernetes cluster on a €12 server.
Part 3: Install CloudNativePG Operator (5 minutes)
We need a PostgreSQL database for Listmonk. We'll use CloudNativePG—an operator that manages PostgreSQL for us.
# Install CloudNativePG operator
kubectl apply --server-side -f \
  https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.27/releases/cnpg-1.27.1.yaml
# Wait for it to be ready
kubectl rollout status deployment \
  -n cnpg-system cnpg-controller-manager
That's it. PostgreSQL management is handled.
Part 4: Set Up External Secrets Operator (10 minutes)
We need a way to store sensitive data (database passwords, etc.) securely. We'll use AWS Systems Manager Parameter Store (it's free for our usage).
Install External Secrets Operator
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets \
  external-secrets/external-secrets \
  --namespace external-secrets \
  --version 0.20.x \
  --create-namespace
Set up AWS credentials
- Create an IAM user in AWS Console with 
AmazonSSMReadOnlyAccesspermission - Generate access keys
 - Create a secret in K8s:
 
kubectl create secret generic aws-credentials \
  --from-literal=access-key-id=YOUR_ACCESS_KEY \
  --from-literal=secret-access-key=YOUR_SECRET_KEY \
  -n external-secrets
- Create a ClusterSecretStore:
 
---
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: aws-parameter-store
spec:
  provider:
    aws:
      service: ParameterStore
      region: eu-central-1 # Frankfurt - change if needed
      auth:
        secretRef:
          accessKeyIDSecretRef:
            name: aws-credentials
            key: access-key-id
            namespace: external-secrets
          secretAccessKeySecretRef:
            name: aws-credentials
            key: secret-access-key
            namespace: external-secrets
kubectl apply -f ClusterSecretStore.yml
Store your secrets in AWS Parameter Store
Go to AWS Console → Systems Manager → Parameter Store and create these parameters:
- 
/listmonk/db/username→listmonk - 
/listmonk/db/password→ (generate strong password) - 
/listmonk/db/superuser/username→postgres - 
/listmonk/db/superuser/password→ (generate strong password) 
All as SecureString type.
Alternatively, use AWS CLI:
aws ssm put-parameter --name /listmonk/db/username --value "listmonk" --type SecureString --overwrite
aws ssm put-parameter --name /listmonk/db/password --value "your-strong-password" --type SecureString --overwrite
aws ssm put-parameter --name /listmonk/db/superuser/username --value "postgres" --type SecureString --overwrite
aws ssm put-parameter --name /listmonk/db/superuser/password --value "your-superuser-password" --type SecureString --overwrite
Part 5: Deploy PostgreSQL (5 minutes)
Create a namespace:
kubectl create namespace listmonk
Create the database configuration files. I'm using the exact setup I run in production—tuned for the CCX13 specs:
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: pg-listmonk
  namespace: listmonk
spec:
  bootstrap:
    initdb:
      database: listmonk
      owner: listmonk
      secret:
        name: postgres-listmonk
  instances: 1
  postgresql:
    parameters:
      effective_cache_size: 6144MB
      maintenance_work_mem: 512MB
      max_connections: "100"
      shared_buffers: 2048MB
      work_mem: 16MB
  resources:
    limits:
      cpu: "2"
      memory: 8Gi
    requests:
      cpu: "1"
      memory: 4Gi
  storage:
    size: 40Gi
  superuserSecret:
    name: postgres-listmonk-superuser
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: postgres-listmonk-superuser
  namespace: listmonk
spec:
  data:
    - remoteRef:
        key: /listmonk/db/superuser/username
      secretKey: username
    - remoteRef:
        key: /listmonk/db/superuser/password
      secretKey: password
  refreshInterval: 24h
  secretStoreRef:
    kind: ClusterSecretStore
    name: aws-parameter-store
  target:
    template:
      type: kubernetes.io/basic-auth
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: postgres-listmonk
  namespace: listmonk
spec:
  data:
    - remoteRef:
        key: /listmonk/db/username
      secretKey: username
    - remoteRef:
        key: /listmonk/db/password
      secretKey: password
  refreshInterval: 24h
  secretStoreRef:
    kind: ClusterSecretStore
    name: aws-parameter-store
  target:
    template:
      type: kubernetes.io/basic-auth
Apply them:
kubectl apply -f postgres/
Wait for PostgreSQL to be ready:
kubectl wait --for=condition=Ready \
  cluster/pg-listmonk -n listmonk --timeout=5m
Part 6: Deploy Listmonk (10 minutes)
Create the Listmonk configuration:
[app]
address = "0.0.0.0:9000"
root_url = "https://newsletter.yourdomain.com"
site_name = "Your Newsletter"
[db]
host = "pg-listmonk-rw"
port = 5432
database = "listmonk"
ssl_mode = "disable"
max_open = 100
max_idle = 50
max_lifetime = "1h"
params = "application_name=listmonk"
TZ=Etc/UTC
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: listmonk
spec:
  template:
    metadata:
      labels:
        app: listmonk
    spec:
      containers:
        - envFrom:
            - configMapRef:
                name: listmonk-envs
            - secretRef:
                name: listmonk-secrets
          image: listmonk/listmonk
          livenessProbe:
            failureThreshold: 3
            httpGet:
              path: /
              port: http
            initialDelaySeconds: 3
            periodSeconds: 5
            successThreshold: 1
            timeoutSeconds: 5
          name: listmonk
          ports:
            - containerPort: 9000
              name: http
          readinessProbe:
            failureThreshold: 3
            httpGet:
              path: /
              port: http
            initialDelaySeconds: 3
            periodSeconds: 5
            successThreshold: 1
            timeoutSeconds: 5
          volumeMounts:
            - mountPath: /listmonk/uploads
              name: uploads
            - mountPath: /listmonk/config.toml
              name: listmonk-config
              subPath: config.toml
            - mountPath: /tmp
              name: tmp
      initContainers:
        - command:
            - ./listmonk
            - "--idempotent"
            - "--upgrade"
            - "--yes"
          envFrom:
            - configMapRef:
                name: listmonk-envs
            - secretRef:
                name: listmonk-secrets
          image: listmonk/listmonk
          name: migrations
          volumeMounts:
            - mountPath: /listmonk/uploads
              name: uploads
            - mountPath: /listmonk/config.toml
              name: listmonk-config
              subPath: config.toml
      volumes:
        - emptyDir: {}
          name: tmp
        - configMap:
            defaultMode: 0400
            name: listmonk-config
            optional: false
          name: listmonk-config
        - name: uploads
          persistentVolumeClaim:
            claimName: listmonk-uploads
---
apiVersion: v1
kind: Service
metadata:
  name: listmonk
  namespace: listmonk
spec:
  ports:
    - name: http
      port: 80
      targetPort: 9000
  selector:
    app: listmonk
  type: ClusterIP
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: listmonk-secrets
  namespace: listmonk
spec:
  data:
    - remoteRef:
        key: /listmonk/db/username
      secretKey: LISTMONK_db__user
    - remoteRef:
        key: /listmonk/db/password
      secretKey: LISTMONK_db__password
  refreshInterval: 24h
  secretStoreRef:
    kind: ClusterSecretStore
    name: aws-parameter-store
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: listmonk-uploads
  namespace: listmonk
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 8Gi
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    traefik.ingress.kubernetes.io/router.middlewares: default-redirect-https@kubernetescrd
  name: listmonk
spec:
  ingressClassName: traefik
  rules:
    - host: newsletter.yourdomain.com
      http:
        paths:
          - backend:
              service:
                name: listmonk
                port:
                  number: 80
            path: /
            pathType: Prefix
  tls:
    - hosts:
        - newsletter.yourdomain.com
      secretName: listmonk-tls
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yml
  - externalsecret.yml
  - ingress.yml
  - pvc.yml
  - service.yml
configMapGenerator:
  - name: listmonk-config
    files:
      - config.toml
  - name: listmonk-envs
    files:
      - configs.env
namespace: listmonk
Apply everything:
kubectl apply -k listmonk/
Wait for Listmonk to be ready:
kubectl wait --for=condition=Ready \
  pod -l app=listmonk -n listmonk --timeout=5m
Part 7: Expose Listmonk to the Internet (15 minutes)
We'll use Traefik as our ingress controller (it comes with K3s).
Install cert-manager for SSL:
kubectl apply -f \
  https://github.com/cert-manager/cert-manager/releases/download/v1.19.1/cert-manager.yaml
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    email: your-email@example.com
    privateKeySecretRef:
      name: letsencrypt-prod
    server: https://acme-v02.api.letsencrypt.org/directory
    solvers:
      - http01:
          ingress:
            class: traefik
kubectl apply -f ClusterIssuer.yml
Point your domain DNS:
- Add an A record: 
newsletter.yourdomain.com→<your-server-ip> 
Wait 5-10 minutes for DNS propagation and SSL certificate issuance.
Part 8: Access and Configure Listmonk
Navigate to https://newsletter.yourdomain.com
Login with the credentials from LISTMONK_db__user & LISTMONK_db__user:
- Username: 
listmonk - Password: 
your-strong-password(you did change this, right?) 
First-time setup
- 
Settings → General:
- Update site name
 - Add your logo
 - Set timezone
 
 - 
Settings → SMTP:
- Use a transactional email service (I recommend Maileroo)
 - Or use your own mail server
 - Test the configuration
 
 - 
Lists:
- Create your first list (e.g., "Weekly Newsletter")
 - Set double opt-in (recommended for deliverability)
 
 - 
Templates:
- Customize the default template
 - Match your brand colors
 
 
Part 9: Set Up Automated Backups (10 minutes)
Your database is the most critical part. Let's back it up to Hetzner Object Storage.
Create a bucket in Hetzner:
- Go to console.hetzner.cloud
 - Object Storage → Create bucket
 - Name it (e.g., 
newsletter-backups) - Create access keys
 
Configure CloudNativePG backup:
---
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
  name: pg-listmonk
  namespace: listmonk
spec:
  configuration:
    data:
      compression: bzip2
    destinationPath: s3://newsletter-backups/postgres/
    endpointURL: https://fsn1.your-objectstorage.com
    s3Credentials:
      accessKeyId:
        key: ACCESS_KEY_ID
        name: hetzner-blob-storage
      secretAccessKey:
        key: ACCESS_SECRET_KEY
        name: hetzner-blob-storage
    wal:
      compression: bzip2
  retentionPolicy: 7d
Create the secret:
kubectl create secret generic hetzner-blob-storage \
  --from-literal=ACCESS_KEY_ID=your-access-key \
  --from-literal=ACCESS_SECRET_KEY=your-secret-key \
  -n listmonk
---
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: pg-listmonk
  namespace: listmonk
spec:
  cluster:
    name: pg-listmonk
  immediate: true
  method: plugin
  pluginConfiguration:
    name: barman-cloud.cloudnative-pg.io
  schedule: 0 0 * * * # Daily at midnight
Apply:
kubectl apply -f postgres/objectstore.yml
kubectl apply -f postgres/scheduledbackup.yml
Your database now backs up daily to object storage. If your server explodes, you can restore from these backups.
What You Just Built
Let's recap:
- ✅ Self-hosted newsletter platform
 - ✅ Unlimited subscribers (only limited by server resources)
 - ✅ PostgreSQL database with automated backups
 - ✅ SSL certificates (auto-renewing)
 - ✅ Modern UI for managing campaigns
 - ✅ API for automation
 - ✅ Full control of your data
 
Monthly cost: €12 for server + ~€0-5 for backups + transactional email costs (starts at ~$0.4 per 1,000 emails with Maileroo)
Compare that to ConvertKit at $33/month for 1,000 subscribers, scaling to $50/month for 3,000.
The Trade-Offs (Let's Be Honest)
What you gained:
- Full ownership of subscriber data
 - No platform risk
 - Predictable costs
 - Unlimited scale potential
 - Learning experience
 - Infrastructure for other tools
 
What you gave up:
- Managed infrastructure (you're responsible for updates)
 - Built-in deliverability reputation (you'll need to warm up your domain)
 - One-click features (you configure everything)
 - Hand-holding support (you're on your own or rely on community)
 
For me, it's worth it. Your mileage may vary.
If you send one newsletter a week and have 500 subscribers, maybe ConvertKit makes more sense. If you're serious about building a long-term relationship with your audience and want to minimize dependencies, this setup pays for itself.
What I Learned Doing This
Setting up Listmonk forced me to stay sharp in my Kubernetes game. That knowledge paid off immediately when I wanted to add other self-hosted tools.
Now my €12 server runs:
- Listmonk (newsletter)
 - Plausible (analytics)
 - Uptime Kuma (monitoring)
 - A few internal tools
 
That same infrastructure would cost $100+/month across different SaaS providers.
The biggest lesson: Self-hosting isn't as scary as it seems. The initial setup is the hardest part. Maintenance is mostly hands-off.
I spend ~30 minutes per month on server updates. That's it.
Next Steps
If you followed this guide, you now have a running newsletter platform.
Immediate tasks:
- Configure your SMTP settings (Maileroo recommended)
 - Import existing subscribers (if you have them)
 - Create your first campaign template
 - Send a test email
 - Set up a subscription form on your website
 
Within the first week:
- Warm up your domain (start with small sends)
 - Monitor deliverability (check spam folders)
 - Create a consistent sending schedule 9. Add analytics tracking (optional)
 
Ongoing:
- Regular server updates (
apt update && apt upgrade) - Monitor backup status
 - Scale server as needed (upgrade CCX type if you grow)
 
The Real Reason I'm Sharing This
When Medium hid subscriber counts, a bunch of indie hackers panicked in my DMs.
- "How do I know if anyone cares?"
 - "Should I just quit?"
 - "All my metrics disappeared."
 
If your entire metric system lives on someone else's platform, you're vulnerable.
I can't control what Medium does. Or X. Or LinkedIn. Or any platform.
But I can control my email list. I know exactly how many people subscribed. I can email them directly, right now, without asking permission or paying per subscriber.
That's not about paranoia. It's about sustainability.
If you're building something long-term—a product, a community, a body of work—own the relationship with your audience. Rent the amplification platforms (social media), but own the foundation (email list).
This setup is my foundation. It took a Saturday afternoon to set up. It'll run for years.
You don't have to use Kubernetes. You could run Listmonk with Docker Compose on the same Hetzner server for even less complexity. I chose K8s because I wanted room to grow.
The point isn't the specific tech stack.
The point is: stop renting your audience.
Running this setup in production? Stuck somewhere? Email me. I read every message.
Building in public? Subscribe to my newsletter where I share lessons like this every week: meysam.io
    
Top comments (0)