<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: John Haab</title>
    <description>The latest articles on DEV Community by John Haab (@johnhaab).</description>
    <link>https://dev.to/johnhaab</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1943386%2F004bf24f-8cfb-4177-8809-359b1af5c75c.jpg</url>
      <title>DEV Community: John Haab</title>
      <link>https://dev.to/johnhaab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/johnhaab"/>
    <language>en</language>
    <item>
      <title>I Built My Own Blog Publishing System Instead of Using a CMS</title>
      <dc:creator>John Haab</dc:creator>
      <pubDate>Sun, 23 Aug 2026 19:31:28 +0000</pubDate>
      <link>https://dev.to/johnhaab/i-built-my-own-blog-publishing-system-instead-of-using-a-cms-1hi4</link>
      <guid>https://dev.to/johnhaab/i-built-my-own-blog-publishing-system-instead-of-using-a-cms-1hi4</guid>
      <description>&lt;h1&gt;
  
  
  Why I Built My Own Blog Publishing System
&lt;/h1&gt;

&lt;p&gt;I wanted to start writing more technical posts, but I immediately ran into a problem I didn't really like.&lt;/p&gt;

&lt;p&gt;If I wrote everything directly on Medium or DEV, those platforms effectively became the main home for my writing. I wanted my own website to be the original source, while still being able to publish to other platforms without rewriting and copying everything manually.&lt;/p&gt;

&lt;p&gt;So instead of installing a full CMS, I built a small publishing system directly into my portfolio.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually needed
&lt;/h2&gt;

&lt;p&gt;The requirements were pretty simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write posts in Markdown&lt;/li&gt;
&lt;li&gt;Save drafts&lt;/li&gt;
&lt;li&gt;Publish them to my portfolio&lt;/li&gt;
&lt;li&gt;Automatically publish a copy to DEV&lt;/li&gt;
&lt;li&gt;Keep my website as the canonical source&lt;/li&gt;
&lt;li&gt;Make publishing to Medium quick without trying to automate their entire website&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I intentionally didn't build user accounts, comments, roles, analytics, scheduling, a block editor, or any of the other stuff that usually comes with a CMS.&lt;/p&gt;

&lt;p&gt;There is one user: me.&lt;/p&gt;

&lt;p&gt;That made the architecture a lot simpler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;My portfolio was already built with Vite, React, React Router, and TypeScript, so I kept the existing frontend instead of rebuilding everything around something like Next.js.&lt;/p&gt;

&lt;p&gt;For the backend I added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Node.js
Express 5
TypeScript
PostgreSQL
node-postgres
Zod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Posts are stored as Markdown in PostgreSQL and rendered on the frontend with &lt;code&gt;react-markdown&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I also stuck with plain parameterized SQL and SQL migration files instead of adding an ORM. I like Drizzle and similar tools, but for a database this small it felt like adding another abstraction without really gaining much.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tiny private admin area
&lt;/h2&gt;

&lt;p&gt;The portfolio now has a private studio where I can manage posts.&lt;/p&gt;

&lt;p&gt;The authentication system is intentionally boring.&lt;/p&gt;

&lt;p&gt;There are no accounts or users table. I have one admin password stored as a scrypt hash on the server. Logging in creates a signed HttpOnly session cookie.&lt;/p&gt;

&lt;p&gt;From the studio I can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a post&lt;/li&gt;
&lt;li&gt;Save it as a draft&lt;/li&gt;
&lt;li&gt;Preview the Markdown&lt;/li&gt;
&lt;li&gt;Publish it&lt;/li&gt;
&lt;li&gt;Edit an existing post&lt;/li&gt;
&lt;li&gt;See its DEV publishing status&lt;/li&gt;
&lt;li&gt;Retry publishing to DEV if something fails&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Draft posts never appear through the public blog API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing outside my website
&lt;/h2&gt;

&lt;p&gt;The part I cared about most was making my site the source of truth.&lt;/p&gt;

