Plinth
A self-hosted personal website platform built with Leptos 0.8 — server-side rendering, WASM hydration, semantic search, and Typst blog support.
Features
- SSR + WASM hydration — Fast initial page loads with server-side rendering, then seamless interactivity via WASM hydration powered by Leptos 0.8.
- Postgres + pgvector — Schema-backed relational storage with junction-table tagging and approximate vector search.
- Semantic search — Built-in vector embeddings via fastembed with cosine similarity search — find content by meaning, not just keywords.
- Typst & Markdown — Author blog posts in Typst or Markdown. The CLI handles frontmatter, image uploads, embeddings, and publishing.
- Immich integration — Self-hosted image proxy backed by Immich. Upload images from the CLI, serve them through a caching proxy with aggressive cache headers.
- Plausible analytics — Optional self-hosted Plausible integration — privacy-friendly analytics with a single config toggle. No tracking when unconfigured.
- NixOS deployment — Declarative NixOS module with systemd hardening, reverse proxy configuration, and reproducible builds via Nix flakes.
Source code
Installation
Prerequisites
Plinth builds with Nix flakes. You need:
- Nix (2.18+) with
experimental-features = nix-command flakes - Or: Rust nightly,
wasm32-unknown-unknowntarget, cargo-leptos, tailwindcss, wasm-bindgen-cli, binaryen
The recommended approach is Nix — it handles the entire toolchain.
Clone and enter dev shell
git clone https://codeberg.org/caniko/plinth.git
cd plinth
nix develop
This drops you into a shell with Rust nightly, cargo-leptos, PostgreSQL 16 with pgvector, Tailwind CSS, and all native dependencies (OpenSSL, ONNX Runtime, libclang).
Build for production
nix build .#plinth
The output is in result/:
result/bin/plinth-server— server binary (wrapper that setsLEPTOS_SITE_ROOT)result/site/— compiled WASM, JS, CSS, and static assetsresult/share/plinth/plinth.toml— example configuration
Build variants
| Command | Profile | Use case |
|---|---|---|
nix build .#plinth | Release (opt-level 3) | Production deployment |
nix build .#plinth-dev | Debug | Fast compile, debugging |
nix build .#plinth-minimal | Release (opt-level z) | Minimal binary size |
Run locally
# Start the development server with hot reload
cargo leptos watch
The server starts at http://127.0.0.1:3000 by default. For local database setup, run ./scripts/dev-db.sh start inside nix develop.
Quick Start
This guide walks through getting Plinth running locally and publishing your first blog post.
1. Start the server
git clone https://codeberg.org/caniko/plinth.git
cd plinth
nix develop
./scripts/dev-db.sh start
cargo leptos watch
The dev-db.sh script starts a local Postgres cluster with pgvector and creates the plinth database. The dev shell exports DATABASE_URL automatically.
Open http://127.0.0.1:3000 in your browser. You should see the default Plinth homepage.
2. Customise your site
Create or edit plinth.toml in the project root:
[site]
name = "My Site"
tagline = "Welcome to my corner of the internet"
[site.author]
name = "Your Name"
email = "you@example.com"
Restart the server to pick up changes.
3. Publish a blog post
Create a Markdown file my-first-post.md:
---
title: My First Post
tags: ["hello", "plinth"]
description: Getting started with Plinth
---
# Hello, world!
This is my first blog post on Plinth.
Publish it using the CLI:
cargo run --package plinth-cli -- publish my-first-post.md
The CLI parses frontmatter, generates a vector embedding for semantic search, and sends the article to the server API.
4. View your post
Navigate to http://127.0.0.1:3000/posts to see your published post.
Next steps
- Configure your site with all available options
- Learn about Typst posts for richer authoring
- Set up image hosting with Immich
- Deploy to production on NixOS
Architecture Overview
Plinth is a four-crate Rust workspace built on Leptos 0.8 with SSR and WASM hydration.
Workspace layout
crates/
shared/ plinth-shared Domain types shared by all crates
client/ plinth-client Leptos frontend (compiles to WASM)
server/ plinth-server Axum server with Leptos SSR
cli/ plinth-cli CLI for publishing and management
Crate responsibilities
plinth-shared
Domain types used across the stack: BlogPost, BlogListItem, PortfolioItem, SiteConfig, Tag, SiteContent, ContentFormat, PublishArticleRequest. Also contains serde_helpers for flexible database ID deserialization.
Compiled to both native (server/CLI) and wasm32-unknown-unknown (client).
plinth-client
Leptos frontend compiled to WASM. Contains:
- Pages:
HomePage,BlogListPage,BlogPostPage,BlogTagPage,PortfolioPage,PortfolioDetailPage,AboutPage,NotFound - Components:
Header,Footer,ThemeToggle - API module: server function calls for data loading
Feature-gated: csr for client-side rendering, hydrate for SSR hydration mode.
plinth-server
Axum HTTP server that renders Leptos components server-side and serves the hydrated WASM client. Contains:
- Actors (
actors/): Kameo actors for in-memory caching and vector search - API (
api/): REST endpoints for admin, search, and image proxy - Services (
services/): Postgres access, migrations, row decoding, markdown processing - Server functions (
server_fns/): Leptos server functions for SSR data loading - Config (
config.rs):PlinthConfigloaded fromplinth.tomlwith env var overrides
AppState holds LeptosOptions, actor refs (CoreCache, brick-specific caches, VectorSearch), the Postgres pool, HTTP client, and config.
plinth-cli
CLI binary for content management:
publish— publish Markdown or Typst articles with embedding generationtag— manage tags (list, add, remove)content— update site content blocks
Typst support includes local image scanning, Immich upload, and typst-as-lib compilation to HTML.
SSR + WASM hydration flow
- Browser requests a page
- Axum receives the request and invokes Leptos SSR
- Leptos server functions fetch data from the cache actors, which read from Postgres on cache misses
- Server renders full HTML and streams it to the browser
- Browser loads the WASM bundle and hydrates the page for interactivity
- Subsequent navigation happens client-side via Leptos router
Data flow: publishing an article
- Author writes a
.mdor.typfile - CLI parses frontmatter, processes content (Markdown to HTML, or Typst compilation)
- CLI generates a 384-dimensional fastembed vector embedding
- CLI sends
POST /api/admin/articleswith content, metadata, and embedding - Server stores the article in Postgres, creates tag junction rows, syncs the read-side tag array, and invalidates caches
- pgvector stores the embedding and the HNSW index supports approximate similarity search
Actor System
Plinth uses Kameo actors for concurrent, in-memory operations that would be expensive to run on every request.
Content Caches
Location: crates/server/src/actors/core_cache.rs, crates/server/src/bricks/*/cache.rs
In-memory caches for blog posts, portfolio items, todos, tags, and site content. They avoid hitting Postgres on every page load.
Messages:
GetAllBlogPosts— returns cachedVec<BlogListItem>GetBlogPost(String)— returns a fullBlogPostby slugGetPostsByTag(String)— returns posts filtered by tag slugGetAllPortfolioItems— returns cached portfolio itemsGetPortfolioItemBySlug(String)— returns a single portfolio itemGetAllTodosandGetTodosByTag(String)— return todo list itemsGetAllTags— returns all tags with post countsGetSiteContent(String)— returns site content by keyInvalidateCache— clears the cache, forcing a reload from DB on next access
The cache is lazily populated: the first request after invalidation triggers a DB query, and subsequent requests are served from memory.
VectorSearch
Location: crates/server/src/actors/vector_search.rs
Handles semantic search using fastembed 384-dimensional embeddings and cosine similarity.
Messages:
SearchSimilarArticles { query, limit }— embeds the query text and finds the most similar articlesFindRelatedArticles { slug, limit }— finds articles related to a given postTrackOpinionEvolution { topic, min_similarity }— finds posts about a topic sorted chronologically (for tracking how opinions evolve over time)
The vector search path stores embeddings in Postgres via pgvector and uses the HNSW index for approximate nearest-neighbour lookup.
Lifecycle
Actors are spawned during server startup in main.rs and stored in AppState. They receive a clone of the Postgres pool for lazy data loading. Cache invalidation is triggered automatically after admin API operations (publish, delete, tag changes).
Rendering
Plinth builds the same Leptos app for several rendering targets. The route-level
source of truth is app_routes() in crates/client/src/app.rs; the table below
documents the default all-bricks build.
Route Modes
| Route | Mode | Why |
|---|---|---|
/ | Streaming SSR, SsrMode::OutOfOrder | The home page aggregates multiple bricks, so the shell can stream while slower sections resolve. |
/about | SSG, SsrMode::Static | Site content changes only when an admin publishes the about key. |
/support | SSG, SsrMode::Static | Site content changes only when an admin publishes the support key. |
/posts | SSG, SsrMode::Static | Blog index content changes at publish cadence. |
/posts/:slug | SSG, SsrMode::Static with prerendered slugs | Published posts are addressable static content. |
/posts/tag/:tag | SSG, SsrMode::Static with prerendered tag names and slugs | Tag pages change when posts or tags are published. |
/series | SSG, SsrMode::Static | Series membership changes at blog publish cadence. |
/series/:slug | SSG, SsrMode::Static with prerendered series slugs | Series pages are derived from published posts. |
/projects | SSG, SsrMode::Static | Portfolio index content changes at publish cadence. |
/projects/:slug | SSG, SsrMode::Static with prerendered project slugs | Portfolio entries are stable published content. |
/activity | Dynamic SSR, SsrMode::OutOfOrder | Activity is ranked and refreshed at request time. |
/activity/:id | Dynamic SSR, SsrMode::OutOfOrder | Individual activity entries may refresh forge metadata. |
/todos | Dynamic SSR, SsrMode::OutOfOrder | Todo ordering and completion state are ranked/user-curated dynamic data. |
/todos/tag/:tag | Dynamic SSR, SsrMode::OutOfOrder | Tagged todo lists are request-time ranked views. |
/todos/:slug | Dynamic SSR, SsrMode::OutOfOrder | Todo detail pages expose mutable state. |
Custom builds with one or more bricks disabled keep the static site-content routes and omit the disabled brick routes at compile time.
Decision Rule
Use static generation for publish-cadence content: site pages, blog posts, series, and portfolio entries. Use streaming SSR for multi-source aggregate pages where independent sections can resolve at different speeds. Use dynamic SSR for user-curated, ranked, or externally refreshed data. Use islands for interactive widgets that need client behavior without hydrating the whole app. Use the CSR build for serverless or static-host deployments where a separate Plinth API server supplies content.
Islands Boundary
The SSR/hydrate build enables Leptos islands. The app shell and page bodies are
server-rendered, while the interactive header boundary hydrates on the client:
Header is an island in non-CSR islands builds, and ThemeToggle is always an
island. This keeps the mobile navigation toggle and theme persistence
interactive without hydrating read-only content pages.
Static Regeneration
Static routes use Leptos StaticRoute::regenerate streams. Admin publish paths
send invalidation events after a successful write:
| Admin write | Invalidated static routes |
|---|---|
| Blog publish, update, delete, or tag change | /posts, matching /posts/:slug, matching /posts/tag/:tag, /series, and matching /series/:slug when a series is involved |
| Portfolio publish | /projects and matching /projects/:slug |
| Site content publish | Matching site-content route such as /about or /support |
The invalidation signal is narrow: request-time routes do not participate in static regeneration because they are rendered dynamically.
Build Targets
Build the default SSR/islands target with:
cargo leptos build
This produces the server binary and the browser WASM/CSS assets for the normal deployment package. The same route table controls server rendering and the hydration boundary.
Build the client-only CSR target with:
nix build .#plinth-csr
The CSR package emits static files only. It renders routes in the browser and
uses public GET /api/* endpoints instead of Leptos server functions. Use it
for static previews or static hosting paired with a separate Plinth API server;
prefer the default SSR package when the deployment should serve rendered HTML,
feeds, admin APIs, and proxied images from one process.
WASM Safety
Server-only dependencies stay behind the ssr feature. The client crate is
included with default-features = false, and browser builds must not pull in
Axum, Tokio server actors, SQLx, forge refresh code, or other server-only
runtime dependencies. Data that the browser needs comes through shared types,
hydrated resources, server functions in SSR builds, or public REST calls in the
CSR build.
plinth.toml
Plinth reads configuration from plinth.toml (or the path in PLINTH_CONFIG). All fields have defaults — an empty file produces a working configuration.
[site]
| Key | Type | Default | Description |
|---|---|---|---|
name | string | "Plinth" | Site name in header and page titles |
tagline | string | "Welcome to my website" | Short tagline on the home page |
description | string | "A personal website" | Default meta description |
lang | string | "en" | HTML lang attribute |
default_theme | string | "dark" | Default colour theme ("dark" or "light") |
animated_background | string | "flow-field" | Home page background preset |
Accepted animated_background values:
"none"disables the animated canvas and uses a static page background."flow-field"renders an Odysseus-style procedural particle flow field."constellation"renders drifting nodes with proximity lines."aurora-ribbons"renders translucent wave ribbons."orbital-trails"renders orbiting particles with fading trails."digital-rain"renders sparse terminal-style falling glyphs."topographic-waves"renders animated contour lines.
[site.author]
| Key | Type | Default | Description |
|---|---|---|---|
name | string | "Admin" | Default author name for articles |
email | string | "" | Email shown in footer (empty = hidden) |
[site.social]
| Key | Type | Default | Description |
|---|---|---|---|
github | string | "" | GitHub profile URL (empty = hidden) |
gitlab | string | "" | GitLab profile URL |
codeberg | string | "" | Codeberg profile URL |
mastodon | string | "" | Mastodon profile URL |
bluesky | string | "" | Bluesky profile URL |
[site.footer]
| Key | Type | Default | Description |
|---|---|---|---|
project_name | string | "Plinth" | Project name in footer attribution |
project_url | string | "https://codeberg.org/caniko/plinth" | Project URL in footer |
[[site.nav]]
Navigation items (order matters). Each entry has:
| Key | Type | Description |
|---|---|---|
label | string | Link text |
path | string | URL path |
Default navigation:
[[site.nav]]
label = "Posts"
path = "/posts"
[[site.nav]]
label = "Projects"
path = "/projects"
[[site.nav]]
label = "About"
path = "/about"
[pages.home]
| Key | Type | Default | Description |
|---|---|---|---|
title | string | "" | Home page title (empty = use site name) |
description | string | "" | Home page meta description |
[pages.blog]
| Key | Type | Default | Description |
|---|---|---|---|
title | string | "Posts" | Blog listing page title |
subtitle | string | "" | Subtitle below the title |
description | string | "" | Meta description |
[pages.portfolio]
| Key | Type | Default | Description |
|---|---|---|---|
title | string | "Projects" | Portfolio page title |
subtitle | string | "" | Subtitle below the title |
description | string | "" | Meta description |
[pages.about]
| Key | Type | Default | Description |
|---|---|---|---|
title | string | "About Me" | About page title |
description | string | "" | Meta description |
[server]
| Key | Type | Default | Description |
|---|---|---|---|
host | string | "127.0.0.1" | Bind address |
port | u16 | 3000 | Bind port |
[database]
| Key | Type | Default | Description |
|---|---|---|---|
database_url | string | "postgres://plinth:plinth@localhost:5432/plinth" | Postgres connection URL |
[observability]
| Key | Type | Default | Description |
|---|---|---|---|
service_name | string | "plinth" | OTLP service name |
log_level | string | "info" | Rust log level (RUST_LOG format) |
otlp_endpoint | string | "" | OTLP endpoint URL (empty = disabled) |
otlp_headers | string | "" | OTLP auth headers (comma-separated key=value) |
[search]
| Key | Type | Default | Description |
|---|---|---|---|
default_limit | usize | 10 | Default search result count |
related_limit | usize | 5 | Default related articles count |
min_similarity | f32 | 0.5 | Minimum cosine similarity for opinion tracking |
[content]
| Key | Type | Default | Description |
|---|---|---|---|
words_per_minute | usize | 200 | WPM for reading time calculation |
vector_truncation | usize | 5000 | Max characters before generating embeddings |
[feeds]
| Key | Type | Default | Description |
|---|---|---|---|
blog_limit | usize | 50 | Max entries in /feeds/blog.xml |
projects_limit | usize | 50 | Max entries in /feeds/projects.xml |
activity_limit | usize | 50 | Max entries in /feeds/activity.xml |
[ranking]
Controls how activity entries are scored for the ranked /activity page, the home strip, and the public API. Score is computed at read time, so changes take effect on the next request.
| Key | Type | Default | Description |
|---|---|---|---|
strategy | string | "exponential" | "exponential", "linear", or "pure" |
half_life_days | float | 365.0 | Exponential ranking: impact * 0.5 ^ (age_days / half_life_days) |
window_days | float | 730.0 | Linear ranking: impact * max(0, 1 - age_days / window_days) |
age uses the reference date coalesce(merged_at, closed_at, created_at). pure uses impact alone, with the most recent reference date as a tiebreaker. Results are ordered by score descending, then reference date descending.
[forge]
Controls how the server fetches and refreshes activity metadata from code forges. Tokens are provided via environment variables only, never in this file. See Environment Variables.
| Key | Type | Default | Description |
|---|---|---|---|
refresh_ttl_secs | integer | 3600 | Refresh an entry in the background when its fetched_at is older than this many seconds |
refresh_backoff_secs | integer | 900 | Minimum seconds between refresh attempts for an entry that errored |
github_base_url | string | "https://api.github.com" | GitHub API base URL, overrideable for GitHub Enterprise or testing |
codeberg_base_url | string | "https://codeberg.org/api/v1" | Codeberg/Forgejo API base URL, overrideable for self-hosted Forgejo or testing |
[ranking]
strategy = "exponential"
half_life_days = 365.0
window_days = 730.0
[forge]
refresh_ttl_secs = 3600
refresh_backoff_secs = 900
github_base_url = "https://api.github.com"
codeberg_base_url = "https://codeberg.org/api/v1"
# tokens via GITHUB_TOKEN / CODEBERG_TOKEN env vars, not here
[immich]
| Key | Type | Default | Description |
|---|---|---|---|
api_url | string | "" | Immich server URL (empty = image proxy disabled) |
[images]
| Key | Type | Default | Description |
|---|---|---|---|
cache_max_age | u64 | 31536000 | Cache-Control max-age for proxied images (seconds) |
[analytics]
| Key | Type | Default | Description |
|---|---|---|---|
plausible_domain | string | "" | Site domain tracked by Plausible (empty = disabled) |
plausible_script_url | string | "" | URL to your Plausible script (e.g. https://plausible.example.com/js/script.js) |
Both fields must be set for the Plausible <script> tag to be injected. This keeps analytics fully opt-in.
[donation]
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Enable donation links across the site |
cta_text | string | "" | Custom text for end-of-article CTA (default: “If you found this useful, consider supporting my work.”) |
[[donation.links]]
Each entry defines a donation platform link:
| Key | Type | Default | Description |
|---|---|---|---|
platform | string | (required) | Platform identifier: "kofi", "github_sponsors", "liberapay", or "custom" |
url | string | (required) | URL to your profile on the platform |
label | string | "" | Custom display label (empty = auto-generated from platform name) |
When enabled, donation links appear in three places:
- Header: A “Support” link with heart icon in the navigation bar
- End of articles: A compact CTA after blog post content
- Footer: A heart icon alongside social links
/supportpage: A dedicated page showing all configured platforms as cards
[donation]
enabled = true
cta_text = "If you found this useful, consider supporting my work."
[[donation.links]]
platform = "kofi"
url = "https://ko-fi.com/yourusername"
[[donation.links]]
platform = "github_sponsors"
url = "https://github.com/sponsors/yourusername"
[[donation.links]]
platform = "liberapay"
url = "https://liberapay.com/yourusername"
Full example
[site]
name = "My Site"
tagline = "Systems, science, and software"
description = "Personal website and blog"
lang = "en"
default_theme = "dark"
animated_background = "flow-field"
[site.author]
name = "Jane Doe"
email = "jane@example.com"
[site.social]
github = "https://github.com/janedoe"
mastodon = "https://fosstodon.org/@janedoe"
[site.footer]
project_name = "Plinth"
project_url = "https://codeberg.org/caniko/plinth"
[[site.nav]]
label = "Posts"
path = "/posts"
[[site.nav]]
label = "Projects"
path = "/projects"
[[site.nav]]
label = "About"
path = "/about"
[pages.blog]
title = "Blog"
subtitle = "Notes on software and systems"
[server]
host = "127.0.0.1"
port = 3000
[database]
database_url = "postgres://plinth:plinth@localhost:5432/plinth"
[observability]
log_level = "info"
[search]
default_limit = 10
[content]
words_per_minute = 200
[feeds]
blog_limit = 50
projects_limit = 50
activity_limit = 50
[ranking]
strategy = "exponential"
half_life_days = 365.0
window_days = 730.0
[forge]
refresh_ttl_secs = 3600
refresh_backoff_secs = 900
github_base_url = "https://api.github.com"
codeberg_base_url = "https://codeberg.org/api/v1"
[analytics]
plausible_domain = "example.com"
plausible_script_url = "https://plausible.example.com/js/script.js"
[donation]
enabled = true
[[donation.links]]
platform = "kofi"
url = "https://ko-fi.com/janedoe"
[[donation.links]]
platform = "github_sponsors"
url = "https://github.com/sponsors/janedoe"
Environment Variables
Environment variables override values from plinth.toml. This is useful for secrets and deployment-specific overrides.
Configuration path
| Variable | Default | Description |
|---|---|---|
PLINTH_CONFIG | plinth.toml | Path to the TOML configuration file |
Server
| Variable | Default | Description |
|---|---|---|
LEPTOS_SITE_ADDR | 127.0.0.1:3000 | Server bind address (set by Nix wrapper) |
LEPTOS_SITE_ROOT | target/site | Path to compiled site assets (set by Nix wrapper) |
Authentication
| Variable | Default | Description |
|---|---|---|
PLINTH_API_KEY | dev_api_key_change_in_production | Bearer token for admin API endpoints |
Database
These override the [database] section in plinth.toml:
| Variable | TOML key | Description |
|---|---|---|
PLINTH_DATABASE_URL | database.database_url | Postgres connection URL |
DATABASE_URL | database.database_url | Postgres connection URL |
Observability
These override the [observability] section:
| Variable | TOML key | Description |
|---|---|---|
RUST_LOG | observability.log_level | Log level filter (e.g. info,plinth=debug) |
OTEL_EXPORTER_OTLP_ENDPOINT | observability.otlp_endpoint | OTLP endpoint URL |
OTEL_EXPORTER_OTLP_HEADERS | observability.otlp_headers | OTLP auth headers |
OTEL_SERVICE_NAME | observability.service_name | Telemetry service name |
Immich
| Variable | TOML key | Description |
|---|---|---|
IMMICH_API_URL | immich.api_url | Immich server URL (enables image proxy) |
IMMICH_API_KEY | — | Immich API key (env-only, not in TOML) |
Forge tokens
Optional tokens for fetching activity from code forges. Public data works without them but is rate-limited. These tokens are read only from the environment, have no TOML equivalent in [forge], and are never sent to the browser.
| Variable | Description |
|---|---|
GITHUB_TOKEN | GitHub personal access token; raises the rate limit for GitHub API requests |
CODEBERG_TOKEN | Codeberg or Forgejo access token |
Activity metadata is served from the database immediately. When an entry’s fetched_at is older than forge.refresh_ttl_secs, the server starts a background refresh and continues serving the cached entry. Configure the TTL and failed-refresh backoff in the [forge] table.
Analytics
These override the [analytics] section:
| Variable | TOML key | Description |
|---|---|---|
PLAUSIBLE_DOMAIN | analytics.plausible_domain | Site domain tracked by Plausible |
PLAUSIBLE_SCRIPT_URL | analytics.plausible_script_url | URL to self-hosted Plausible script |
CLI-only
These are used by plinth-cli, not the server:
| Variable | Default | Description |
|---|---|---|
PLINTH_API_URL | http://localhost:3000 | Target server URL for CLI operations |
Precedence
- Environment variables (highest priority)
plinth.tomlvalues- Compiled defaults (lowest priority)
NixOS Module
Plinth ships a NixOS module for declarative deployment with systemd hardening.
Adding the flake
# flake.nix
{
inputs.plinth.url = "github:caniko/plinth";
outputs = { self, nixpkgs, plinth, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
plinth.nixosModules.default
# Make packages available via overlay
{ nixpkgs.overlays = [ plinth.overlays.default ]; }
];
};
};
}
Minimal deployment
services.plinth.instances.default = {
host = "127.0.0.1";
port = 3000;
};
This starts Plinth on port 3000 with all defaults. The module also enables PostgreSQL 16, loads pgvector, creates the plinth database and plinth role, and starts the service after postgresql.service with DATABASE_URL=postgres:///plinth?host=/run/postgresql.
Site personalisation
services.plinth.instances.default = {
site = {
name = "Jane's Blog";
tagline = "Thoughts on systems programming";
defaultTheme = "dark";
author = {
name = "Jane Doe";
email = "jane@example.com";
};
social = {
github = "https://github.com/janedoe";
mastodon = "https://fosstodon.org/@janedoe";
};
nav = [
{ label = "Blog"; path = "/posts"; }
{ label = "Projects"; path = "/projects"; }
{ label = "About"; path = "/about"; }
];
};
pages.blog = {
title = "Blog";
subtitle = "Notes on software and systems";
};
};
Production with Caddy
services.plinth.instances.default = {
host = "127.0.0.1";
port = 3000;
stateDir = "/var/lib/plinth";
apiKeyFile = "/run/secrets/plinth-api-key";
database = {
name = "plinth";
url = "postgres:///plinth?host=/run/postgresql";
};
};
services.caddy = {
enable = true;
virtualHosts."example.com".extraConfig = ''
reverse_proxy localhost:3000
'';
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
Observability
services.plinth.instances.default.observability = {
enable = true;
otlpEndpoint = "https://openobserve.example.com:5081";
serviceName = "plinth-prod";
logLevel = "info,plinth=debug";
};
Secrets with agenix
age.secrets.plinth-api-key = {
file = ./secrets/plinth-api-key.age;
owner = "plinth";
group = "plinth";
};
services.plinth.instances.default = {
apiKeyFile = config.age.secrets.plinth-api-key.path;
};
Secrets with sops-nix
sops.secrets."plinth/api-key".owner = "plinth";
services.plinth.instances.default = {
apiKeyFile = config.sops.secrets."plinth/api-key".path;
};
All module options
| Option | Type | Default | Description |
|---|---|---|---|
instances | attrset | {} | Named Plinth instances to run |
package | package | pkgs.plinth | Plinth package to use |
user | string | "plinth" for default, otherwise "plinth-<name>" | System user |
group | string | "plinth" for default, otherwise "plinth-<name>" | System group |
host | string | "127.0.0.1" | Bind address |
port | port | 3000 | Bind port |
stateDir | path | /var/lib/plinth | Stateful data directory |
apiKeyFile | path or null | null | Path to API key file (loaded via systemd LoadCredential) |
site.* | — | — | Site identity (see plinth.toml) |
pages.* | — | — | Page-specific config (see plinth.toml) |
database.name | string | "plinth" for default, otherwise "plinth_<name>" | Postgres database name |
database.url | string | postgres:///plinth?host=/run/postgresql for default | Postgres connection URL |
observability.enable | bool | false | Enable OTLP export |
observability.otlpEndpoint | string | "" | OTLP endpoint URL |
observability.otlpHeaders | string or null | null | OTLP auth headers |
observability.serviceName | string | "plinth" | Telemetry service name |
observability.logLevel | string | "info" | Log level (RUST_LOG) |
search.defaultLimit | int | 10 | Search result limit |
search.relatedLimit | int | 5 | Related articles limit |
search.minSimilarity | float | 0.5 | Min similarity for opinion tracking |
content.wordsPerMinute | int | 200 | Reading time WPM |
content.vectorTruncation | int | 5000 | Embedding char limit |
immich.apiUrl | string | "" | Immich URL (empty = disabled) |
immich.apiKey | string | "" | Immich API key |
images.cacheMaxAge | int | 31536000 | Image cache max-age (seconds) |
extraEnv | lines | "" | Additional env vars (KEY=value per line) |
Systemd hardening
The module applies security hardening by default:
NoNewPrivileges,ProtectSystem=strict,ProtectHomePrivateTmp,PrivateDevices,PrivateMountsRestrictAddressFamilies(AF_UNIX, AF_INET, AF_INET6 only)RestrictNamespaces,LockPersonality,RestrictRealtimeReadWritePathslimited tostateDir
Reverse Proxy
Plinth binds to localhost by default. Use a reverse proxy to handle TLS and expose it to the internet.
Caddy (recommended)
Caddy automatically provisions TLS certificates via Let’s Encrypt.
services.caddy = {
enable = true;
virtualHosts."example.com".extraConfig = ''
reverse_proxy localhost:3000
'';
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
Nginx
services.nginx = {
enable = true;
virtualHosts."example.com" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:3000";
proxyWebsockets = true;
};
};
};
security.acme = {
acceptTerms = true;
defaults.email = "admin@example.com";
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
Notes
- Plinth serves static assets (WASM, CSS, JS) directly — no separate static file hosting needed
- The image proxy endpoint (
/api/images/) streams from Immich with 1-year cache headers, so the reverse proxy benefits from caching - WebSocket support is not currently needed (Leptos uses standard HTTP for SSR)
CSR Static Build
Plinth’s default deployment target is the SSR + hydrate server package. Use that for production sites that should render HTML on the server, serve feeds, proxy images, and expose the admin API from the same process.
The plinth-csr package is a client-only static build. It is useful for
serverless/static-host previews or deployments where another Plinth API server is
available. The bundle renders all routes in the browser and reads public content
with GET /api/* REST calls instead of Leptos server functions.
Build it with:
nix build .#plinth-csr
The result is a static directory containing index.html, pkg/plinth.js,
pkg/plinth_bg.wasm, pkg/plinth.css, and copied assets from public/.
By default the CSR bundle assumes the API is available on the same origin as the
static files. For a separate API origin, build with PLINTH_CSR_API_BASE set to
the API origin, for example https://plinth-api.example.com. Cross-origin
deployments require the API host to allow browser CORS requests.
Publishing Blog Posts
Plinth supports two authoring formats: Markdown and Typst. The CLI detects the format by file extension (.md or .typ).
Markdown workflow
Frontmatter
Use YAML frontmatter at the top of your .md file:
---
title: My Post Title
tags: ["rust", "leptos"]
description: A brief description for search engines
author: Jane Doe
published: true
featured: false
---
# Your content here
Regular Markdown with headings, code blocks, links, and images.
All frontmatter fields are optional. If title is omitted, you must provide it via the CLI or API.
| Field | Type | Default | Description |
|---|---|---|---|
title | string | required | Article title |
tags | string[] | [] | Tag names |
description | string | — | Meta description |
author | string | site author | Author name |
published | bool | true | Whether the post is visible |
featured | bool | false | Whether to feature the post |
Publishing
plinth-cli publish article.md
Or during development:
cargo run --package plinth-cli -- publish article.md
The CLI:
- Parses YAML frontmatter
- Converts Markdown to HTML (pulldown-cmark)
- Generates a 384-dim fastembed vector embedding from the text
- Sends
POST /api/admin/articleswith content, HTML, metadata, and embedding
Typst workflow
Typst files (.typ) offer richer formatting with programmatic layout capabilities.
Frontmatter
Typst uses comment-based YAML frontmatter:
// ---
// title: My Typst Post
// tags: ["typst", "rust"]
// description: A post authored in Typst
// ---
= Introduction
This is a Typst document with rich formatting.
Image functions
The Plinth Typst template (templates/blog.typ) provides image layout functions:
#blog-image("photo.jpg", placement: "inline", caption: "A photo", alt: "Description")
Placements: inline, hero, float-left, float-right, full-width
Convenience functions:
#hero-image("banner.jpg", alt: "Banner image")
#gallery((src: "a.jpg", alt: "First"), (src: "b.jpg", alt: "Second"))
Publishing with images
When your .typ file references local images, the CLI handles upload:
plinth-cli publish post.typ --immich-url https://immich.example.com --immich-api-key KEY
The CLI:
- Extracts comment-based YAML frontmatter
- Scans for local image references (
#blog-image("local.jpg", ...)) - Uploads local images to Immich, receives asset IDs
- Replaces local paths with
/api/images/{asset_id}proxy URLs - Compiles Typst to HTML via
typst-as-lib+typst-html - Generates a fastembed embedding from the text content
- Sends pre-rendered HTML + metadata to the server API
Managing tags
# List all tags
plinth-cli tag list
# Add a tag to a post
plinth-cli tag add --post my-post-slug --tag "new-tag"
# Remove a tag from a post
plinth-cli tag remove --post my-post-slug --tag "old-tag"
Managing site content
Update content blocks (e.g. the about page) via the CLI:
plinth-cli content update --key about --file about.md
Image Handling
Plinth integrates with Immich for self-hosted image management. Images are stored in Immich and served to readers through Plinth’s image proxy.
Architecture
Author Plinth CLI Immich Plinth Server Browser
| | | | |
|-- publish post.typ --> | | | |
| |-- upload images ---> | | |
| |<-- asset IDs ------- | | |
| |-- POST article ----->| | |
| | (with /api/images/{id} URLs) | |
| | | | |
| | | GET /api/images/{id} <------------- |
| | |<---- fetch with API key --------------- |
| | |---- stream bytes ---------------------->|
Immich is never exposed publicly. Plinth authenticates with Immich using an API key and streams the image bytes to the browser with aggressive caching.
Server configuration
Enable the image proxy by setting the Immich URL and API key:
# plinth.toml
[immich]
api_url = "http://immich:2283"
[images]
cache_max_age = 31536000 # 1 year in seconds
The API key is set via environment variable (not in TOML, for security):
export IMMICH_API_KEY="your-immich-api-key"
Or in NixOS:
services.plinth.immich = {
apiUrl = "http://immich:2283";
apiKey = "your-key"; # or use secrets management
};
Image proxy endpoint
GET /api/images/{asset_id}?size=original|preview|thumbnail
| Parameter | Default | Description |
|---|---|---|
asset_id | required | Immich asset UUID |
size | original | Image variant: original, preview, or thumbnail |
Response headers include Cache-Control: public, max-age=31536000, immutable for browser and CDN caching.
The asset_id is validated as a UUID to prevent path traversal.
CLI image upload
When publishing Typst posts with local image references:
plinth-cli publish post.typ \
--immich-url https://immich.example.com \
--immich-api-key YOUR_KEY
The CLI scans for #blog-image("local-file.jpg", ...) references, uploads each file to Immich, and replaces the local path with /api/images/{asset_id} in the compiled HTML.
Environment variables IMMICH_API_URL and IMMICH_API_KEY can be used instead of flags.
Curating External Activity
The activity feature showcases pull requests and issues you have landed on other people’s repositories across GitHub and Codeberg, ranked by impact and recency. It is distinct from the portfolio, which is for your own projects.
Each entry is fetched from the forge once, at add time, by the CLI. The CLI also computes the search embedding locally; the server never runs fastembed for activity publishing. The server keeps forge metadata fresh in the background according to the [forge] settings in plinth.toml.
Adding a contribution
plinth activity add \
--forge github \
--repo owner/name \
--pr 1234 \
--impact 7 \
--featured
For an issue, use --issue <n> instead of --pr <n>.
| Flag | Required | Description |
|---|---|---|
--forge | yes | github or codeberg |
--repo | yes | Repository in owner/name form |
--pr <n> | one of | Pull-request number |
--issue <n> | one of | Issue number, mutually exclusive with --pr |
--impact <1-10> | yes | Curated impact score |
--featured | no | Show in the home-page strip |
On add, the CLI fetches the PR or issue metadata from the forge, embeds the title and body with fastembed (AllMiniLML6V2, 384 dimensions), and posts the result to /api/admin/activity.
Updating impact or featured
<id> is the numeric entry id shown by plinth activity list.
plinth activity update <id> --impact 9 --featured true
At least one of --impact or --featured is required.
Removing
plinth activity remove <id>
Listing
plinth activity list
The list is read from the public /api/activity endpoint and is already ranked by the server.
Authentication and rate limits
The CLI uses PLINTH_API_URL and PLINTH_API_KEY to publish, update, and remove entries from your Plinth instance.
Public forge data works without forge tokens, but unauthenticated requests are rate-limited. Set GITHUB_TOKEN or CODEBERG_TOKEN when fetching activity to raise GitHub’s limit and reduce throttling. See Environment Variables.
Admin API
All admin endpoints are protected by Bearer token authentication. Set the token via PLINTH_API_KEY.
Authorization: Bearer <your-api-key>
Publish article
POST /api/admin/articles
Content-Type: application/json
Request body (PublishArticleRequest):
| Field | Type | Required | Description |
|---|---|---|---|
title | string | no | Article title (can come from frontmatter) |
slug | string | no | URL slug (auto-generated from title if omitted) |
description | string | no | Meta description |
content | string | yes | Markdown or Typst source content |
html_content | string | no | Pre-rendered HTML (required for Typst) |
tags | string[] | no | Tag names |
author | string | no | Author name (defaults to site author) |
published | bool | no | Visibility (default: true) |
featured | bool | no | Featured flag (default: false) |
embedding | float[] | no | 384-dim fastembed vector |
content_format | string | no | "Markdown" (default) or "Typst" |
Response (200):
{
"success": true,
"slug": "my-article",
"id": "blog_posts:abc123",
"message": "Article 'My Article' published successfully"
}
Error (400):
{
"error": "Title is required",
"details": "Provide title in request or frontmatter"
}
List tags
GET /api/admin/tags
Returns all tags with metadata.
Response (200):
[
{ "id": "tags:abc", "name": "rust", "slug": "rust", "post_count": 5 }
]
Add tag to post
POST /api/admin/posts/{post_slug}/tags
Content-Type: application/json
Request body (AddTagRequest):
{ "tag": "new-tag" }
Creates the tag if it doesn’t exist, then creates a tagged graph relation.
Remove tag from post
DELETE /api/admin/posts/{post_slug}/tags/{tag_slug}
Removes the tagged graph relation. Does not delete the tag itself.
Update site content
PUT /api/admin/content/{key}
Content-Type: application/json
Request body (UpdateSiteContentRequest):
| Field | Type | Description |
|---|---|---|
title | string | Content block title |
content | string | Markdown source |
html_content | string | Rendered HTML |
Upserts the content block — deletes existing entry with the same key and creates a new one.
Get site content
GET /api/admin/content/{key}
Returns the site content block, or null if not found.
Search API
The search API is publicly accessible (no authentication required). It uses fastembed vector embeddings for semantic similarity.
Semantic search
GET /api/search?q=<query>&limit=<n>
| Parameter | Type | Default | Description |
|---|---|---|---|
q | string | required | Search query text |
limit | integer | 10 | Maximum results |
The query text is embedded into a 384-dimensional vector and matched against article embeddings in Postgres using pgvector’s HNSW approximate nearest-neighbour index with cosine distance.
Response (200):
[
{
"post": {
"id": "blog_posts:abc",
"slug": "my-article",
"title": "My Article",
"description": "First 200 characters of content...",
"published_at": "2025-01-15T10:00:00Z",
"author": "Jane Doe",
"tags": ["rust"],
"featured": false,
"reading_time_minutes": 5
},
"similarity": 0.87
}
]
Results are sorted by similarity (highest first). Because HNSW is approximate, very close neighbours may not be returned in perfect exhaustive order.
Related articles
GET /api/articles/{slug}/related?limit=<n>
| Parameter | Type | Default | Description |
|---|---|---|---|
slug | path | required | Source article slug |
limit | integer | 5 | Maximum results |
Finds articles whose embeddings are most similar to the given article’s embedding. Useful for “related posts” sections.
Response: same format as semantic search.
Opinion evolution
GET /api/opinion?topic=<topic>&min_similarity=<threshold>
| Parameter | Type | Default | Description |
|---|---|---|---|
topic | string | required | Topic to track |
min_similarity | float | 0.5 | Minimum similarity threshold |
Returns articles related to the topic sorted chronologically (oldest first), allowing you to see how your writing about a topic has evolved over time. Only articles above the similarity threshold are included.
Response: same format as semantic search, but sorted by published_at date.
Image Proxy
The image proxy serves images from a private Immich instance through Plinth, adding authentication and caching.
Endpoint
GET /api/images/{asset_id}?size=original|preview|thumbnail
| Parameter | Type | Default | Description |
|---|---|---|---|
asset_id | path (UUID) | required | Immich asset ID |
size | query string | original | Image variant |
Size variants
| Size | Description | Immich endpoint |
|---|---|---|
original | Full-resolution image | /api/assets/{id}/original |
preview | Resized preview | /api/assets/{id}/thumbnail?size=preview |
thumbnail | Small thumbnail | /api/assets/{id}/thumbnail?size=thumbnail |
Response headers
Content-Type: <forwarded from Immich>
Cache-Control: public, max-age=31536000, immutable
The 1-year cache duration is configurable via images.cache_max_age in plinth.toml.
Error responses
| Status | Cause |
|---|---|
| 400 | asset_id is not a valid UUID |
| 404 | Asset not found in Immich |
| 502 | Failed to connect to Immich |
| 503 | Image proxy not configured (no IMMICH_API_URL) |
Security
- The
asset_idis validated as a UUID to prevent path traversal attacks - Immich API key is never exposed to the client — Plinth authenticates server-side
- The endpoint is publicly accessible (images are meant to be viewed by readers)
Activity API
Endpoints for curated external contributions: pull requests and issues you landed on other repositories, ranked by impact and recency.
Admin
Admin endpoints require Bearer token authentication with PLINTH_API_KEY.
Authorization: Bearer <your-api-key>
Publish activity
POST /api/admin/activity
Content-Type: application/json
Upserts by the natural key (forge, repo_owner, repo_name, kind, number).
Request body (PublishActivityRequest):
| Field | Type | Required | Description |
|---|---|---|---|
forge | string | yes | "github" or "codeberg" |
repo_owner | string | yes | Repository owner |
repo_name | string | yes | Repository name |
kind | string | yes | "pr" or "issue" |
number | integer | yes | PR or issue number, greater than 0 |
url | string | yes | Canonical forge URL |
title | string | yes | Contribution title |
body | string | no | Contribution body or description |
state | string | yes | "open", "closed", or "merged" |
created_at | string | yes | ISO-8601 creation timestamp |
closed_at | string | no | ISO-8601 close timestamp |
merged_at | string | no | ISO-8601 merge timestamp |
impact | integer | no | Curated impact score, 1..=10, default 1 |
additions | integer | no | Lines added |
deletions | integer | no | Lines deleted |
comments_count | integer | no | Number of comments reported by the forge |
labels | string[] | no | Label names |
repo_stars | integer | no | Repository star count |
embedding | float[] | no | 384-dimensional fastembed vector supplied by the CLI |
featured | bool | no | Show in the home strip, default false |
published | bool | no | Include in public surfaces, default true |
content_hash | string | no | Optional content fingerprint |
Response (200):
{
"success": true,
"url": "https://github.com/owner/repo/pull/1234",
"id": 42,
"message": "Activity published successfully"
}
Update activity
PATCH /api/admin/activity/{id}
Content-Type: application/json
Updates curated fields for a numeric activity id.
| Field | Type | Required | Description |
|---|---|---|---|
impact | integer | no | New impact score, 1..=10 |
featured | bool | no | New featured flag |
published | bool | no | New public visibility flag |
Delete activity
DELETE /api/admin/activity/{id}
Deletes an activity entry by numeric id.
Public
List activity
GET /api/activity?limit=<n>&featured=<bool>
Returns entries ranked by score descending, then reference date descending. Reading a stale entry serves cached data immediately and triggers a single-flighted background refresh.
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | server default | Maximum entries |
featured | bool | omitted | When true, return featured entries only |
Response (200):
[
{
"id": 42,
"forge": "github",
"repo_owner": "owner",
"repo_name": "repo",
"kind": "pr",
"number": 1234,
"url": "https://github.com/owner/repo/pull/1234",
"title": "Improve build output",
"state": "merged",
"impact": 7,
"labels": ["rust"],
"featured": true,
"score": 6.92
}
]
Get activity
GET /api/activity/{id}
Returns one activity entry by numeric id.
Feed
GET /feeds/activity.xml
RSS 2.0 feed for curated external activity. The response is application/rss+xml with Cache-Control: public, max-age=3600; entries link to the forge URL and are ordered by ranking.
Search
Activity entries are unioned into semantic search at GET /api/search. See the Search API.
Dev Environment
Using Nix (recommended)
git clone https://codeberg.org/caniko/plinth.git
cd plinth
nix develop
The dev shell provides:
- Rust nightly with
wasm32-unknown-unknowntarget - cargo-leptos, wasm-bindgen-cli, binaryen
- Tailwind CSS standalone binary
- PostgreSQL 16 with pgvector
- sqlx-cli
- OpenSSL, ONNX Runtime, libclang
- Mold linker (Linux)
- mdBook (for documentation development)
Local database
Start the development Postgres before running the server or integration tests:
./scripts/dev-db.sh start
The dev shell exports PGDATA=$PWD/.dev-pgdata, PGHOST=$PWD/.dev-pgsocket, and DATABASE_URL=postgres://localhost/plinth?host=$PWD/.dev-pgsocket. The lifecycle script initializes the cluster if needed, creates the plinth database, and installs the vector extension.
Other database commands:
./scripts/dev-db.sh stop
./scripts/dev-db.sh reset
pg_ctl requires PGDATA; run these commands inside nix develop unless you set the Postgres environment variables yourself.
Development server
cargo leptos watch
This starts the Axum server with Leptos hot reload at http://127.0.0.1:3000. Changes to Rust source files trigger recompilation of both the server and the WASM client.
Running checks
# Full CI check (build + clippy + fmt + tests)
nix flake check
# Individual commands
cargo fmt --all
cargo clippy --all-targets -- --deny warnings
cargo test --workspace --exclude plinth-client
The client crate is excluded from cargo test because it targets wasm32-unknown-unknown.
Building documentation locally
cd docs
mdbook serve
This starts a local preview server with live reload.
Important build notes
- New files must be
git add-ed beforenix flake checkornix buildcan see them (Nix uses the git index) reqwest::Client::new()panics in Nix sandbox — useClient::builder().build()and handle errorsfastembed::TextEmbedding::try_new()downloads models at runtime and fails in the Nix sandbox — all tests must avoid it- Raw string literals: use
r##"..."##when content contains"#(common with Markdown headings)
Testing
Plinth uses unit tests for pure logic and Postgres-backed integration tests for database behaviour.
Running tests
# All tests and integration tests
cargo test --workspace --all-targets
# Single test
cargo test --package plinth-server test_name
# With output
cargo test --workspace --all-targets -- --nocapture
Test organisation
Unit tests
Unit tests live in #[cfg(test)] modules within source files. Examples:
crates/server/src/config.rs— config parsing and defaultscrates/server/src/api/admin.rs— request construction and error responsescrates/server/src/api/images.rs— Immich URL building and query defaultscrates/server/src/services/markdown_processor.rs— Markdown parsing, slug generationcrates/server/src/services/db.rs— database operationscrates/shared/src/blog_post.rs— reading time calculation
Integration tests
Located in crates/server/tests/. Database-backed integration tests use #[sqlx::test], which creates an isolated database for each test, runs crates/server/migrations/, and passes the test a PgPool.
migration_integration.rs— verifies migrations create the expected tables, pgvector extension, vector column, and HNSW index.db_integration.rs— CRUD for coresite_contentandtags.blog_admin_integration.rs— blog post lifecycle, tag junction rows, denormalized tag cache, ordering, slug uniqueness, and delete cascades.tag_integration.rs— shared tag creation, post/todo tag attachment, counts, and tag-filtered list reads.todo_integration.rs— todo lifecycle, completion, ordering, tagging, slug uniqueness, and delete cascades.
Local Postgres setup
Inside nix develop, start the local Postgres cluster:
./scripts/dev-db.sh start
The dev shell exports DATABASE_URL=postgres://localhost/plinth?host=$PWD/.dev-pgsocket. #[sqlx::test] uses that as the base URL for per-test databases. Outside the dev shell, set DATABASE_URL to a Postgres 16 instance where the test user can create and drop databases and where pgvector is installed.
DATABASE_URL='postgres://localhost/plinth?host=/path/to/socket' \
cargo test -p plinth-server --test blog_admin_integration
Constraints
- No network in Nix sandbox: tests cannot download fastembed models or make HTTP requests
- Client crate: browser-specific code targets
wasm32-unknown-unknown; prefer workspace checks for full coverage - Postgres extensions: pgvector must be installed in the base cluster before integration tests run
Contributing
Code style
- Format with
cargo fmt --all - Lint with
cargo clippy --all-targets -- --deny warnings - Both are enforced in CI
Before submitting
Run the full CI check locally:
nix flake check
This runs build, clippy, formatting, and all tests in one command. It matches exactly what CI runs.
If you’ve added new files, remember to git add them first — Nix only sees files in the git index.
Architecture guidelines
- Shared types go in
plinth-shared— anything used by both server and client - Feature-gate server deps with
#[cfg(feature = "ssr")]— the client compiles to WASM without tokio, axum, etc. - Actor messages should be small and cloneable — actors handle concurrency
- Use parameterized SQLx queries — avoid string-built SQL, keep nullable values explicit, and add stable
ORDER BYclauses for list reads - Keep tag writes transactional — blog and todo tags use junction tables plus denormalized tag caches; mutations that touch both must update them in one transaction
- Write Postgres null checks explicitly — nullable columns use
IS NULL/IS NOT NULL;= NULLnever matches rows - Use
r##"..."##for raw strings containing"#(Markdown headings in SQL)
CI
Woodpecker CI runs on Codeberg for push and PR events to main, poc, and develop branches:
nix flake check— build, clippy, fmtcargo test --workspace --all-features- Release build (main branch only)