12 KiB
volumen — file-based Markdown blog engine
volumen is a small, dependency-light blog engine written in Python. It serves your Markdown posts as a JSON API and includes a built-in, server- rendered admin with both a Markdown source editor and a visual editor — so any front-end (Vue, React, Svelte, plain HTML) can consume your content without re-implementing the engine.
It is designed to be:
- File-based — every post is a plain
.mdfile with TOML frontmatter, safe to commit to Git and edit in your favourite editor. - Self-contained — runs as a single Python process (Uvicorn); no Node build step is required to operate the engine or its admin.
- Multi-user with roles — username + password login; admin (full access, manages users) and author (posts and own account) roles.
- Multi-site friendly — one instance per blog, configurable per instance.
- Markdown-first — the visual editor round-trips through the same renderer that powers the public API, so what you store is always Markdown.
- IPv6-first — the default bind address is
::(IPv6 with automatic IPv4 fallback); bind to::1when running behind nginx so the admin port is not exposed to the network.
Stack
| Concern | Choice |
|---|---|
| Language | Python 3.14+ |
| Web framework | FastAPI |
| App server | Uvicorn (via fastapi[standard]) |
| Markdown | python-markdown + pymdown-extensions |
| Frontmatter | TOML via stdlib tomllib + tomli-w |
| Templating | Jinja2 |
| Sessions | Starlette SessionMiddleware (signed cookies via itsdangerous) |
| Packaging | Wheel + sdist via setuptools; uv for dependency management |
| Lint / format | Ruff |
| Tests | pytest + httpx |
Features (target)
- Markdown posts with TOML frontmatter (
title,date,lang,tags,draft,translations, …) - Multi-language posts with a translations map
- Clean JSON API at
/api/volumen/:GET /site— site metadataGET /posts— paginated list withlang,tag,q,page,limitfiltersGET /posts/{slug}— single post (raw Markdown + rendered HTML)GET /tags— tag cloud with countsGET /feed.xml— RSS 2.0 feedGET /feed.json— JSON Feed 1.1GET /sitemap.xml— sitemap
- Server-rendered admin at
/admin/:- Username + password login (scrypt-hashed via
hashlib.scrypt, verified in constant time withsecrets.compare_digest) - Settings page: change password, change username, manage users and roles (admin only)
- Session cookies (HttpOnly, SameSite=Strict) + CSRF tokens
- List, create, edit, delete posts
- Markdown source editor and a visual editor with live preview, both storing Markdown
- "Reload from disk" to pick up out-of-band edits
- Username + password login (scrypt-hashed via
- Permissive CORS for the public API, same-origin only for the admin
Status: the public read API (
/api/volumen) and the server-rendered admin (/admin, login + CRUD + Markdown editor with live preview) are implemented and tested. Seedocs/api-reference.md.
Quick start
Production / first install
# 1. Install the volumen CLI (once, on the machine):
uv tool install volumen # or: pipx install volumen
# 2. Bootstrap (once, typically as root — generates config, prompts for
# an admin password, and installs a hardened systemd unit):
sudo volumen init
sudo systemctl status volumen
# 3. Reverse-proxy with nginx and you're done — see docs/deployment.md.
uv tool install (or pipx install) drops a self-contained volumen
executable on PATH; volumen init is the operational bootstrap that
renders the config, creates the data dirs, generates a session key, and
(with --systemd, on by default under root) installs a hardened unit
file. See volumen init --help and docs/deployment.md for the full
flow.
Development (this checkout)
# 1. Install dependencies (Python 3.14+ and uv required)
just install
# 2. Run
just run
just install runs uv sync (creates .venv/ and installs the locked
dependency set from uv.lock). just run launches the volumen console
script from the venv with the bundled sample posts.
The admin will live at http://localhost:9090/admin/ and the API at
http://localhost:9090/api/volumen/posts. The dev admin password is admin
(the hash lives in ./config.toml).
Post format
Each post is a Markdown file with a TOML frontmatter block delimited by +++:
+++
title = "My post title"
slug = "my-post" # optional, defaults to the filename
date = 2026-01-15 # first-class TOML date
lang = "en" # language code
author = "Petr Balvín" # optional
fediverse_creator = "@petrbalvin@mastodon.social" # optional, for Mastodon attribution
tags = ["python", "web"] # optional
draft = false # optional
excerpt = "Short summary" # optional, auto-derived from body if missing
all_langs = false # optional, show this post in every language
[translations] # optional, maps lang → slug
cs = "muj-prispevek"
+++
# Heading
Body in **Markdown**, rendered by python-markdown.
Posts are organised on disk as either:
content_dir/<slug>.md(default language), orcontent_dir/<lang>/<slug>.md(per-language subdirectory)
The translations table links posts in different languages together so a
front-end can offer a language switcher.
Set all_langs = true on a post to show it in every language (useful for
posts that have no per-language translations).
Cover caption
A cover_caption frontmatter field renders a short caption next to the cover
image — useful for photo credits or a one-line blurb:
cover = "media/cover.webp"
cover_caption = "Photo by Petr Balvín"
Titled images as figures
A Markdown image with a title () is rendered as a
<figure> with a <figcaption> and a small i info icon. Images without a
title render exactly as before:

Fediverse attribution
Set fediverse_creator = "@user@instance.tld" on a post to attach a Mastodon
handle to it. The value is exposed as fediverse_creator on the post payload
(/api/volumen/posts/{slug} and /api/volumen/posts), and as <dc:creator> in
the RSS feed and authors[].name in the JSON feed. A front-end consuming the
API can drop it straight into <head>:
<meta name="fediverse:creator" content="@petrbalvin@mastodon.social">
A site-wide default can be set in config.toml ([site].fediverse_creator); a
per-post value takes precedence.
Why TOML instead of YAML
- First-class dates —
date = 2026-01-15is a real date, not a guess. - Explicit typing — no YAML implicit-coercion footguns (
lang = nobecomingfalse). - No whitespace pitfalls — indentation is not significant.
The engine parses it with the Python 3.11+ standard library tomllib; no
extra TOML dependency is needed at runtime.
Configuration
A single TOML file, auto-generated at /etc/volumen/config.toml when you
run volumen init (or, for local development, by volumen serve on first
start). See docs/configuration.md for the full
schema.
[server]
host = "::" # IPv6 + IPv4 fallback; use "::1" behind nginx
port = 9090
env = "production"
trust_proxy = true
cookie_secure = true # required in production behind HTTPS
content_dir = "/var/lib/volumen/posts"
users_file = "/var/lib/volumen/users.toml"
[site]
title = "My Blog"
description = "A blog powered by volumen."
base_url = "https://example.com"
language = "en"
author = "Anonymous"
[admin]
password_hash = "" # populated by `volumen init`
session_key = "" # populated by `volumen init`
session_ttl = 86400
min_password_length = 10
cookie_secure = false # set to true in production behind HTTPS
volumen init writes the bootstrap admin hash and a fresh session key for
you. To rotate the admin password later:
sudo volumen hash-password # returns a scrypt$… string; paste into [admin].password_hash
Public API
The API is at /api/volumen/. CORS is open (*) for read endpoints; admin
routes are same-origin only.
GET /api/volumen/posts
| Name | Type | Default | Description |
|---|---|---|---|
| lang | string | — | Filter by language |
| tag | string | — | Filter by tag |
| q | string | — | Search in title/body |
| page | int | 1 | Page number |
| limit | int | 20 | Posts per page |
{
"page": 1,
"page_size": 20,
"total": 42,
"has_next": true,
"has_prev": false,
"posts": [
{
"slug": "welcome",
"title": "Welcome to volumen",
"excerpt": "A short introduction…",
"date": "2026-01-15",
"lang": "en",
"tags": ["intro", "python"],
"author": "Petr Balvín",
"translations": { "cs": "vitame-vas" },
"url": "/api/volumen/posts/welcome"
}
]
}
GET /api/volumen/posts/{slug}?lang=en
Returns the full post including the raw Markdown body and the rendered HTML.
{
"slug": "welcome",
"title": "Welcome to volumen",
"date": "2026-01-15",
"lang": "en",
"tags": ["intro", "python"],
"excerpt": "…",
"body": "# Welcome to **volumen**\n\n…",
"html": "<h1>Welcome to <strong>volumen</strong></h1>…",
"translations": { "cs": "vitame-vas" }
}
Development
just # list recipes
just install # uv sync (creates .venv/ from uv.lock)
just run # run the dev server (volumen serve)
just dev # run against ./posts and ./config.toml on :9090
just build # ruff check + ruff format --check (zero warnings)
just test # uv run pytest
just fmt # ruff format + ruff check --fix
just uninstall # remove build artifacts (dist/, __pycache__, .pytest_cache, .ruff_cache)
The console script lives at src/volumen/cli.py and is registered as
volumen via the [project.scripts] entry in pyproject.toml; uv run
shims it onto PATH inside .venv/. In production the CLI is installed
globally via uv tool install volumen (or pipx install volumen) and the
operational bootstrap is done with sudo volumen init —
see docs/deployment.md.
Public site integration
volumen is headless for the front-end: it does not render your public site, it
only renders its own admin. The public website (e.g.
petrbalvin.org, a Vue 3 + Vite + Tailwind SPA)
consumes the JSON API. If you proxy /api/volumen/* through nginx on the same
origin, no front-end configuration is required.
Documentation
docs/architecture.md— components and request flowdocs/configuration.md— config schema (TOML)docs/api-reference.md— public JSON APIdocs/deployment.md— systemd, nginx, first rundocs/security.md— threat model and controls
Contributing
See CONTRIBUTING.md for development setup, code style, commit conventions, the pull request flow, and how releases are cut.
License
MIT — see LICENSE. Copyright © 2026 Petr Balvín