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.
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:
---
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:
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:
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:
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:
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.