~/blog / · 4 min read

How I Built a No-Database Markdown Blog in Rails 8

This blog is just markdown files in the repo — no database, no admin UI, scheduled posts, and server-side syntax highlighting. Write a file, git push, done.

#rails #ruby #blogging

This blog has no database. No posts table, no Active Storage, no admin UI. Every post is a markdown file in the repo, and publishing is git push. Here's how it fits together — and why a database would have been the wrong tool.

Why no database

My portfolio is a deliberately stateless Rails 8 app. The resume data lives in a frozen constant, the AI knowledge base is one markdown file, and the campaign pages are YAML. Adding Postgres just to hold blog posts would mean migrations, an editor, a deploy story for content — a lot of moving parts for what is, fundamentally, text I write in my editor.

Files are the better fit: version-controlled, reviewable in a PR, diffable, and publishable with the same git push I use for code. It also means I can hand a post to an AI, have it draft the markdown, and ship it without clicking through a CMS.

A post is a file

Each post is content/posts/<slug>.md. The filename is the slug, so URLs are clean and uniqueness is free. YAML frontmatter holds the metadata:

markdown
---
title: How I Built a No-Database Markdown Blog in Rails 8
published_at: 2026-06-18 09:00:00 -0700
description: Write a file, git push, done.
tags: [rails, ruby, blogging]
---

The body is just markdown.

A plain Ruby object reads the directory and parses the frontmatter — no ActiveRecord::Base, just File.read and YAML.safe_load:

ruby
class BlogPost
  POSTS_DIR = Rails.root.join("content", "posts")
  FRONTMATTER = /\A---\s*\n(.*?)\n---\s*\n?(.*)\z/m

  def self.all
    Dir[POSTS_DIR.join("*.md")].filter_map { |path| build(path) }
                               .sort_by { |p| p.published_at }
                               .reverse
  end

  def self.build(path)
    raw = File.read(path)
    match = raw.match(FRONTMATTER)
    meta = YAML.safe_load(match[1], permitted_classes: [Date, Time])
    new(File.basename(path, ".md"), meta, match[2])
  end
end

Scheduling without cron

I wanted future-dated posts: write something today, have it appear on its publish date — without a scheduler or a deploy. With files this is almost free. A post is hidden until its published_at has passed, and the filter runs on every request:

ruby
def hidden?
  draft? || published_at.nil? || published_at > Time.current
end

In production the index, the feed, and the sitemap all reject hidden posts. The post sits in the repo, deployed, completely invisible — then the moment its timestamp passes it simply starts showing up. No cron, no job queue, no database row flipping a boolean.

The one wrinkle is HTTP caching: if I cache the index for an hour, a post scheduled for noon won't appear until the cache expires. So the cache TTL is capped at the time until the next post goes live:

ruby
def set_blog_cache_headers
  next_at = BlogPost.next_publish_at
  seconds = next_at ? (next_at - Time.current).to_i : 600
  expires_in seconds.clamp(60, 600).seconds, public: true
end

In development every post renders (with a little scheduled badge) so I can preview before pushing.

Code blocks that don't need JavaScript

For a blog that's mostly tutorials, the code blocks matter more than anything. I render them server-side with Rouge, so there's no client-side highlighter to load and the markup is fully in the HTML for crawlers. A custom Redcarpet renderer wraps each fenced block in a terminal -style card with a copy button:

ruby
class Renderer < Redcarpet::Render::HTML
  def block_code(code, language)
    lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new
    highlighted = Rouge::Formatters::HTML.new.format(lexer.lex(code))
    %(<figure class="blog-code"><pre class="highlight"><code>#{highlighted}</code></pre></figure>)
  end
end

The copy button reuses a clipboard Stimulus controller I already had — the whole interactive surface of the blog is one tiny pre-existing controller.

SEO is the actual work

Owning the platform only pays off if the content is discoverable, so every post ships with the full kit: a canonical URL (so syndicated copies on dev.to or HEY World point back here), Open Graph and Twitter cards, JSON-LD BlogPosting structured data, an Atom feed, and an entry in sitemap.xml. None of it needs a database either — it's all derived from the same files at request time.

The whole thing

A model, a renderer, a controller, two views, a feed, and a sitemap. No migrations, no schema, no admin. I write markdown in my editor, push, and it's live — or scheduled for whenever I want it to be.

That's the version of blog I actually wanted: as close to just writing as I could get.