Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.
axumblogdockermarkdownminijinjarustself-hostedtypstvite
1> **Archived.** This project is no longer in use and no longer maintained. Last updated July 2026.
2
3# blog.bythewood.me
4
5My personal blog. It is a single Rust axum binary that renders Markdown files: posts, templates, Vite-built static assets, and the binary all live in this one repo. There is no database. To publish, you write a Markdown file and restart the process.
6
7It started life as a Flask app. The Rust rewrite uses far less memory, serves far more requests, and answers in well under a millisecond per request in release mode, which is why it exists in this form.
8
9## Features
10
11- Markdown posts with YAML frontmatter (title, slug, date, publish_date, tags, description, cover_image)
12- Server-rendered tag and year archives
13- Server-rendered search plus a live JSON search endpoint (`/search/` and `/search/live/`)
14- Per-post PDF export via embedded Typst (no chromium subprocess)
15- Per-post raw markdown download
16- Dynamic OG image generation per post
17- Single-binary deploy via `git push server master`
18
19## Tech stack
20
21| Concern | Crate / Tool |
22|-----------------|------------------------------|
23| Web framework | axum + tokio |
24| Template engine | minijinja |
25| Markdown | comrak |
26| PDF | embedded Typst (no chromium) |
27| Static assets | Vite + Bun |
28
29Why these: axum is the most-used async framework, minijinja is the Rust engine that accepts upstream Jinja2 syntax unchanged, comrak's partial-formatter hook is the closest match to the Mistune renderer-override pattern the original Flask version used, and embedded Typst renders PDFs in-process without spawning a browser.
30
31## Quickstart
32
33```sh
34cp samplefiles/env.sample .env
35make
36```
37
38`make` (alias `make run`) installs frontend deps if needed, then runs Vite watch and `cargo run` concurrently on port 8000. Visit http://localhost:8000.
39
40You need these on your `PATH` for local dev:
41
42| Tool | Why | Version |
43|---|---|---|
44| `rustc` / `cargo` | Build the axum binary | 2021 edition, stable 1.70+ |
45| `bun` | Frontend deps + Vite | 1.x |
46| `make` | Run the dev/build targets | any |
47
48If you only deploy via Docker you do not need any of these on the host; the `Dockerfile` reproduces the toolchain on `rust:alpine` + `alpine:3.23`.
49
50## Writing a post
51
521. Create a Markdown file under `content/posts/`, for example `content/posts/my-post.md`.
532. Give it YAML frontmatter:
54
55 ```yaml
56 ---
57 title: My Post
58 slug: my-post
59 date: 2026-06-19
60 publish_date: 2026-06-19
61 tags: [rust, notes]
62 description: A short summary used in listings and OG images.
63 cover_image: my-cover.webp
64 ---
65 ```
66
67 Body Markdown follows the frontmatter.
683. Put any images in `content/images/`; they are served at `/content/images/`.
694. **Restart the process.** Posts are loaded once at startup, so a new or edited post only appears after a restart. A `publish_date` in the future hides the post until that date (and still needs a restart once the date has passed).
70
71## Configuration
72
73All config comes from `.env` (loaded via `dotenvy`):
74
75| Variable | Required | Purpose |
76|---|---|---|
77| `PORT` | no (default `8000`) | HTTP listen port |
78| `BLOG_ROOT` | no | Override the project root (where `templates/`, `dist/`, and `content/` are read from) |
79
80## Make targets
81
82| Target | What it does |
83|---|---|
84| `make run` (default) | Vite watch + `cargo run` on port 8000 |
85| `make build` | Vite assets + release binary (`target/release/blog`) |
86| `make start` | Run the release binary (after `make build`) |
87| `make bench` | `oha` load test sweep across the main routes. Compares against a Flask server on port 8002 if running |
88| `make push` | `git push` to every configured remote |
89| `make clean` | Remove `target/`, `dist/`, and `frontend/node_modules/` |
90
91There are no tests or linters configured.
92
93## Routes
94
95- `/posts/<slug>/`: single post (old `/blog/<slug>/` 301-redirects here)
96- `/posts/<slug>/pdf/`: PDF export
97- `/posts/<slug>/md/`: raw markdown download
98- `/blog/`: post index, plus `/blog/tag/<tag>/` and `/blog/year/<year>/`
99- `/search/?q=...` and `/search/live/?q=...`: search
100- `/og/<slug>.svg`: per-post OG image
101
102## Layout
103
104```
105blog.bythewood.me/
106├── Cargo.toml, Cargo.lock # rust deps
107├── Makefile, README.md, bench/ # top-level
108├── src/ # rust source
109│ ├── main.rs # tiny entry: server boot
110│ ├── app.rs # AppState + Router assembly
111│ ├── render.rs # render_html helper
112│ ├── middleware.rs # request log
113│ ├── routes/ # home, blog, post, search, seo, errors
114│ ├── posts.rs # frontmatter + post loading
115│ ├── markdown.rs # comrak custom renderer
116│ ├── templates.rs # minijinja env, url_for, vite_asset, Jinja2-compat formatter
117│ └── pdf.rs # embedded Typst renderer
118├── templates/ # jinja2 source + blog_post.typ
119├── content/ # markdown source
120│ ├── posts/ # markdown posts with YAML frontmatter
121│ └── images/ # served at /content/images/
122├── frontend/ # JS pipeline (package.json, vite.config.js, static_src/)
123├── dist/ # vite build output (gitignored, served at /static/)
124├── target/ # cargo build output (gitignored)
125└── samplefiles/ # Caddyfile.sample, env.sample, post-receive.sample
126```
127
128The binary reads `templates/`, `dist/`, and `content/` from the current working directory. Override with `BLOG_ROOT=<path>`.
129
130## Deploy
131
132Production runs on Docker. The flow is `git push server master` to a remote whose post-receive hook runs `docker compose up --build --detach`. The `alpine:3.23` runtime image installs `font-jetbrains-mono`, `ttf-dejavu`, `ttf-liberation`, and `fontconfig` so the embedded Typst renderer can find body, mono, and fallback fonts. Sample files in `samplefiles/`:
133
134- `Caddyfile.sample`: reverse proxy with TLS
135- `env.sample`: the `.env` shown above
136- `post-receive.sample`: the git hook