Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

codeberg.org/caniko/plinth

Installation

Prerequisites

Plinth builds with Nix flakes. You need:

  • Nix (2.18+) with experimental-features = nix-command flakes
  • Or: Rust nightly, wasm32-unknown-unknown target, 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 sets LEPTOS_SITE_ROOT)
  • result/site/ — compiled WASM, JS, CSS, and static assets
  • result/share/plinth/plinth.toml — example configuration

Build variants

CommandProfileUse case
nix build .#plinthRelease (opt-level 3)Production deployment
nix build .#plinth-devDebugFast compile, debugging
nix build .#plinth-minimalRelease (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

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): PlinthConfig loaded from plinth.toml with 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 generation
  • tag — 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

  1. Browser requests a page
  2. Axum receives the request and invokes Leptos SSR
  3. Leptos server functions fetch data from the cache actors, which read from Postgres on cache misses
  4. Server renders full HTML and streams it to the browser
  5. Browser loads the WASM bundle and hydrates the page for interactivity
  6. Subsequent navigation happens client-side via Leptos router

Data flow: publishing an article

  1. Author writes a .md or .typ file
  2. CLI parses frontmatter, processes content (Markdown to HTML, or Typst compilation)
  3. CLI generates a 384-dimensional fastembed vector embedding
  4. CLI sends POST /api/admin/articles with content, metadata, and embedding
  5. Server stores the article in Postgres, creates tag junction rows, syncs the read-side tag array, and invalidates caches
  6. 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 cached Vec<BlogListItem>
  • GetBlogPost(String) — returns a full BlogPost by slug
  • GetPostsByTag(String) — returns posts filtered by tag slug
  • GetAllPortfolioItems — returns cached portfolio items
  • GetPortfolioItemBySlug(String) — returns a single portfolio item
  • GetAllTodos and GetTodosByTag(String) — return todo list items
  • GetAllTags — returns all tags with post counts
  • GetSiteContent(String) — returns site content by key
  • InvalidateCache — 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 articles
  • FindRelatedArticles { slug, limit } — finds articles related to a given post
  • TrackOpinionEvolution { 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

RouteModeWhy
/Streaming SSR, SsrMode::OutOfOrderThe home page aggregates multiple bricks, so the shell can stream while slower sections resolve.
/aboutSSG, SsrMode::StaticSite content changes only when an admin publishes the about key.
/supportSSG, SsrMode::StaticSite content changes only when an admin publishes the support key.
/postsSSG, SsrMode::StaticBlog index content changes at publish cadence.
/posts/:slugSSG, SsrMode::Static with prerendered slugsPublished posts are addressable static content.
/posts/tag/:tagSSG, SsrMode::Static with prerendered tag names and slugsTag pages change when posts or tags are published.
/seriesSSG, SsrMode::StaticSeries membership changes at blog publish cadence.
/series/:slugSSG, SsrMode::Static with prerendered series slugsSeries pages are derived from published posts.
/projectsSSG, SsrMode::StaticPortfolio index content changes at publish cadence.
/projects/:slugSSG, SsrMode::Static with prerendered project slugsPortfolio entries are stable published content.
/activityDynamic SSR, SsrMode::OutOfOrderActivity is ranked and refreshed at request time.
/activity/:idDynamic SSR, SsrMode::OutOfOrderIndividual activity entries may refresh forge metadata.
/todosDynamic SSR, SsrMode::OutOfOrderTodo ordering and completion state are ranked/user-curated dynamic data.
/todos/tag/:tagDynamic SSR, SsrMode::OutOfOrderTagged todo lists are request-time ranked views.
/todos/:slugDynamic SSR, SsrMode::OutOfOrderTodo 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 writeInvalidated 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 publishMatching 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]

KeyTypeDefaultDescription
namestring"Plinth"Site name in header and page titles
taglinestring"Welcome to my website"Short tagline on the home page
descriptionstring"A personal website"Default meta description
langstring"en"HTML lang attribute
default_themestring"dark"Default colour theme ("dark" or "light")
animated_backgroundstring"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]

KeyTypeDefaultDescription
namestring"Admin"Default author name for articles
emailstring""Email shown in footer (empty = hidden)

[site.social]

