Serval is a high-performance snippet delivery and templating engine. It
stores templated configurations and text snippets once, addresses them by
content hash, and serves them at the edge with on-the-fly variable
substitution over plain HTTP GET.
Building or modifying Serval with an AI coding agent? Start with AGENTS.md and the docs under docs/agents/.
- Pure content-addressed storage (CAS). Raw templates are stored immutably,
keyed by a signed content id
Base64URL(BLAKE3(content) || keyed-MAC). The same 20KB config uploaded 1,000 times is stored exactly once — absolute byte-level deduplication. - Every snippet is editable. A snippet is an unguessable, signed route you publish new versions to over time. Each version's content is also addressable internally by its content hash — a deterministic, immutable pointer to that exact revision the Data Plane can serve directly — but that is a delivery detail, never a separate user-facing kind of snippet.
- Forgery-proof ids (DoS mitigation). Every id carries a keyed MAC over its prefix, so the Data Plane rejects forged or enumerated ids with a constant-time check before any cache or database lookup. See docs/agents/delivery.md.
- Tolerant templating. Snippets use
{{variable}}placeholders filled from standard query parameters (GET /<id>?port=8080). Unprovided placeholders are left intact as literal text — universally client-compatible, no special request format required. - Versioned history. Every update is recorded in an infinite, append-only ledger so prior content states stay auditable — and any of them can be viewed or restored.
- Embedded dashboard. A React/Vite management UI is bundled into the binary; no separate frontend deployment needed.
Serval runs two HTTP servers over a single PostgreSQL database:
| Plane | Port | Role |
|---|---|---|
| Control Plane | 8080 |
Management API (/api/snippets) + embedded React dashboard. Handles version-controlled writes. |
| Data Plane | 3000 |
Public, extreme-throughput delivery. GET-only, with template substitution and a byte-bounded in-memory moka read-through cache. |
A PATCH on the Control Plane instantly evicts the affected entry from the Data
Plane cache, so updates are reflected on the very next read.
content_blocks— deduplicated, immutable raw payloads, addressed by hash.routes— active, editable snippet pointers at a content hash, with MIME type.pointer_history— append-only audit ledger of every pointer change.
See docs/agents/database.md for the full schema.
- Backend: Rust (Axum), PostgreSQL 16+,
mokacache, BLAKE3 hashing & keyed-MAC route ids. - Frontend: React + Vite + TypeScript, embedded via
rust-embedat build time. - Delivery: stateless by design — no custom telemetry; rely on edge logs.
# 1. Init submodules (agent skills live in .github/skills)
git submodule update --init --recursive
# 2. Run a local PostgreSQL (16+) and point Serval at it
docker compose up -d postgres
export DATABASE_URL=postgres://serval:serval@localhost:5432/serval
# Set the route-id signing secret (required; >= 32 chars, keep it stable)
export ID_SIGNING_SECRET="$(openssl rand -base64 48)"
# 3. Build and run (build.rs builds and embeds frontend/dist/ automatically)
cargo run --release
# Control Plane + UI: http://localhost:8080
# Data Plane: http://localhost:3000The database schema is applied idempotently on startup, so the first run needs no separate migration step. To run the whole stack — app included — in containers instead:
# Pull a published image and run the full stack (uses :stable by default)
docker compose --profile app up
# Or build the app from your local checkout instead of pulling
docker compose --profile build up --buildMulti-arch images (linux/amd64 + native linux/arm64) are published to the
GitHub Container Registry at ghcr.io/btreemap/serval. Two rolling channels
make the stability contract explicit:
| Tag | Channel | When it moves |
|---|---|---|
:stable |
Vetted releases | Published from each v* version tag. Recommended for real deployments. |
:latest |
Cutting edge | Republished on every main build after the PR Quality Gate passes, so community testers can exercise unreleased changes without running un-vetted code. |
:vX.Y.Z, :vX.Y |
Pinned release | Immutable semantic-version tags for reproducible deploys. |
docker pull ghcr.io/btreemap/serval:stable # production
docker pull ghcr.io/btreemap/serval:latest # help us test mainPoint docker compose at a channel with SERVAL_IMAGE_TAG (defaults to
stable):
SERVAL_IMAGE_TAG=latest docker compose --profile app upThe Control Plane speaks JSON under /api. With the default AUTH_MODE=none
every request is the local superuser, so no token is needed for local use.
# Create a snippet holding a template (returns an unguessable, signed id)
curl -s -X POST http://localhost:8080/api/snippets \
-H 'content-type: application/json' \
-d '{"content":"Hello {{name}} on {{port}}"}'
# => {"id":"<id>", ...}
# Fetch it from the Data Plane, substituting variables from the query string
curl "http://localhost:3000/<id>?name=world&port=8080"
# => Hello world on 8080 ({{name}}/{{port}} filled; unknown vars stay literal)
# Publish a new version; the next GET reflects it
curl -s -X PATCH http://localhost:8080/api/snippets/<id> \
-H 'content-type: application/json' -d '{"content":"Goodbye"}'
# Inspect a snippet and its version ledger
curl -s http://localhost:8080/api/snippets/<id>
# => {"id":"<id>","history":[{"target_hash":"<hash>", ...}, ...], ...}
# Restore an earlier version (repoints the snippet, appends a new version)
curl -s -X POST http://localhost:8080/api/snippets/<id>/restore \
-H 'content-type: application/json' -d '{"target_hash":"<hash>"}'
# A version's content is also addressable directly by its hash on the Data
# Plane (a deterministic, immutable pointer to that exact revision)
curl "http://localhost:3000/<hash>"When AUTH_MODE=oauth, send Authorization: Bearer <jwt>; identity comes from
the token's sub, while the admin role is managed locally (see below).
When AUTH_MODE=cloudflare, put Serval's Control Plane behind a Cloudflare Zero
Trust Access application. Cloudflare authenticates each user at the edge and
injects a signed Cf-Access-Jwt-Assertion header, which Serval validates
against your team's published certs — the dashboard signs in transparently with
no token to paste. Identity comes from the token's sub; the admin role is
still managed locally.
Serval is configured entirely through the environment (a local .env is loaded
if present). See .env.example for the full list.
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
required | PostgreSQL connection string |
ID_SIGNING_SECRET |
required | Secret salt (>= 32 chars) keying the route-id MAC; keep stable per deployment |
DATABASE_MAX_CONNECTIONS |
16 |
Pool size |
CONTROL_PLANE_ADDR |
0.0.0.0:8080 |
Management API + UI bind address |
DATA_PLANE_ADDR |
0.0.0.0:3000 |
Delivery bind address |
DATA_PLANE_PUBLIC_URL |
(guessed) | Public Data Plane base URL (e.g. https://cdn.example.com) advertised to the dashboard for "copy link"; unset falls back to :3000 on the dashboard host |
CACHE_BYTE_BUDGET |
33554432 |
Delivery cache size cap, in bytes. Entries are never time-evicted; freshness rests entirely on Control Plane invalidation (both planes share one in-process cache handle) |
AUTH_MODE |
none |
none (local superuser), oauth, or cloudflare |
OAUTH_ISSUER / OAUTH_AUDIENCE / OAUTH_JWKS_URL |
— | Required when AUTH_MODE=oauth |
CLOUDFLARE_TEAM_DOMAIN / CLOUDFLARE_AUDIENCE |
— | Required when AUTH_MODE=cloudflare |
Authorization for writes is owner-or-admin, and the admin role lives in
Serval's own users table rather than in any token claim. Manage it out of band
with the CLI:
serval admin promote <user-id> # grant the admin role
serval admin demote <user-id> # revoke it
serval admin list # list current admins- Quality gate, integration tests, and CI: docs/agents/testing.md
- Database & migration rules: docs/agents/database.md
- Delivery, caching & rendering internals: docs/agents/delivery.md
- Frontend setup: docs/agents/frontend.md
- Engineering standards: docs/agents/engineering-standards.md
The GitHub Actions pipeline is built for least privilege and supply-chain containment:
- Deny-all token default. Every workflow sets
permissions: {}at the top and grants each job only the narrowest scope it needs. - Build code never holds a write token. Jobs that execute third-party code
(
npm install,cargo buildscripts/proc-macros) run read-only. Publishing is split into separate jobs — releases are attached and images are pushed by steps that run no build code — so a poisoned dependency has no release/registry token to exfiltrate. :latestis gated. Images are pushed to:latestonly after the PR Quality Gate passes onmain; the publish workflow never runs on pull requests, so untrusted PR code never sees a registry-write token.- Poison-proof compiler cache. Rust caching is shared for speed, but only
builds on
mainmay write the cache while pull requests restore it read-only — a PR cannot persist a poisoned artifact for a later trusted build.
See LICENSE.