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)