KeyTypeDefaultDescription
githubstring""GitHub profile URL (empty = hidden)
gitlabstring""GitLab profile URL
codebergstring""Codeberg profile URL
mastodonstring""Mastodon profile URL
blueskystring""Bluesky profile URL
KeyTypeDefaultDescription
project_namestring"Plinth"Project name in footer attribution
project_urlstring"https://codeberg.org/caniko/plinth"Project URL in footer

Navigation items (order matters). Each entry has:

KeyTypeDescription
labelstringLink text
pathstringURL path

Default navigation:

[[site.nav]]
label = "Posts"
path = "/posts"

[[site.nav]]
label = "Projects"
path = "/projects"

[[site.nav]]
label = "About"
path = "/about"

[pages.home]

KeyTypeDefaultDescription
titlestring""Home page title (empty = use site name)
descriptionstring""Home page meta description

[pages.blog]

KeyTypeDefaultDescription
titlestring"Posts"Blog listing page title
subtitlestring""Subtitle below the title
descriptionstring""Meta description

[pages.portfolio]

KeyTypeDefaultDescription
titlestring"Projects"Portfolio page title
subtitlestring""Subtitle below the title
descriptionstring""Meta description

[pages.about]

KeyTypeDefaultDescription
titlestring"About Me"About page title
descriptionstring""Meta description

[server]

KeyTypeDefaultDescription
hoststring"127.0.0.1"Bind address
portu163000Bind port

[database]

KeyTypeDefaultDescription
database_urlstring"postgres://plinth:plinth@localhost:5432/plinth"Postgres connection URL

[observability]

KeyTypeDefaultDescription
service_namestring"plinth"OTLP service name
log_levelstring"info"Rust log level (RUST_LOG format)
otlp_endpointstring""OTLP endpoint URL (empty = disabled)
otlp_headersstring""OTLP auth headers (comma-separated key=value)
KeyTypeDefaultDescription
default_limitusize10Default search result count
related_limitusize5Default related articles count
min_similarityf320.5Minimum cosine similarity for opinion tracking

[content]

KeyTypeDefaultDescription
words_per_minuteusize200WPM for reading time calculation
vector_truncationusize5000Max characters before generating embeddings

[feeds]

KeyTypeDefaultDescription
blog_limitusize50Max entries in /feeds/blog.xml
projects_limitusize50Max entries in /feeds/projects.xml
activity_limitusize50Max 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.

KeyTypeDefaultDescription
strategystring"exponential""exponential", "linear", or "pure"
half_life_daysfloat365.0Exponential ranking: impact * 0.5 ^ (age_days / half_life_days)
window_daysfloat730.0Linear 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.

KeyTypeDefaultDescription
refresh_ttl_secsinteger3600Refresh an entry in the background when its fetched_at is older than this many seconds
refresh_backoff_secsinteger900Minimum seconds between refresh attempts for an entry that errored
github_base_urlstring"https://api.github.com"GitHub API base URL, overrideable for GitHub Enterprise or testing
codeberg_base_urlstring"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]

KeyTypeDefaultDescription
api_urlstring""Immich server URL (empty = image proxy disabled)

[images]

KeyTypeDefaultDescription
cache_max_ageu6431536000Cache-Control max-age for proxied images (seconds)

[analytics]

KeyTypeDefaultDescription
plausible_domainstring""Site domain tracked by Plausible (empty = disabled)
plausible_script_urlstring""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]

KeyTypeDefaultDescription
enabledboolfalseEnable donation links across the site
cta_textstring""Custom text for end-of-article CTA (default: “If you found this useful, consider supporting my work.”)

Each entry defines a donation platform link:

KeyTypeDefaultDescription
platformstring(required)Platform identifier: "kofi", "github_sponsors", "liberapay", or "custom"
urlstring(required)URL to your profile on the platform
labelstring""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
  • /support page: 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

VariableDefaultDescription
PLINTH_CONFIGplinth.tomlPath to the TOML configuration file

Server

VariableDefaultDescription
LEPTOS_SITE_ADDR127.0.0.1:3000Server bind address (set by Nix wrapper)
LEPTOS_SITE_ROOTtarget/sitePath to compiled site assets (set by Nix wrapper)

Authentication

VariableDefaultDescription
PLINTH_API_KEYdev_api_key_change_in_productionBearer token for admin API endpoints

Database

These override the [database] section in plinth.toml:

