A small, file-based Markdown blog engine with a built-in admin.
Project description
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
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file volumen-0.4.0.tar.gz.
File metadata
- Download URL: volumen-0.4.0.tar.gz
- Upload date:
- Size: 74.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"44","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cbef781c3c4cee5926933f1efd614d4fc89c4a4dd640dc8061dcd43ce52489fe
|
|
| MD5 |
706857edb53bd74a195810a25303e7a5
|
|
| BLAKE2b-256 |
4baecf62b80a0112688590d2b06ff7c532b1b4347ab4a1b35702f1c2cc3a3269
|
File details
Details for the file volumen-0.4.0-py3-none-any.whl.
File metadata
- Download URL: volumen-0.4.0-py3-none-any.whl
- Upload date:
- Size: 54.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"44","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4c46c8c1110ba6a707dc34d5b4a4184098a4e3422163c95e8e66d36d8e5e268a
|
|
| MD5 |
9abb8f7acc964cd01e8e980a4c861a89
|
|
| BLAKE2b-256 |
3add634f166a701252413dc94017787d3e4b5fe020433ce018c882baaec4bbae
|