# 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 `.md` file 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 `::1` when running behind nginx so the admin port is not
exposed to the network.
---
## Stack
| Concern | Choice |
|----------------|------------------------------------------------------------------------|
| Language | Python 3.14+ |
| Web framework | [FastAPI](https://fastapi.tiangolo.com/) |
| App server | [Uvicorn](https://www.uvicorn.org/) (via `fastapi[standard]`) |
| Markdown | [python-markdown](https://python-markdown.github.io/) + [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/) |
| Frontmatter | TOML via stdlib [`tomllib`](https://docs.python.org/3/library/tomllib.html) + [`tomli-w`](https://github.com/camillescott/tomli-w) |
| Templating | [Jinja2](https://jinja.palletsprojects.com/) |
| Sessions | Starlette `SessionMiddleware` (signed cookies via [`itsdangerous`](https://itsdangerous.palletsprojects.com/)) |
| Packaging | Wheel + sdist via [`setuptools`](https://setuptools.pypa.io/); [`uv`](https://docs.astral.sh/uv/) for dependency management |
| Lint / format | [Ruff](https://docs.astral.sh/ruff/) |
| Tests | [pytest](https://docs.pytest.org/) + [httpx](https://www.python-httpx.org/) |
---
## 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 metadata
- `GET /posts` — paginated list with `lang`, `tag`, `q`, `page`, `limit`
filters
- `GET /posts/{slug}` — single post (raw Markdown + rendered HTML)
- `GET /tags` — tag cloud with counts
- `GET /feed.xml` — RSS 2.0 feed
- `GET /feed.json` — JSON Feed 1.1
- `GET /sitemap.xml` — sitemap
- Server-rendered admin at `/admin/`:
- Username + password login (scrypt-hashed via `hashlib.scrypt`,
verified in constant time with `secrets.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
- 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. See [`docs/api-reference.md`](docs/api-reference.md).
---
## Quick start
### Production / first install
```bash
# 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)
```bash
# 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 and the API at
. 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 `+++`:
```markdown
+++
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/.md` (default language), or
- `content_dir//.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:
```toml
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
`` with a `` and a small `i` info icon. Images without a
title render exactly as before:
```markdown

```
### 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 `` in
the RSS feed and `authors[].name` in the JSON feed. A front-end consuming the
API can drop it straight into ``:
```html
```
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-15` is a real date, not a guess.
- **Explicit typing** — no YAML implicit-coercion footguns (`lang = no`
becoming `false`).
- **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`](docs/configuration.md) for the full
schema.
```toml
[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:
```bash
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 |
```json
{
"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.
```json
{
"slug": "welcome",
"title": "Welcome to volumen",
"date": "2026-01-15",
"lang": "en",
"tags": ["intro", "python"],
"excerpt": "…",
"body": "# Welcome to **volumen**\n\n…",
"html": "