&lt;p&gt;When I publish something, the order looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Publish locally
    ↓
Article goes live on my domain
    ↓
Publish the same Markdown to DEV
    ↓
DEV points its canonical URL back to my website
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DEV API call is intentionally treated as secondary.&lt;/p&gt;

&lt;p&gt;If DEV is down or its API fails, my article still gets published normally. I can retry the DEV sync afterward.&lt;/p&gt;

&lt;p&gt;That seems like a small detail, but I didn't want an external platform being able to break publishing on my own website.&lt;/p&gt;

&lt;p&gt;For Medium I took an even simpler approach. Instead of trying to automate it with browser scripts, the studio gives me the published URL so I can use Medium's importer.&lt;/p&gt;

&lt;p&gt;That takes less than a minute and saves me from maintaining some fragile automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  RSS, sitemap, and SEO
&lt;/h2&gt;

&lt;p&gt;Since the blog lives directly on my portfolio, I also added the boring stuff that makes it behave like a real blog.&lt;/p&gt;

&lt;p&gt;Each article gets its own metadata, canonical URL, Open Graph information, and publish/update dates.&lt;/p&gt;

&lt;p&gt;The backend also generates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/blog/rss.xml
/sitemap.xml
/robots.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only published articles are included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving it onto my homelab
&lt;/h2&gt;

&lt;p&gt;The project originally ran through Cloudflare Workers, but I had recently built a small Ubuntu homelab and wanted to start running more of my own infrastructure.&lt;/p&gt;

&lt;p&gt;The final setup is now roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub
   ↓
Dokploy
   ↓
Docker build
   ↓
Node + Express container
   ↓
PostgreSQL container
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Public traffic comes through Cloudflare Tunnel into Dokploy's Traefik proxy, so I don't need to expose the application or PostgreSQL directly to the internet.&lt;/p&gt;

&lt;p&gt;PostgreSQL stays on the internal Docker network and the application talks to it using its private service address.&lt;/p&gt;

&lt;p&gt;The production Docker image is multi-stage, so the first stage installs everything and builds the React and TypeScript code, while the final runtime image contains only what is actually needed to run the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  One deployment bug that was actually useful
&lt;/h2&gt;

&lt;p&gt;I also managed to waste some time debugging what looked like a backend routing problem.&lt;/p&gt;

&lt;p&gt;Every endpoint worked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/api/health → index.html
/api/blog/posts → index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first it looked like Express was accidentally letting the SPA fallback catch all of the API routes.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;After checking the process inside the production container, I found this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;caddy run --config /assets/Caddyfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Express wasn't running at all.&lt;/p&gt;

&lt;p&gt;Dokploy had deployed the project as a static frontend, so Caddy was happily serving the Vite build while the compiled backend was sitting inside the image doing absolutely nothing.&lt;/p&gt;

&lt;p&gt;Switching the application to build directly from my Dockerfile fixed it.&lt;/p&gt;

&lt;p&gt;It was a good reminder that once Docker, reverse proxies, tunnels, and application routing are involved, it's usually faster to test each layer individually instead of guessing which one is broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was building this necessary?
&lt;/h2&gt;

&lt;p&gt;Probably not.&lt;/p&gt;

&lt;p&gt;I could have just written directly on DEV or Medium.&lt;/p&gt;

&lt;p&gt;But this gives me something I wanted more: control over the original content and a publishing workflow that fits how I actually work.&lt;/p&gt;

&lt;p&gt;I can write an article once, publish it on infrastructure I control, and still use larger platforms for discovery.&lt;/p&gt;

&lt;p&gt;More importantly, I deliberately stopped once that workflow worked.&lt;/p&gt;

&lt;p&gt;I didn't want to spend three weeks building a CMS just so I could eventually start writing blog posts.&lt;/p&gt;

&lt;p&gt;The whole point was to build the tool, then get out of its way and actually use it.&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>softwaredevelopment</category>
      <category>webdev</category>
      <category>writing</category>
    </item>
  </channel>
</rss>
