May 7, 2026

Self-Hosting the Full AI Stack on $5/mo Hetzner

$9/mo of Hetzner replaces $80 to $150/mo of SaaS. Listmonk, Cal.com, Umami, and Uptime Kuma on a single CX22, with the deliverability playbook nobody writes down.

Deep diveself-hostinghetznerlistmonknewslettersovereign-computeinfrastructure
Contents (10)

TL;DR. A single Hetzner CX22 in Ashburn (€3.79/mo, roughly $4.59 USD), plus a BX11 Storage Box at $4.51/mo, plus Resend's free tier for SMTP, runs the entire creator/consultant SaaS stack. Listmonk replaces Substack and Buttondown. Cal.com self-hosted replaces Cal.com Teams. Umami v2 replaces Plausible and PostHog Cloud. Uptime Kuma replaces UptimeRobot. Total infrastructure: about $9/mo.

The retail SaaS bill that stack replaces, at typical creator/consultant volume (5,000 newsletter subs, 50,000 monthly analytics events, one Cal.com seat, one uptime monitor), runs $80 to $150/mo. Substack alone takes 10% of paid revenue plus a 0.7% billing fee plus the Stripe cut, which is effectively $20 to $80/mo at modest list size. Buttondown at 1,000+ subs with paid subscriptions, RSS-to-email, and analytics add-ons sits around $36/mo. Cal.com Teams is $15/seat/mo. UptimeRobot Solo is $9/mo. PostHog Cloud is free up to 1M events and then climbs to $450/mo at 50M, which is exactly why this stack ships Umami instead.

The thesis is not penny-pinching. The thesis is that the same compute primitives that run agent workflows, a Linux box with Docker and Postgres and an LLM-friendly observability layer, also collapse the SaaS bill to a rounding error. The migration story is proof of the sovereign-compute thesis: when you own the substrate, the marginal cost of running another service on it is electricity. The CX22 pays for itself in the first 24 hours of every month. Everything after that is dividend.

Counter-arguments worth pre-empting up front. Yes, your time is worth more than $80/mo if you bill at $200/hr; the article is for people who already enjoy the boxes, not people looking to be talked into them. Yes, Substack's 10% includes growth and discovery; the article is for founders who already drive their own traffic. Yes, PostHog Cloud's free tier is real up to 1M events; if you genuinely need session replay and feature flags, run PostHog Cloud and skip Umami. The math below assumes you've already decided you want to own the boxes.

Architecture

                          Cloudflare (DNS + L7 DDoS, optional)
                                       │
                                  ┌────┴─────┐
                                  │  :443    │
                                  ▼          ▼
