My first instinct for this blog was Astro. It ships zero JavaScript by default and makes that hard to regress - you have to actively opt an island into hydration, so a blog full of static articles stays static almost by construction. The plan was Astro for the front end and Payload CMS running as its own containerized service behind a REST API. Clean separation, two things doing one job each.
The problem showed up once I actually looked at what makes Payload worth using in the first place. Its real advantages - the Local API, which lets Server Components query the database directly with zero network hop instead of going through HTTP; the admin panel mounting inside your own app instead of living on a separate subdomain; live in-context editing - all of that only exists when Payload runs inside the same Node process as your front end. Run it as a separate service and you get a perfectly good headless CMS talking REST, which is fine, but it's not what you're paying Payload's complexity budget for.
So I gave up the two-services-one-job-each cleanliness and moved to Next.js App Router with Payload embedded in the same process - one codebase, one deploy, one runtime to secure instead of two. I didn't give up the zero-JS instinct entirely; I just accepted it now takes discipline instead of coming for free. Server Components by default, and keeping `use client` out of article bodies specifically, gets most of the way there. It's a decision I have to keep making on every PR instead of one I made once in a framework choice, which is a real cost, just one I decided was worth paying for what Payload gives me when it's embedded.
2. Cutting the FastAPI Backend I Never Needed
The original plan also had a separate FastAPI service in Python, handling tips, the newsletter signup, the contact form, and article view/engagement counters - the kind of small stateful features that don't obviously belong in a CMS. I got as far as scaffolding it before the question actually hit me: what is FastAPI doing here that Payload isn't already doing?
Payload is a full backend framework, not just a content model. Collections, access control rules, hooks that run on create/update/delete, custom REST and GraphQL endpoints, all running against the same Postgres database the articles already live in. A newsletter signup is a collection with an email field and a beforeChange hook that calls out to an email provider. A tip confirmation is a collection with a webhook-verified create endpoint. None of that needed a second language and a second runtime sitting next to Next.js for no integration benefit - it needed features I could build directly in the framework I was already running.
I cut FastAPI entirely and rebuilt those four features as Payload collections and custom endpoints in the same Next.js process. I did leave the door open for a real Python service later, specifically for AI features - semantic search over articles, content recommendations - because that's a case where Python's ecosystem is a genuine advantage, not a habit. I'm not building it now, because there's no actual feature that needs it yet, and I'd rather add that complexity when a real requirement forces the question than carry it speculatively from day one.
3. Incident One: Native Postgres and the ETIMEDOUT That Taught Me a Lesson
For the database, I first tried running Postgres natively on the VPS host via systemd instead of in a container. The reasoning felt sound at the time: this VPS only runs this one project, so there's no multi-tenant reason to containerize Postgres, and I could connect the app container to the host's Postgres over Docker's host-gateway routing (host.docker.internal). One less container to manage, I thought.
It broke the next day. The app container started throwing ETIMEDOUT trying to reach Postgres over host-gateway. Nothing had changed in my config - it was this particular VPS's networking that didn't resolve host.docker.internal reliably, which is exactly the kind of host-specific quirk you don't discover until it's already in front of real traffic.
I reverted to running Postgres as its own service inside the same Docker Compose stack as the app and Caddy, reachable over plain Compose service-name DNS - postgres:5432, no host-gateway indirection, no subnet-pinned firewall rules to maintain going forward. It's a less 'clever' setup than reasoning through host networking, and that's exactly why it works: fewer moving parts that depend on this specific host's quirks. The lesson I took from it wasn't about Postgres specifically, it was that 'simpler because there's only one project on this box' is not the same claim as 'simpler because there's less to go wrong' - and I'd optimized for the wrong one.
4. Incident Two: A Fake TLS Certificate and a k3s Ghost
The second incident was stranger. Visitors were getting a self-signed certificate labeled TRAEFIK DEFAULT CERT instead of Caddy's real Let's Encrypt cert - a browser warning on a site that should have had automatic HTTPS. I went through every Docker-level check I could think of: caddy, app, and postgres containers all healthy, Caddy's logs showed it had successfully issued and was serving the correct certificate, the Caddyfile was right. Everything at the Docker level checked out clean, which was exactly the problem - I was looking in the wrong layer entirely.
The actual cause was upstream of Docker: a leftover k3s (lightweight Kubernetes) installation on the VPS, unrelated to this project, left over from something that had run on the box before I got it. k3s ships its own bundled Traefik ingress controller, and its iptables rules were DNAT'ing ports 80 and 443 to a k3s pod IP in the kernel's PREROUTING chain - upstream of, and invisible to, Docker's own port publishing. Docker thought it owned ports 80/443. The kernel disagreed, and the kernel wins.
I found it by running `iptables -t nat -L PREROUTING -n -v` and spotting a CNI-HOSTPORT-DNAT rule that had no business being there. The fix was the official `k3s-uninstall.sh` script, and the certificate started resolving correctly within minutes. The generalizable lesson, which I now apply on every VPS with unknown prior history: a networking-layer symptom that looks like an app or Compose bug is worth checking at the iptables/kernel level early, not as a last resort - every Docker-level diagnostic can pass cleanly while something upstream of Docker silently rewrites the packet path.
5. Media Storage: Local Disk to Cloudflare R2
Payload's default local storage writes uploaded media to disk inside the container. That works for exactly as long as you never redeploy - every deploy rebuilds and replaces the container, so anything written to local disk vanishes unless it's on a mounted volume, and even with a volume, the VPS's disk grows unboundedly as media accumulates with no natural ceiling.
I moved to Cloudflare R2, which is S3-compatible, so the official @payloadcms/storage-s3 adapter works against it unmodified - it's just a different endpoint and region in the config, nothing R2-specific to write. The bigger reason I picked R2 over plain S3 specifically is egress: R2 doesn't charge for bandwidth out, and for a blog with an international, largely non-US audience, image-heavy article pages, and no way to predict traffic spikes, that's not a minor line item, it's the difference between a predictable bill and one that scales with how well an article happens to do.
6. Payments Without Stripe: The Cameroon Problem
Stripe doesn't operate in Cameroon. That's not a workaround-able API limitation, it's a business decision on their end that no amount of clever integration fixes. Standard Cameroonian PayPal accounts compound the problem: they're send-only, meaning I could pay someone else with PayPal but couldn't receive or withdraw a tip through it under a normal account. Two of the most obvious payment rails were simply not available to me as the person receiving money, which isn't a problem most tutorials about 'adding a tip button' prepare you for.
Rather than block launch on solving this completely, I landed on a tiered approach I could ship incrementally. Tier 0, at launch, is just an external link to whichever platform makes sense for a given reader. Tier 1, which I did build, is PayPal Buttons rendered client-side: order creation happens in the browser via PayPal's SDK, and my backend's only job is verifying the webhook signature before recording a confirmed tip - deliberately no fixed price enforced server-side beyond what the webhook itself confirms, specifically to avoid a client able to tamper with an amount that the server never independently asserted in the first place.
Tier 2, not built yet, is NotchPay - a Cameroon-based payment gateway that supports Mobile Money, which matters because most of the local and francophone African audience I actually write for doesn't have a card to put into a Stripe or PayPal form in the first place. I'm sequencing it this way on purpose: ship what works today, and add the rail that serves my actual core audience once the rest of the site has proven itself, rather than blocking launch on the hardest payment integration first.
7. Localization: One Content Tree, Not Two
I write in English and French, and the early sketch of this had two separate content trees - an /en/ collection and a /fr/ collection, each independently manageable, which sounds flexible right up until you have to keep two sets of slugs, two sets of SEO metadata, and two publish states in sync by hand across every article.
Payload's built-in field-level localization does this better: one document per article, with specific fields (title, description, the sections array itself) marked localized, and Payload storing and serving per-locale values off the same underlying record. There's one slug, one publish/draft state, one set of relationships and access-control rules - only the actual translatable content forks by locale, not the whole document.
The practical win shows up in the admin panel too: editing an article means switching a locale toggle, not navigating to an entirely separate collection and hoping the two stay conceptually in sync. It's a smaller amount of code than a bolted-on translation layer would have been, and it's structurally impossible for the English and French versions of an article to drift into being two different articles with the same title, because they're the same document.
8. What I'd Do Again, and What I'd Do Differently
The architecture I ended up with - Next.js App Router with Payload embedded in the same process, Postgres and Caddy in the same Compose stack, R2 for media, GitHub Actions building and pushing to GHCR then SSHing in to redeploy on every push to main - is boring in the good sense. Every piece is something with a large community and clear documentation, and boring is exactly what you want for infrastructure you're maintaining solo alongside actually writing content.
What I'd do again without hesitation: embedding Payload in Next.js instead of running it separately, cutting FastAPI before I'd written much code against it, moving media to R2 before local disk became a real problem instead of after, and using Payload's field-level localization from the start instead of retrofitting it. Those were all decisions I got right the first time because I asked what am I actually getting from this extra piece before adding it, not because I predicted the future.
What I'd do differently: I'd run `iptables -t nat -L PREROUTING` on day one of provisioning any new VPS, before deploying anything, specifically to catch exactly the kind of leftover-k3s situation that cost me an afternoon of chasing a phantom TLS bug through every layer except the right one. And I'd default to Postgres-in-Compose from the start rather than reasoning my way into native-Postgres-plus-host-gateway as a 'simpler' option - the instinct to reduce container count is usually right, but not when the alternative depends on this specific host's networking behaving the way you assumed it would.
# Every Docker-level check passes, and the cert is still wrong.# Check upstream of Docker: is something else DNAT-ing 80/443?sudo iptables -t nat -L PREROUTING -n -v | grep -i dnat# Found:# Chain PREROUTING (policy ACCEPT)# pkts bytes target prot opt in out source destination# 3 180 CNI-HOSTPORT-DNAT all -- * * 0.0.0.0/0 0.0.0.0/0# That rule belongs to a leftover k3s install, not to this project.curl -sfL https://get.k3s.io | sh -s - --help >/dev/null 2>&1 || truesudo /usr/local/bin/k3s-uninstall.sh# Restart the stack, verify the real cert comes back.docker compose down && docker compose up -dcurl -vI https://armeltechfun.com 2>&1 | grep -i "issuer\|subject"