Security, SEO, and performance in a self-hosted blog
Self-hosting a blog means security, SEO, and performance are on you. CSP headers, rate limiting, generated OG images, ISR, and the lessons we learned while setting it all up.

When you use a managed platform like WordPress.com or Vercel, a lot of things come built in. SSL certificates renew themselves, security headers come preconfigured, and the CDN handles caching. When you self-host, all of that is your job.
It’s not as hard as it looks, but you do have to get it right. A badly configured blog isn’t just vulnerable, it also ranks worse in search engines and gives readers a slow experience that drives them away.
Security headers, the first line of defense
Every HTTP response from this blog includes a set of security headers that browsers use to restrict dangerous behavior. From a security perspective, in supply chain security in Docker we go deeper into this.
Content Security Policy (CSP) is the most important one, and the hardest to configure properly. It defines exactly which scripts, styles, images, and iframes the page is allowed to load. Our policy allows first-party and inline scripts (needed for Next.js), first-party styles, images from our domain and generic HTTPS, and only YouTube and Vimeo iframes. Everything else is blocked. If someone injected malicious HTML into a post, the browser would reject any script or external resource that isn’t on the whitelist.
HSTS (HTTP Strict Transport Security) forces HTTPS connections for two years with preload enabled, which means browsers won’t even try to connect over plain HTTP.
X-Frame-Options DENY stops the blog from being loaded inside an iframe on another site, which prevents clickjacking attacks. Referrer-Policy controls how much information gets sent to external sites when a reader clicks a link. Permissions-Policy blocks access to the camera, microphone, and geolocation, which a blog doesn’t need under any circumstances.
Rate limiting on every sensitive endpoint
Without rate limiting, an attacker could hammer the login form with thousands of passwords, overload search with massive queries, or abuse the AI generation endpoint.
We implemented specific limiters for each type of endpoint. Login has a limit of 10 attempts every 15 minutes per IP. Public search allows 30 queries per minute. The AI generator, 20 requests per hour per session. The admin API, 60 requests per minute per key. Each limiter returns an HTTP 429 with a Retry-After header when the limit is exceeded.
The limiters use in-memory storage with automatic cleanup to avoid leaks. The rate limit keys hash IPs with SHA-256 and a random salt, so the original IP addresses are never stored in memory.
Cloudflare in front, and the silent rate-limit trap
A few months ago I put the entire domain behind Cloudflare. The obvious upsides are edge cache in hundreds of locations, managed WAF, DDoS mitigation, and TLS certificates renewed automatically. What wasn’t obvious is that putting a proxy in front silently breaks IP-based rate limiting if you don’t update how you read the client IP.
The problem is this. When Cloudflare forwards a request to my origin, the TCP IP that Next.js sees is Cloudflare’s, not the visitor’s. If the rate limit hashes that IP as the key, every request in the world ends up under a handful of keys (one for each Cloudflare PoP). For weeks the admin login had a rate limit that didn’t limit anything, because 10 attempts per Cloudflare IP means tens of thousands of attempts per second spread across all visitors.
The fix is to use the header Cloudflare injects, cf-connecting-ip, which contains the real client IP. The getClientIp helper reads that first, falls back to x-real-ip if it’s not there (for environments behind Traefik without CF), and finally to x-forwarded-for taking the first element. None of those headers are trusted blindly, the origin only trusts them because it has Authenticated Origin Pulls enabled, which restricts incoming TLS connections to ones that present a client certificate signed by Cloudflare. If an attacker tried to talk directly to the VPS and bypass Cloudflare, the TLS handshake would bounce them before they touched anything.
A nice side effect of edge cache is that public endpoints now (manifest, post detail, search) are served from the PoP closest to the visitor without touching my origin. But that brought its own trap, on Cloudflare Free the cache rule ignores Set-Cookie and certain Vary headers, and after a few attempts at tuning it I ended up simplifying the whole thing and leaving edge cache only where it was safe. The lesson is that putting a proxy in front isn’t free, you need to understand what changes along the way so you don’t get surprised later by rate limits that don’t limit or caches that don’t cache.
Authentication without complexity
The admin panel uses Better Auth with email and password authentication. Sign-up is disabled because the blog has a single administrator. Sessions last 7 days and are validated in Next.js middleware, which redirects any request to /login for /admin/* without a valid session.
The REST API uses an API key with timing-safe comparison (to avoid timing attacks) and is separate from session authentication. That lets external tools publish posts without needing a browser.
SEO that works without plugins
This blog’s SEO doesn’t depend on any plugin. It’s built straight into the code.
Each page generates its own Open Graph and Twitter Card tags with title, description, content type, and publication date. When a post has a cover image, that image is used as og:image. When it doesn’t, Next.js automatically generates a 1200x630 pixel image with the post title, categories, and the blog branding. Everything server-side, with no external services.
The XML sitemap is generated dynamically and includes all published posts (respecting the noindex flag), active categories, tags with posts, series, and static pages. Each entry includes the last modified date and relative priority.
The JSON-LD structured data includes schema.org BlogPosting for each article (with author, date, wordcount), BreadcrumbList for navigation, and CollectionPage for archive pages. Google Search Console validates them without errors.
The RSS feed includes the full content of the latest 50 posts (not just the excerpt), with categories as separate XML elements and the cover image as an enclosure. Any feed reader can consume the blog without visiting the website.
Performance with ISR and smart caching
Next.js supports Incremental Static Regeneration (ISR), which combines the best parts of static and dynamic sites. Pages are rendered on the server the first time they’re visited and cached for 5 minutes. Later visits get the cached version instantly while the server regenerates the page in the background if it has expired.
On top of ISR, we configured different Cache-Control headers. Next.js static assets (JavaScript, CSS) get max-age=31536000, immutable (one year, immutable) because the file name includes a hash of the content. If the content changes, the hash changes, and so does the URL, which invalidates the cache automatically.
Uploaded images have a one-day cache with one week of stale-while-revalidate, which balances freshness and performance. The RSS feed has a one-hour cache at the proxy.
SQLite helps performance in a way PostgreSQL can’t match in this context. Queries don’t go over the network, there’s no protocol serialization, and there’s no connection pool. A read query takes microseconds, not milliseconds.
Content integrity verification
One feature you won’t find in WordPress is integrity verification. We have an external system that calculates SHA-256 hashes for every published post and stores them as baselines signed with Ed25519. Periodically, an agent compares the current content with the baselines and alerts if something changed without an explicit update. From a security perspective, in the blog’s integrity verification system we go deeper into this.
This gives us confidence that if someone compromised the database and modified a post, we’d detect it automatically.
What we learned
CSP is the most valuable header, and the hardest one. Start restrictive and only open up what you need. Every exception should have a documented reason.
Rate limiting isn’t optional. Without it, any public endpoint is an abuse vector. Implementing it is simple compared to dealing with the consequences of not having it.
Putting a proxy in front breaks things silently. Anything that depended on the client IP needs to be reviewed, because what the origin sees now is the proxy IP. Rate limits, logs, geo-lookups, all of it.
SEO is code, not magic. Correct metadata, structured data, sitemap, and canonical URLs. You don’t need a plugin telling you your title is too long.
Caching should be deliberate. Immutable assets with long cache, dynamic content with stale-while-revalidate, and always a clear invalidation strategy.
SQLite in production isn’t heresy. For the right access pattern, it’s faster, simpler, and more reliable than a client-server database.
Security isn’t a feature you add at the end. It’s a decision you make at the beginning, and it shapes everything else
Another entry in the Building this blog series. You’re coming from Next.js, SQLite, and Docker, the technical stack and continuing with How we verify the integrity of posts.

Jose, author of the blog
QA Engineer. I write out loud about automation, AI and software architecture. If something here helped you, write to me and tell me about it.
Leave the first comment
What did you think? What would you add? Every comment sharpens the next post.
If you liked this

Un asistente de IA dentro de mi CV, arquitectura del chat
Cómo está montado por dentro el chat con agente de IA embebido en mi portfolio. Streaming SSE con eventos tipados, tool calling contra la API pública del blog, prompt como código y sesiones con cookie HttpOnly.

Next.js, SQLite y Docker, el stack técnico detrás de este blog
Cada decisión técnica tiene un porqué. Elegimos Next.js sobre Astro, SQLite sobre PostgreSQL y Docker con Dokploy sobre Vercel. Aquí explicamos las razones y lo que aprendimos por el camino.

Por qué construí mi propio motor de blog en Next.js (en lugar de WordPress o Ghost)
Teníamos WordPress, Ghost y decenas de plataformas a un clic de distancia. Pero ninguna nos daba lo que necesitábamos sin compromisos. Esta es la historia de por qué acabamos escribiendo nuestro propio motor de blog.