Store markdown files in PostgreSQL — edit them as plain files, share them across tools, and get transactional writes for free.
Write and organize .md files the way you normally would — with any text editor, shell script, or AI agent. The Markdown App stores each file as a database row with YAML frontmatter mapped to columns, so your content is:
- Shareable — multiple users, editors, and agents access the same files simultaneously
- Transactionally safe — every write is an atomic database operation; no partial saves or corrupted files
- Searchable — use
grepacross all files, or query metadata via SQL on the underlying table - Metadata-rich — frontmatter fields (author, tags, etc.) are real columns you can index and query
Under the hood, each markdown file is stored as a database row — frontmatter fields map to columns and the body maps to a text column:
hello-world.md:
---
title: Hello
author: alice
draft: false
---
# Hello
Welcome...Stored as:
id: 1, filename: "hello-world.md", title: "Hello", author: "alice", headers: {"draft": false}, body: "# Hello\n\nWelcome..."
Start fresh with a pre-configured table:
# Create a new markdown app called "notes"
echo "markdown" > /mnt/db/.build/notes
# Start writing
echo "---
title: Shopping List
---
- Milk
- Eggs
- Bread" > /mnt/db/notes/shopping.mdIf you already have a table with content:
# Create a markdown view on your posts table
echo "markdown" > /mnt/db/posts/.format/markdown
# Your posts are now available as .md files
ls /mnt/db/posts_md/
# hello-world.md my-first-post.md announcement.md
cat /mnt/db/posts_md/hello-world.mdThe two creation methods use different naming conventions:
| Method | Synthesized View | Native Table | Example |
|---|---|---|---|
.build/notes |
notes/ |
tigerfs.notes |
/notes/hello.md and /.tables/notes/1/body |
posts/.format/markdown |
posts_md/ |
posts/ |
/posts_md/hello.md and /posts/1/body |
.build/ (new app): View gets the clean name in the user's schema, backing table lives in the tigerfs schema. This is the primary method.
.format/ (existing table): View gets _md suffix to avoid collision with the existing table name.
These examples use a .build/ app called notes. The same operations work with .format/ views (just use posts_md/ instead of notes/).
# List all files
ls /mnt/db/notes/
# Read a file
cat /mnt/db/notes/hello-world.md
# Search across all files
grep -r "TODO" /mnt/db/notes/# Create with frontmatter
cat > /mnt/db/notes/new-post.md << 'EOF'
---
author: bob
tags: [tutorial, getting-started]
---
# Getting Started
This is my new post...
EOF# Edit with any editor
vim /mnt/db/notes/hello-world.md
# Or append content
echo "\n## Update\n\nMore content here." >> /mnt/db/notes/hello-world.md# Rename updates the filename column in the database
mv /mnt/db/notes/old-name.md /mnt/db/notes/new-name.mdrm /mnt/db/notes/unwanted-post.mdSynthesized apps support subdirectories. Create directories with mkdir and organize files into them:
# Create a directory
mkdir /mnt/db/notes/tutorials
# Create a file in the directory
cat > /mnt/db/notes/tutorials/getting-started.md << 'EOF'
---
title: Getting Started
author: alice
---
# Getting Started
Follow these steps...
EOF
# List files in the directory
ls /mnt/db/notes/tutorials/
# getting-started.mdAuto-creation: Writing a file with a path automatically creates parent directories. Writing to notes/a/b/c.md auto-creates the a/ and a/b/ directories.
Directory rename: Renaming a directory atomically renames all files within it:
mv /mnt/db/notes/tutorials /mnt/db/notes/guides
# All files under tutorials/ are now under guides/.format/ views: Directory support also works with .format/ views, as long as the underlying table has a filetype column.
TigerFS automatically detects column roles by naming convention (first match wins):
| Role | Detected From (priority order) | Required |
|---|---|---|
| Filename | filename, name, title, slug |
Yes |
| Body | body, content, description, text |
Yes |
| Timestamps | modified_at, updated_at (modification time); created_at (creation time) |
No |
| Extra Headers | headers (JSONB, merged into frontmatter) |
No |
| Frontmatter | All remaining columns (excluding primary key) | — |
Timestamp columns are used for file modification/creation times (visible in ls -l), but are not rendered as frontmatter fields.
Currently, column roles are always auto-detected by naming convention. A future release will allow explicit mapping for tables whose column names don't match the conventions:
# Planned — not yet implemented
echo '{filename:post_slug,body:post_content}' > /mnt/db/posts/.format/markdownA future release will allow reading the current column mapping by reading the .format/markdown control file:
# Planned — not yet implemented
cat /mnt/db/posts/.format/markdownTables created with .build/ include a headers JSONB column for storing arbitrary frontmatter keys beyond the fixed schema columns.
How it works:
- On read, entries from the
headerscolumn are merged into YAML frontmatter after the known columns, sorted alphabetically by key. - On write, any frontmatter keys that don't match a known column are collected into the
headersJSONB value. - Overwrite semantics — the entire
headersvalue is replaced on each write. If you remove a key from the frontmatter, it's removed from the database.
Example:
cat > /mnt/db/blog/welcome.md << 'EOF'
---
title: Welcome to My Blog
author: alice
tags: [intro, welcome]
draft: false
---
# Welcome
Thanks for visiting...
EOFHere title and author are stored in their own columns. tags and draft — which don't have dedicated columns — are stored together in the headers JSONB column.
Reading the file back:
---
title: Welcome to My Blog
author: alice
draft: false
tags:
- intro
- welcome
---
# Welcome
Thanks for visiting...Known columns (title, author) appear first in schema order, then extra headers (draft, tags) appear alphabetically.
# Set up blog
echo "markdown" > /mnt/db/.build/blog
# Write posts
cat > /mnt/db/blog/welcome.md << 'EOF'
---
title: Welcome to My Blog
author: alice
date: 2024-01-15
tags: [intro, welcome]
draft: false
---
# Welcome
Thanks for visiting...
EOF
# Find all drafts (draft and tags are stored in the headers JSONB column)
grep -l "draft: true" /mnt/db/blog/*.md# Create knowledge base
echo "markdown" > /mnt/db/.build/kb
# Organize into directories
mkdir /mnt/db/kb/getting-started
mkdir /mnt/db/kb/reference
cat > /mnt/db/kb/getting-started/setup-guide.md << 'EOF'
---
title: Setup Guide
last_updated: 2024-01-20
---
# Setup Guide
Follow these steps...
EOF
cat > /mnt/db/kb/reference/api.md << 'EOF'
---
title: API Reference
---
# API Reference
Endpoints and usage...
EOF
# List a section
ls /mnt/db/kb/getting-started/
# setup-guide.md
# Search across all sections
grep -r "TODO" /mnt/db/kb/# Create meeting notes app
echo "markdown" > /mnt/db/.build/meetings
# Record a meeting
cat > /mnt/db/meetings/2024-01-15-standup.md << 'EOF'
---
date: 2024-01-15
attendees: [alice, bob, charlie]
type: standup
---
# Daily Standup - Jan 15
## Updates
- Alice: Finished API work
- Bob: Working on frontend
- Charlie: Reviewing PRs
## Action Items
- [ ] Alice to deploy API
- [ ] Bob to demo on Friday
EOFAI agents can read and write content naturally:
# Agent reads all posts
for f in /mnt/db/blog/*.md; do
echo "=== $f ==="
cat "$f"
done
# Agent creates content
cat > /mnt/db/blog/ai-generated.md << 'EOF'
---
title: AI-Generated Summary
author: assistant
generated: true
---
Based on my analysis...
EOFThe underlying table is still accessible for SQL operations:
# For .format/ (existing table)
ls /mnt/db/posts/ # Native row-as-directory
ls /mnt/db/posts_md/ # Synthesized markdown
# For .build/ (new app)
ls /mnt/db/.tables/notes/ # Native backing table
ls /mnt/db/notes/ # Synthesized markdown- Frontmatter is automatic — All columns except filename, body, timestamps, and primary key become frontmatter
- Extra headers — Add a
headers JSONBcolumn to store arbitrary frontmatter keys beyond the fixed schema - Timestamps are file times —
modified_atandcreated_atset file mtime/ctime (visible inls -l), not rendered as frontmatter - Arrays supported —
tags: [a, b, c]works with PostgreSQL array columns