┌──────────────────────────────────────────────────────────────┐
│  Hetzner CX22 · 2 vCPU · 4GB RAM · 40GB NVMe · Ashburn, VA   │
│                                                              │
│   ┌──────────────────────────────────────────────────────┐   │
│   │           Caddy 2 (auto-TLS via Let's Encrypt)       │   │
│   └────┬───────────┬──────────┬──────────┬───────────────┘   │
│        │           │          │          │                   │
│   newsletter.   cal.       analytics.  status.               │
│        ▼           ▼          ▼          ▼                   │
│   ┌────────┐  ┌────────┐  ┌────────┐ ┌────────┐              │
│   │Listmonk│  │ Cal.com│  │ Umami  │ │ Uptime │              │
│   │  v6.1  │  │ Docker │  │   v2   │ │  Kuma  │              │
│   └───┬────┘  └───┬────┘  └───┬────┘ └────────┘              │
│       │           │           │                              │
│       └─────┬─────┴─────┬─────┘                              │
│             ▼           ▼                                    │
│        ┌──────────────────┐                                  │
│        │   Postgres 16    │  (separate DBs per app)          │
│        └──────────────────┘                                  │
│                                                              │
│   Restic (cron · daily 03:00 UTC) ──► Hetzner Storage Box    │
└──────────────────────────────────────────────────────────────┘
                                  │
                            SMTP relay
                                  ▼
                       Resend (free 3k/mo)
                              or AWS SES ($0.10/1k)

Why this fits in 4GB of RAM is the load-bearing question. Listmonk runs in roughly 150MB. Cal.com lands at 600 to 800MB depending on how many integrations you've enabled. Umami sits around 200MB. Uptime Kuma is about 80MB. A single shared Postgres 16 instance, with separate databases per app, runs around 400MB. Caddy adds 30MB. Total resident set is approximately 1.5GB at idle and 2.5GB under load, leaving ~1.5GB of headroom for Postgres buffers, OS cache, and Docker overhead.

This is also the section where I tell you what does not fit on a CX22. Plane (the OSS Linear alternative) needs 8GB minimum and will OOM the box during sync workers. PostHog self-hosted wants 16GB; their docs are explicit about this and you should believe them. Mattermost, Mastodon, and Plausible CE with ClickHouse all break the 4GB ceiling. If you need any of those, jump to a CX32 (€6.80/mo, 8GB) or CCX13 (€15.99/mo, 8GB dedicated). The architecture is the same; only the box size changes.

The Postgres consolidation is the trick that buys the headroom. Three separate Postgres containers would each carry their own buffer pool overhead. One container with three databases shares the buffer pool across all three workloads, which is a meaningful win at 4GB. This is the same logic that makes shared-everything multi-tenant Postgres work for thousands of tenants on a single instance.

Provisioning and DNS

Create a Hetzner project, add an SSH key, and provision a CX22 in Ashburn (ash) for US audiences or Falkenstein (fsn) / Helsinki (hel) for EU. Pick Ubuntu 24.04 LTS. Skip the managed backup add-on; Restic to a Storage Box is cheaper and gives you point-in-time recovery you actually control. Pricing as of May 2026, per Hetzner's price reference:

SKU Cost vCPU RAM Disk Egress
CX22 €3.79/mo 2 shared 4 GB 40 GB NVMe 20 TB
CX32 €6.80/mo 4 shared 8 GB 80 GB 20 TB
CCX13 €15.99/mo 2 dedicated 8 GB 80 GB 20 TB
Storage Box BX11 ~$4.51/mo n/a n/a 1 TB unmetered

DNS is where most self-hosted newsletter setups quietly fail before they send their first email. The records below are the minimum viable set for a domain that wants Gmail to deliver to the inbox in 2026:

A    @                 → <hetzner-ip>
A    newsletter        → <hetzner-ip>
A    cal               → <hetzner-ip>
A    analytics         → <hetzner-ip>
A    status            → <hetzner-ip>
TXT  @                 v=spf1 include:_spf.resend.com -all
TXT  resend._domainkey <DKIM key from Resend dashboard>
TXT  _dmarc            v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com; adkim=r; aspf=r; pct=10
PTR  (reverse DNS)     yourdomain.com   ; in Hetzner Cloud Console

SPF (Sender Policy Framework) tells receiving servers which IPs are allowed to send mail for your domain. DKIM (DomainKeys Identified Mail) cryptographically signs each outbound message so the receiver can verify the body wasn't altered. DMARC (Domain-based Message Authentication, Reporting, and Conformance) tells receivers what to do when SPF or DKIM fail, and where to send aggregate reports. PTR is the reverse-DNS record that maps your IP back to your hostname; missing PTR is an automatic spam-folder ticket at Gmail. All four are non-optional. The deliverability section below has the full enforcement context.

OS bootstrap is standard Ubuntu hardening. Create a non-root deploy user, copy the SSH key, install ufw and fail2ban and unattended-upgrades, deny inbound everything except 22, 80, and 443, disable root SSH and password auth, install Docker via the official convenience script, add the deploy user to the docker group. About 90 seconds of work; I won't reproduce the full bash here because every guide on the internet already has it.

The actual docker-compose.yml

This is the file. Save it as ~/stack/docker-compose.yml, populate .env with the secrets, and docker compose up -d:

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports: ["80:80", "443:443", "443:443/udp"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${PG_ROOT_PW}
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./initdb:/docker-entrypoint-initdb.d:ro

  listmonk:
    image: listmonk/listmonk:v6.1.0
    restart: unless-stopped
    environment:
      LISTMONK_app__address: "0.0.0.0:9000"
      LISTMONK_db__host: postgres
      LISTMONK_db__user: listmonk
      LISTMONK_db__password: ${LISTMONK_DB_PW}
      LISTMONK_db__database: listmonk
      LISTMONK_db__ssl_mode: disable
    depends_on: [postgres]

  calcom:
    image: calcom/cal.com:v4.1.2
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://calcom:${CAL_DB_PW}@postgres:5432/calcom
      NEXTAUTH_URL: https://cal.yourdomain.com
      NEXT_PUBLIC_WEBAPP_URL: https://cal.yourdomain.com
      NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
      CALENDSO_ENCRYPTION_KEY: ${CAL_ENC_KEY}
      NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${STRIPE_PK}
      STRIPE_PRIVATE_KEY: ${STRIPE_SK}
      EMAIL_FROM: bookings@yourdomain.com
      EMAIL_SERVER_HOST: smtp.resend.com
      EMAIL_SERVER_PORT: 465
      EMAIL_SERVER_USER: resend
      EMAIL_SERVER_PASSWORD: ${RESEND_API_KEY}
    depends_on: [postgres]

  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://umami:${UMAMI_DB_PW}@postgres:5432/umami
      DATABASE_TYPE: postgresql
      APP_SECRET: ${UMAMI_SECRET}
    depends_on: [postgres]

  uptime-kuma:
    image: louislam/uptime-kuma:1
    restart: unless-stopped
    volumes:
      - kuma_data:/app/data

volumes:
  caddy_data: {}
  caddy_config: {}
  pg_data: {}
  kuma_data: {}

A few things worth flagging. Every image tag is pinned to a specific version, except Uptime Kuma which uses the :1 major-version tag because its API is stable across the 1.x line. Pinning to :latest is the single most common reason a self-hosted stack breaks at 3am. The Listmonk maintainer's official compose example is the canonical reference; my version above just adds the multi-app shared-Postgres pattern. Cal.com's environment variables follow the self-hosting Docker docs and the Stripe integration guide. The initdb directory contains a single SQL file that creates listmonk, calcom, and umami users and databases on first Postgres boot.

Caddyfile

Caddy is the reason this whole stack feels easy. It fetches Let's Encrypt certificates automatically, renews them automatically, terminates TLS, and reverse-proxies to the backend services by hostname. The entire config is four lines:

{ email ops@yourdomain.com }

newsletter.yourdomain.com  { reverse_proxy listmonk:9000 }
cal.yourdomain.com         { reverse_proxy calcom:3000 }
analytics.yourdomain.com   { reverse_proxy umami:3000 }
status.yourdomain.com      { reverse_proxy uptime-kuma:3001 }

The global block sets the contact email for ACME registration. Each site block is one hostname with one upstream. Caddy's reverse-proxy quick-start covers the variations. If you put Cloudflare in front, set the proxy to "DNS only" (gray cloud) for the Caddy hostnames during initial cert issuance, then flip to proxied if you want L7 DDoS protection. Cloudflare proxy mode plus Let's Encrypt HTTP-01 challenge is a common 3am hiccup; use the DNS-01 challenge with a Cloudflare API token if you want both.

Backups: Restic to Hetzner Storage Box

Restic does encrypted, deduplicated, incremental backups over SFTP. Hetzner Storage Boxes speak SFTP natively, per Hetzner's Storage Box access docs. The combination is roughly $4.51/mo for 1TB of off-box backup storage with daily snapshots and a 6-month retention horizon.

#!/usr/bin/env bash
set -euo pipefail
export RESTIC_PASSWORD_FILE=/home/deploy/.restic-pw
export RESTIC_REPOSITORY="sftp:u123456@u123456.your-storagebox.de:/restic"

docker exec -t stack-postgres-1 pg_dumpall -U postgres > /tmp/pgdump.sql
restic backup /home/deploy/stack /var/lib/docker/volumes /tmp/pgdump.sql --tag daily
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
rm /tmp/pgdump.sql

Cron entry: 0 3 * * * deploy /home/deploy/scripts/backup.sh >> /var/log/restic.log 2>&1. The pg_dumpall runs first so the SQL dump is captured alongside the live volume directory; in practice the volume dir is recoverable on its own, but the SQL dump is the file you actually want during a panic-restore. Test the restore once a quarter; an untested backup is a hope, not a backup. The restic check command should run weekly on a separate cron to catch repository corruption early.

Email deliverability, the dirty secret

This is where most "I'll just self-host my newsletter" stories die. Listmonk is excellent software. Listmonk does not solve deliverability. Deliverability is a domain-reputation problem, not a software problem, and the rules changed in 2024 in ways that punish naive setups hard.

Pick one SMTP relay, do not run your own. The two reasonable choices in 2026:

  • Resend. Free tier: 3,000 emails/mo, capped at 100/day. Pro: $20/mo for 50,000 emails, no daily cap. Built by ex-Vercel folks; the dashboard is excellent and the React Email integration is best-in-class.
  • AWS SES. $0.10 per 1,000 emails. Roughly 9× cheaper than Resend at scale. You handle bounce and complaint webhooks yourself, which is a real engineering tax until you've done it once.

For under 50,000 emails/month, use Resend Pro. Above that, the math tips toward SES, but the operational complexity tips back toward Resend. I run Resend.

Do not try to send SMTP directly from the Hetzner box. Hetzner blocks outbound port 25 by default on new accounts, which is the right call because cloud-VPS IP space has terrible reputation in the spam-filter universe. Even if you got the block lifted, you'd spend weeks negotiating with Spamhaus to delist your IP. Relay through Resend or SES on port 587 or 465. The deliverability problem is not "can I send packets"; it is "do receivers trust me."

The mandatory DNS records, again, with annotations:

SPF    @                  TXT  "v=spf1 include:_spf.resend.com -all"
DKIM   resend._domainkey  TXT  "k=rsa; p=MIGfMA0..."
DMARC  _dmarc             TXT  "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com; adkim=r; aspf=r; pct=10"
PTR    (in Hetzner Cloud Console → reverse DNS)

The -all in the SPF record is a hard fail; receivers should reject mail that doesn't match. p=quarantine in DMARC tells receivers to send failures to spam rather than reject outright. pct=10 means apply that policy to only 10% of failures during the warm-up period; ramp to 25 → 50 → 100 over four to six weeks of clean reports. rua is the aggregate-report address; point it at a parsing service like Postmark DMARC or dmarcian so you can read the JSON without crying.

Gmail's bulk-sender requirements went into effect in February 2024 and started enforcing in November 2025, per Google's bulk sender documentation and the enforcement timeline coverage. For any sender hitting Gmail with more than 5,000 messages a day, the rules are: SPF and DKIM both passing, valid DMARC at minimum p=none, one-click List-Unsubscribe header, spam-complaint rate below 0.30% (with 0.10% as the safe zone), valid forward and reverse DNS. Listmonk handles the List-Unsubscribe header correctly out of the box; everything else is on you.

The 4-week warm-up

A new sending domain with zero history looks identical to a hijacked account. Receivers will throttle aggressively until you've established a pattern. The warm-up table below is calibrated against the Mailgun warm-up guide and what I've seen actually work in practice:

Week Daily cap Audience
1 50 Last-30-day-active subs only
2 200 Last-30-day-active
3 1,000 Last-60-day-active
4 5,000 Last-90-day-active
5+ Full list Everyone except no-activity-90+

Two rules during warm-up. First, consistency over volume; sending 200 messages on Monday and 0 on Tuesday and 800 on Wednesday looks like a compromised account. Pick a daily target and hit it. Second, send only to subscribers who opted in within the last 90 days. Old, cold subs are the highest-risk segment and they're the ones who will spam-flag you and tank your reputation in the first week. Yes, this is a real tradeoff: you'll lose some genuinely-loyal subscribers who happened to be quiet that quarter. The math says do it anyway. You can re-engage cold subs after week 8, with a separate domain or subdomain if the reputation cost is too high.

The deliverability QA loop

Four checks, run on every campaign for the first month and on every major template change forever:

  1. Send each campaign to a fresh mail-tester.com address before the real send. Aim for 9.5+/10. Anything below 9 means a misconfigured header, a missing alt-text, or a content-spam-flag (lots of caps, lots of "free", a bare IP in a link).
  2. Run mxtoolbox.com/deliverability on your domain monthly. It checks SPF, DKIM, DMARC, and 100+ real-time blackhole lists in one shot.
  3. Paste raw headers from a sent message into mxtoolbox.com/EmailHeaders.aspx. Confirm the DKIM signature has d=yourdomain.com, not d=resend.com. If it's signing with the relay's domain, DMARC alignment will fail and Gmail will drop you to spam. The fix is to set up a custom sending domain in Resend or the equivalent in SES.
  4. Watch Gmail Postmaster Tools daily during warm-up, weekly forever. The numbers that matter: spam rate (must stay below 0.30%, target below 0.10%), domain reputation (target "high"), IP reputation (less critical when relaying through a shared pool).

Common gotchas

The four that have actually bitten me or someone I know.

Greylisting. First-time sends to a domain may defer with a 4xx response. Listmonk and Resend both handle the retry transparently, but on a fresh domain you'll see 1 to 2% of week-one sends sit in queue for an hour. Do not panic.

DKIM signed by the relay's domain instead of yours. This is the number-one silent failure. The message technically passes DKIM, but DMARC alignment requires the DKIM d= to match the From header domain. Without alignment, DMARC fails, and p=quarantine does its job by routing you to spam. Set up the custom sending domain on day one.

Reverse DNS missing. Hetzner sets a generic static.X.X.X.X.clients.your-server.de PTR by default. Gmail treats messages from a relay-domain envelope sender with a missing or generic PTR as automatic spam. Set the PTR to your domain in the Hetzner Cloud Console under "Reverse DNS" before sending the first email.

One-click unsubscribe not actually one-click. Gmail's bulk-sender rules require the List-Unsubscribe-Post header with List-Unsubscribe=One-Click. Listmonk does this correctly. Buttondown and Mailchimp do this correctly. A custom Python script using smtplib does not, by default. Verify the headers on a real send.

Cost comparison

Function SaaS option Cost @ 5k subs / 50k events OSS replacement Cost on Hetzner
Newsletter Substack (10% take) ~$50 to $100/mo Listmonk v6.1 $0
Newsletter Buttondown w/ paid + RSS + analytics ~$36/mo Listmonk $0
Booking Cal.com Teams $15/seat/mo Cal.com self-host $0
Product analytics PostHog Cloud Free → $450/mo @ 50M events Umami v2 $0
Web analytics Plausible Cloud (10k pv) $9/mo Umami v2 $0
Uptime monitoring UptimeRobot Solo $9/mo Uptime Kuma $0
Email relay Resend Pro 50k/mo $20/mo Resend Free / SES $0 to $5
Hosting n/a n/a Hetzner CX22 Ashburn €3.79/mo
Backups n/a n/a Hetzner Storage Box BX11 ~$4.51/mo
Total SaaS ~$80 to $150/mo ~$9/mo all-in

The annual delta is $850 to $1,700. At the high end that's a Framework 16 every two years, which is the hardware that lets you stop paying token rent and brings the token-budgeting math home. The pattern compounds: own the inference substrate, own the SaaS substrate, and the only line items left are Stripe, DNS, and the LLM frontier API for the 5% of turns that actually need it.

What NOT to self-host

The temptation, once the stack is up, is to keep going. Don't. Six things should stay on managed services in 2026:

  • Stripe and payment processing. PCI-DSS compliance, chargeback handling, fraud-detection ML, KYC, dispute mediation. You cannot replicate any of it. Stripe's 2.9% + $0.30 is the cheapest insurance you'll buy.
  • DNS. Use Cloudflare (free) or Hetzner DNS or Route 53. Hosting your authoritative DNS on the same VPS that serves your apps is a single-point-of-failure trap; when the box goes down, you can't even update records to point elsewhere.
  • The SMTP relay itself. Covered above; don't fight Hetzner port 25, don't fight Spamhaus, use Resend or SES.
  • Object storage. S3, Cloudflare R2, Backblaze B2 all cost roughly $5 to $15 per TB per month with eleven-nines durability. Your CX22's 40GB local disk does not have eleven-nines anything, and rebuilding lost user uploads is a customer-trust event you do not want.
  • LLM inference at scale. Hetzner's GPU offerings (the GEX44 with an RTX 6000) run €184/mo, which is fine for a sandbox and not at all fine for serving production traffic. Self-host the orchestration, the prompts, and the agents; rent the inference. The local-first thesis is about the laptop on your desk, not the rented GPU in a data center.
  • Auth providers for B2B SaaS. Clerk, WorkOS, Auth0. SAML, SCIM, SOC 2 audit trails, MFA enforcement, breach notifications, password-leak detection. The compliance paperwork alone takes a year to replicate, and your auth provider is the last service you want to debug at 3am.

The pattern is consistent: self-host anything where the failure mode is "I'm down for an afternoon"; rent anything where the failure mode is "I lost user data" or "I'm legally exposed."

The maintenance reality

Honest numbers from running this stack across a few client deployments and my own.

Initial setup. 5 to 10 hours if you've worked with Docker Compose before. 15 to 20 hours if it's your first compose stack. The Caddy and DNS pieces are the fastest. The Cal.com env-var matrix and the deliverability warm-up are where the time goes.

Steady state. 1 to 2 hours per month if nothing breaks. That's typically: review Gmail Postmaster Tools, check Restic backup logs, glance at Uptime Kuma, run docker compose pull && docker compose up -d for the patch-version updates you've decided to track, run restic check once a month.

Bad month. 4 to 8 hours when something does break. The usual suspects: a Cal.com major-version bump that requires a Postgres migration with breaking schema changes, a Let's Encrypt renewal that fails because Cloudflare proxy mode broke the HTTP-01 challenge, Listmonk silently failing because Resend rotated an API key and the env file wasn't updated, Postgres running out of disk because Umami's events table grew faster than expected. None of these are catastrophic. All of them are evening-eaters.

Two rules that save real grief. First, pin every Docker tag to a specific version, never :latest. The five minutes you save by not pinning are paid back as four hours of debugging when an upstream maintainer ships a breaking change at 11pm. Second, write the runbook now, while you remember what each environment variable does and which port maps to which service. Your future self in six months will not remember; the runbook is the only thing standing between "30 minutes to fix" and "rebuild the whole stack from the README."

If 1 to 2 hours per month sounds like too much: stay on SaaS. The math only works if you actually enjoy the boxes. The article is for the people who do.

The closer

The CX22 pays for itself in the first 24 hours of every month. Everything after that is dividend, and the dividend buys you something more valuable than the cash: the same substrate that runs your newsletter also runs your agents, your analytics, your status page, and whatever else you wire into it next. Owning the boxes turns the SaaS bill from a fixed cost into a one-time setup cost with a small maintenance surcharge. The full hardware list and the rest of the sovereign-compute stack follows the same pattern: rent the things that are genuinely hard, own the things that are merely tedious. The line moves a little further toward "merely tedious" every year.

Local-First AI

If this was useful, the weekly notes go deeper. No drip sequences, no upsells.

n8n templates, cost teardowns, and what is actually working in 2026. No drip sequences, no upsells. Reply to opt out.