VariableTOML keyDescription
PLINTH_DATABASE_URLdatabase.database_urlPostgres connection URL
DATABASE_URLdatabase.database_urlPostgres connection URL

Observability

These override the [observability] section:

VariableTOML keyDescription
RUST_LOGobservability.log_levelLog level filter (e.g. info,plinth=debug)
OTEL_EXPORTER_OTLP_ENDPOINTobservability.otlp_endpointOTLP endpoint URL
OTEL_EXPORTER_OTLP_HEADERSobservability.otlp_headersOTLP auth headers
OTEL_SERVICE_NAMEobservability.service_nameTelemetry service name

Immich

VariableTOML keyDescription
IMMICH_API_URLimmich.api_urlImmich server URL (enables image proxy)
IMMICH_API_KEYImmich 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.

VariableDescription
GITHUB_TOKENGitHub personal access token; raises the rate limit for GitHub API requests
CODEBERG_TOKENCodeberg 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:

VariableTOML keyDescription
PLAUSIBLE_DOMAINanalytics.plausible_domainSite domain tracked by Plausible
PLAUSIBLE_SCRIPT_URLanalytics.plausible_script_urlURL to self-hosted Plausible script

CLI-only

These are used by plinth-cli, not the server:

VariableDefaultDescription
PLINTH_API_URLhttp://localhost:3000Target server URL for CLI operations

Precedence

  1. Environment variables (highest priority)
  2. plinth.toml values
  3. 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

OptionTypeDefaultDescription
instancesattrset{}Named Plinth instances to run
packagepackagepkgs.plinthPlinth package to use
userstring"plinth" for default, otherwise "plinth-<name>"System user
groupstring"plinth" for default, otherwise "plinth-<name>"System group
hoststring"127.0.0.1"Bind address
portport3000Bind port
stateDirpath/var/lib/plinthStateful data directory
apiKeyFilepath or nullnullPath to API key file (loaded via systemd LoadCredential)
site.*Site identity (see plinth.toml)
pages.*Page-specific config (see plinth.toml)
database.namestring"plinth" for default, otherwise "plinth_<name>"Postgres database name
database.urlstringpostgres:///plinth?host=/run/postgresql for defaultPostgres connection URL
observability.enableboolfalseEnable OTLP export
observability.otlpEndpointstring""OTLP endpoint URL
observability.otlpHeadersstring or nullnullOTLP auth headers
observability.serviceNamestring"plinth"Telemetry service name
observability.logLevelstring"info"Log level (RUST_LOG)
search.defaultLimitint10Search result limit
search.relatedLimitint5Related articles limit
search.minSimilarityfloat0.5Min similarity for opinion tracking
content.wordsPerMinuteint200Reading time WPM
content.vectorTruncationint5000Embedding char limit
immich.apiUrlstring""Immich URL (empty = disabled)
immich.apiKeystring""Immich API key
images.cacheMaxAgeint31536000Image cache max-age (seconds)
extraEnvlines""Additional env vars (KEY=value per line)

Systemd hardening

The module applies security hardening by default:

  • NoNewPrivileges, ProtectSystem=strict, ProtectHome
  • PrivateTmp, PrivateDevices, PrivateMounts
  • RestrictAddressFamilies (AF_UNIX, AF_INET, AF_INET6 only)
  • RestrictNamespaces, LockPersonality, RestrictRealtime
  • ReadWritePaths limited to stateDir

Reverse Proxy

Plinth binds to localhost by default. Use a reverse proxy to handle TLS and expose it to the internet.

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.

FieldTypeDefaultDescription
titlestringrequiredArticle title
tagsstring[][]Tag names
descriptionstringMeta description
authorstringsite authorAuthor name
publishedbooltrueWhether the post is visible
featuredboolfalseWhether to feature the post

Publishing

plinth-cli publish article.md

Or during development:

cargo run --package plinth-cli -- publish article.md

The CLI:

  1. Parses YAML frontmatter
  2. Converts Markdown to HTML (pulldown-cmark)
  3. Generates a 384-dim fastembed vector embedding from the text
  4. Sends POST /api/admin/articles with 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:

  1. Extracts comment-based YAML frontmatter
  2. Scans for local image references (#blog-image("local.jpg", ...))
  3. Uploads local images to Immich, receives asset IDs
  4. Replaces local paths with /api/images/{asset_id} proxy URLs
  5. Compiles Typst to HTML via typst-as-lib + typst-html
  6. Generates a fastembed embedding from the text content
  7. 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
ParameterDefaultDescription
asset_idrequiredImmich asset UUID
sizeoriginalImage 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>.

FlagRequiredDescription
--forgeyesgithub or codeberg
--repoyesRepository in owner/name form
--pr <n>one ofPull-request number
--issue <n>one ofIssue number, mutually exclusive with --pr
--impact <1-10>yesCurated impact score
--featurednoShow 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.

<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):

FieldTypeRequiredDescription
titlestringnoArticle title (can come from frontmatter)
slugstringnoURL slug (auto-generated from title if omitted)
descriptionstringnoMeta description
contentstringyesMarkdown or Typst source content
html_contentstringnoPre-rendered HTML (required for Typst)
tagsstring[]noTag names
authorstringnoAuthor name (defaults to site author)
publishedboolnoVisibility (default: true)
featuredboolnoFeatured flag (default: false)
embeddingfloat[]no384-dim fastembed vector
content_formatstringno"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):

FieldTypeDescription
titlestringContent block title
contentstringMarkdown source
html_contentstringRendered 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.

GET /api/search?q=<query>&limit=<n>
ParameterTypeDefaultDescription
qstringrequiredSearch query text
limitinteger10Maximum 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.

GET /api/articles/{slug}/related?limit=<n>
ParameterTypeDefaultDescription
slugpathrequiredSource article slug
limitinteger5Maximum 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>
ParameterTypeDefaultDescription
topicstringrequiredTopic to track
min_similarityfloat0.5Minimum 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
ParameterTypeDefaultDescription
asset_idpath (UUID)requiredImmich asset ID
sizequery stringoriginalImage variant

Size variants

SizeDescriptionImmich endpoint
originalFull-resolution image/api/assets/{id}/original
previewResized preview/api/assets/{id}/thumbnail?size=preview
thumbnailSmall 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

StatusCause
400asset_id is not a valid UUID
404Asset not found in Immich
502Failed to connect to Immich
503Image proxy not configured (no IMMICH_API_URL)

Security

  • The asset_id is 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):

FieldTypeRequiredDescription
forgestringyes"github" or "codeberg"
repo_ownerstringyesRepository owner
repo_namestringyesRepository name
kindstringyes"pr" or "issue"
numberintegeryesPR or issue number, greater than 0
urlstringyesCanonical forge URL
titlestringyesContribution title
bodystringnoContribution body or description
statestringyes"open", "closed", or "merged"
created_atstringyesISO-8601 creation timestamp
closed_atstringnoISO-8601 close timestamp
merged_atstringnoISO-8601 merge timestamp
impactintegernoCurated impact score, 1..=10, default 1
additionsintegernoLines added
deletionsintegernoLines deleted
comments_countintegernoNumber of comments reported by the forge
labelsstring[]noLabel names
repo_starsintegernoRepository star count
embeddingfloat[]no384-dimensional fastembed vector supplied by the CLI
featuredboolnoShow in the home strip, default false
publishedboolnoInclude in public surfaces, default true
content_hashstringnoOptional 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.

FieldTypeRequiredDescription
impactintegernoNew impact score, 1..=10
featuredboolnoNew featured flag
publishedboolnoNew 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.

ParameterTypeDefaultDescription
limitintegerserver defaultMaximum entries
featuredboolomittedWhen 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

git clone https://codeberg.org/caniko/plinth.git
cd plinth
nix develop

The dev shell provides:

  • Rust nightly with wasm32-unknown-unknown target
  • 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 before nix flake check or nix build can see them (Nix uses the git index)
  • reqwest::Client::new() panics in Nix sandbox — use Client::builder().build() and handle errors
  • fastembed::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 defaults
  • crates/server/src/api/admin.rs — request construction and error responses
  • crates/server/src/api/images.rs — Immich URL building and query defaults
  • crates/server/src/services/markdown_processor.rs — Markdown parsing, slug generation
  • crates/server/src/services/db.rs — database operations
  • crates/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 core site_content and tags.
  • 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 BY clauses 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; = NULL never 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:

  1. nix flake check — build, clippy, fmt
  2. cargo test --workspace --all-features
  3. Release build (main branch only)