Introduction
Substrukt is a schema-driven CMS built in Rust. You define content types using JSON Schema, edit data through a web UI, and serve it via a REST API. Content is stored as plain JSON files on disk – not in a database – making it easy to version, sync, and inspect.
Why Substrukt
Most CMS options fall into two camps: heavy database-backed systems that require significant infrastructure, or headless CMSes locked behind SaaS platforms. Substrukt takes a different approach:
- Schema-first: Content types are defined as JSON Schema. The UI, validation, and API are all generated from the schema at runtime. No code changes needed to add a new content type.
- Files on disk: Content lives as JSON files in a directory. You can read them, version them in git, or sync them between environments with a tar.gz bundle. SQLite is only used for infrastructure (users, sessions, API tokens).
- Single binary: One Rust binary handles everything – the web UI, REST API, file storage, and background jobs. No external services required beyond the filesystem.
- Minimal frontend: The UI is server-rendered with htmx for interactivity and twind for styling. No build step, no node_modules, no bundler. Includes dark mode with a theme toggle.
What it does
- You create schemas that describe your content types (blog posts, settings, pages, etc.)
- The CMS generates forms from those schemas for editing content through the web UI
- Content is saved as JSON files on disk and cached in memory for fast reads
- A REST API serves the content to your frontend, static site generator, or mobile app
- Import/export bundles let you sync content between local and production environments
- Webhooks can trigger rebuilds of your frontend when content changes
Core concepts
| Concept | Description |
|---|---|
| Schema | A JSON Schema document with an x-substrukt extension that defines a content type |
| Content entry | A JSON object conforming to a schema, stored as a file on disk |
| Upload | A file (image, document, etc.) stored with content-addressed deduplication |
| Bundle | A tar.gz archive containing all schemas, content, and uploads for syncing |
| API token | A bearer token for authenticating API requests |
Getting Started
Build from source
Substrukt requires Rust nightly (2026-01-05 or later).
git clone https://github.com/wavefunk/substrukt.git
cd substrukt
cargo build --release
The binary is at ./target/release/substrukt.
Run it
./target/release/substrukt serve
Or during development:
cargo run -- serve
The server starts on http://localhost:3000 by default.
First-run setup
On your first visit, Substrukt redirects you to /setup where you create an admin account. This only happens once – when there are no users in the database.
- Open
http://localhost:3000in your browser - Enter a username and password on the setup page
- You are logged in and redirected to the dashboard
Create your first schema
Navigate to Schemas in the sidebar and click New Schema. Paste in a JSON Schema definition:
{
"x-substrukt": {
"title": "Blog Posts",
"slug": "blog-posts",
"storage": "directory"
},
"type": "object",
"properties": {
"title": { "type": "string", "title": "Title" },
"body": { "type": "string", "format": "textarea", "title": "Body" },
"published": { "type": "boolean", "title": "Published" }
},
"required": ["title"]
}
Click Save. The new content type appears in the sidebar.
Create content
Click Blog Posts in the sidebar. Click New Entry. Fill in the form fields that were generated from your schema and save. The entry is stored as a JSON file at data/content/blog-posts/<id>.json.
Access via API
Create an API token in Settings > API Tokens. Use it to fetch content:
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/v1/content/blog-posts
Configuration
All configuration is passed as CLI flags. There are no config files or environment variables for server settings.
CLI flags
| Flag | Default | Description |
|---|---|---|
--data-dir <PATH> | data | Root directory for schemas, content, uploads, and databases |
--db-path <PATH> | <data-dir>/substrukt.db | Path to the main SQLite database |
-p, --port <PORT> | 3000 | HTTP listen port |
--secure-cookies | off | Set the Secure flag on session cookies (required for HTTPS) |
--staging-webhook-url <URL> | none | Webhook URL fired automatically when content changes |
--staging-webhook-auth-token <TOKEN> | none | Bearer token sent with staging webhook requests |
--production-webhook-url <URL> | none | Webhook URL fired on manual publish |
--production-webhook-auth-token <TOKEN> | none | Bearer token sent with production webhook requests |
--webhook-check-interval <SECONDS> | 300 | How often (in seconds) to check if the staging webhook should fire |
Commands
Substrukt has four commands. If no command is specified, serve is the default.
substrukt serve # Start the web server
substrukt import <path.tar.gz> # Import a content bundle
substrukt export <path.tar.gz> # Export a content bundle
substrukt create-token <name> # Create an API token from the command line
serve
Starts the web server. All flags listed above apply.
substrukt serve --port 8080 --data-dir /var/lib/substrukt --secure-cookies
import
Imports a tar.gz bundle into the data directory. Overwrites existing schemas and content. Validates all imported content against its schema and prints warnings for any validation errors.
substrukt import backup.tar.gz --data-dir /var/lib/substrukt
export
Exports all schemas, content, and uploads into a tar.gz bundle.
substrukt export backup.tar.gz --data-dir /var/lib/substrukt
create-token
Creates an API token without starting the server. Requires at least one user to exist (run the server and complete setup first).
substrukt create-token "CI deploy"
The raw token is printed to stdout. Save it – it cannot be retrieved again.
Logging
Substrukt uses the RUST_LOG environment variable for log filtering. The default level is substrukt=info,tower_http=info.
# Debug logging
RUST_LOG=substrukt=debug ./substrukt serve
# Trace everything
RUST_LOG=trace ./substrukt serve
# Only errors
RUST_LOG=error ./substrukt serve
The server listens on 0.0.0.0 (all interfaces) by default. The listen address is not configurable via CLI – bind to a specific interface using a reverse proxy.
Schemas
Schemas are the core building block of Substrukt. Each schema defines a content type – its fields, validation rules, and storage behavior. Schemas are standard JSON Schema documents with an x-substrukt extension for CMS-specific metadata.
Schema structure
A schema has two parts:
x-substrukt– metadata that tells Substrukt how to handle this content type- Standard JSON Schema – defines the fields, types, and validation rules
{
"x-substrukt": {
"title": "Blog Posts",
"slug": "blog-posts",
"storage": "directory",
"kind": "collection"
},
"type": "object",
"properties": {
"title": { "type": "string", "title": "Title" },
"body": { "type": "string", "format": "textarea", "title": "Body" },
"category": {
"type": "string",
"title": "Category",
"enum": ["tech", "design", "business"]
},
"cover": { "type": "string", "format": "upload", "title": "Cover Image" },
"published": { "type": "boolean", "title": "Published" }
},
"required": ["title"]
}
x-substrukt metadata
| Field | Required | Default | Description |
|---|---|---|---|
title | yes | – | Display name shown in the UI sidebar and headings |
slug | yes | – | URL-safe identifier used in file paths and API endpoints |
storage | no | directory | How content is stored on disk: directory or single-file |
kind | no | collection | Whether the schema holds multiple entries (collection) or a single document (single) |
id_field | no | auto-detected | Which field to use for generating entry IDs in directory mode |
Slug rules
Slugs must:
- Contain only lowercase letters, digits, hyphens, and underscores
- Not start with a hyphen or dot
- Not contain
.. - Be at most 128 characters
Entry ID generation
In directory mode, each entry needs a filename. Substrukt generates IDs using this logic:
- If
id_fieldis set inx-substrukt, use the value of that field (slugified) - Otherwise, find the first
stringproperty (that isn’t an upload) and slugify its value - If neither works, generate a UUID
Managing schemas
Via the web UI
- Go to Schemas in the sidebar
- Click New Schema to create, or click a schema’s Edit button
- Edit the JSON Schema in the interactive editor (powered by vanilla-jsoneditor)
- Click Save
The schema is validated before saving. Errors are displayed inline.
Via the filesystem
Schemas are stored as JSON files at data/schemas/<slug>.json. You can create, edit, or delete them directly on disk. Changes are picked up automatically by the file watcher.
Via the API
Schemas are read-only through the API (list and get). To create or modify schemas programmatically, use the import/export mechanism.
Validation
When saving a schema, Substrukt validates:
- The
x-substruktextension is present with a title and slug - The slug is valid (see rules above)
- The document is valid JSON Schema (compiled with
jsonschema)
Content is validated against the schema on every create and update, both through the UI and the API.
Field Types
Substrukt generates UI form elements from JSON Schema property definitions. Each combination of type and format maps to a specific HTML input.
Supported types
| JSON Schema | Format | UI Element | Stored as |
|---|---|---|---|
"type": "string" | (none) | Text input | "value" |
"type": "string" | "textarea" | Multi-line textarea | "value" |
"type": "string" | "upload" | File input | {"hash": "...", "filename": "...", "mime": "..."} |
"type": "string" + "enum" | (none) | Select dropdown | "value" |
"type": "number" | (none) | Number input (decimal) | 1.5 |
"type": "integer" | (none) | Number input (whole) | 42 |
"type": "boolean" | (none) | Checkbox | true / false |
"type": "object" | (none) | Nested fieldset | { ... } |
"type": "array" | (none) | Repeatable field group | [ ... ] |
String fields
A basic text input:
{
"title": { "type": "string", "title": "Title" }
}
Textarea
For longer text content, use format: "textarea":
{
"body": { "type": "string", "format": "textarea", "title": "Body" }
}
This renders as a multi-line textarea with 6 rows.
Enum (select dropdown)
Add an enum array to create a dropdown:
{
"category": {
"type": "string",
"title": "Category",
"enum": ["tech", "design", "business"]
}
}
Upload
Use format: "upload" for file fields:
{
"cover": { "type": "string", "format": "upload", "title": "Cover Image" }
}
Upload fields are stored as objects, not strings. See File Uploads for details.
Number fields
{
"price": { "type": "number", "title": "Price" },
"quantity": { "type": "integer", "title": "Quantity" }
}
number allows decimals (step="any"), integer is whole numbers only (step="1").
Boolean fields
{
"published": { "type": "boolean", "title": "Published" }
}
Rendered as a checkbox. A hidden input ensures false is submitted when unchecked.
Object fields (nested)
Objects render as a bordered fieldset containing the nested properties:
{
"author": {
"type": "object",
"title": "Author",
"properties": {
"name": { "type": "string", "title": "Name" },
"email": { "type": "string", "title": "Email" }
}
}
}
Form field names use dot notation: author.name, author.email.
Array fields (repeatable)
Arrays render as a list of items with “Add Item” and “Remove” buttons:
{
"tags": {
"type": "array",
"title": "Tags",
"items": {
"type": "object",
"properties": {
"name": { "type": "string", "title": "Tag Name" }
}
}
}
}
Form field names use bracket notation: tags[0].name, tags[1].name.
Items can be added and removed dynamically in the browser. The form template supports any nesting depth.
Required fields
Add field names to the required array to mark them as mandatory:
{
"type": "object",
"properties": {
"title": { "type": "string", "title": "Title" },
"body": { "type": "string", "format": "textarea", "title": "Body" }
},
"required": ["title"]
}
Required fields show an asterisk (*) in the UI and have the HTML required attribute.
Title property
The title property on any field controls its label in the UI. If omitted, the property key is used as the label.
Storage Modes
Substrukt supports two storage modes for content, configured in the schema’s x-substrukt.storage field.
Directory mode (default)
{
"x-substrukt": {
"storage": "directory"
}
}
Each entry is stored as a separate JSON file:
data/content/blog-posts/
my-first-post.json
another-post.json
some-uuid-here.json
The filename (without .json) is the entry ID. IDs are generated from the content using the entry ID generation rules.
Directory mode is best for:
- Content types with many entries
- Entries that change independently
- Cases where you want to see individual files in git history
Single-file mode
{
"x-substrukt": {
"storage": "single-file"
}
}
All entries are stored as a JSON array in a single file:
data/content/faq.json
The file contains:
[
{
"_id": "uuid-1",
"question": "How does this work?",
"answer": "..."
},
{
"_id": "uuid-2",
"question": "Is it free?",
"answer": "..."
}
]
Each entry gets an _id field injected automatically (UUID). The _id field is hidden from forms.
Single-file mode is best for:
- Small lists (FAQ items, navigation links, feature lists)
- Content that is always loaded together
- Ordered collections where all items belong in one file
Single vs Collection
The kind field in x-substrukt controls whether a schema represents a single document or a collection of entries.
Collection (default)
{
"x-substrukt": {
"kind": "collection"
}
}
A collection holds multiple entries. The UI shows a list of entries with “New Entry” and “Delete” actions. The API supports full CRUD:
GET /api/v1/content/:slug– list all entriesPOST /api/v1/content/:slug– create an entryGET /api/v1/content/:slug/:id– get one entryPUT /api/v1/content/:slug/:id– update one entryDELETE /api/v1/content/:slug/:id– delete one entry
Use collections for: blog posts, products, team members, FAQ items.
Single
{
"x-substrukt": {
"kind": "single"
}
}
A single schema holds exactly one document. The UI skips the list view and goes directly to the edit form. There is no “New” or “Delete” button – you just edit the one document.
The API uses a different endpoint pattern:
GET /api/v1/content/:slug/single– get the documentPUT /api/v1/content/:slug/single– create or update the documentDELETE /api/v1/content/:slug/single– delete the document
Creating entries via POST /api/v1/content/:slug returns a 400 error for single schemas.
Use singles for: site settings, homepage content, about page, footer configuration.
Example
{
"x-substrukt": {
"title": "Site Settings",
"slug": "site-settings",
"kind": "single"
},
"type": "object",
"properties": {
"site_name": { "type": "string", "title": "Site Name" },
"tagline": { "type": "string", "title": "Tagline" },
"logo": { "type": "string", "format": "upload", "title": "Logo" },
"analytics_id": { "type": "string", "title": "Analytics ID" }
},
"required": ["site_name"]
}
This creates a single editable document for site-wide settings, accessed in the sidebar as “Site Settings” and via the API at /api/v1/content/site-settings/single.
Content Management
The dashboard
The dashboard (/) shows a summary: total schema count and total entry count across all content types. Content types are listed in the sidebar for quick navigation.
Working with entries
Creating an entry
- Click a content type in the sidebar
- Click New Entry
- Fill in the form (generated from the schema)
- Click Save
For schemas with kind: "single", clicking the content type in the sidebar goes directly to the edit form.
Editing an entry
- Click a content type in the sidebar
- Click Edit next to the entry
- Modify the fields
- Click Save
Deleting an entry
- Click a content type in the sidebar
- Click Delete next to the entry
- The entry is removed immediately (no confirmation dialog in the current UI)
Entry list
The entry list shows up to 4 columns from the schema’s properties. It picks the first 4 fields that are simple types (string, number, integer, boolean) – upload and nested fields are excluded from the list view.
How content is stored
Content is stored as plain JSON files on disk. The file structure depends on the storage mode:
- Directory mode:
data/content/<slug>/<entry-id>.json - Single-file mode:
data/content/<slug>.json
You can edit these files directly on disk. A file watcher detects changes and updates the in-memory cache automatically.
Content validation
Every create and update operation validates the content against its schema. Validation runs both in the UI and via the API. Errors are displayed inline in the form or returned as a JSON array in API responses.
Upload fields are treated specially during validation: the schema says "type": "string" but the stored value is an object ({hash, filename, mime}). Substrukt patches the schema at validation time to accept either format.
In-memory cache
Content is loaded into a DashMap (concurrent hash map) on startup. The cache is keyed by <schema-slug>/<entry-id>. Cache updates happen:
- On create/update/delete via the UI or API
- On file system changes detected by the file watcher
- On import (full cache rebuild)
The cache is used for the dashboard entry counts and content gauges in Prometheus metrics.
Flash messages
Success messages (“Entry created”, “Schema updated”) appear as flash notifications after actions. They are stored in the session and consumed on the next page load.
File Uploads
Substrukt supports file uploads through a custom format: "upload" extension to JSON Schema. Uploaded files are stored using content-addressed storage with SHA-256 hashing, which provides automatic deduplication.
Defining upload fields
Add an upload field to your schema:
{
"cover_image": {
"type": "string",
"format": "upload",
"title": "Cover Image"
}
}
This renders as a file input in the form. When a file is uploaded, it is stored on disk and the field value becomes an object:
{
"cover_image": {
"hash": "a1b2c3d4e5f6...",
"filename": "photo.jpg",
"mime": "image/jpeg"
}
}
How storage works
- The file is hashed with SHA-256
- The hash determines the storage path:
data/uploads/<first-2-hex>/<remaining-hex> - Upload metadata (hash, filename, MIME type, size) is stored in SQLite
- If an identical file already exists (same hash), the upload is deduplicated – only one copy is kept
File serving
Uploads are served at:
/uploads/file/<hash>/<filename>
The filename in the URL is for display purposes. The hash is what identifies the file. The correct Content-Type header is set from the stored MIME type.
Upload serving is public (no authentication required), so uploaded files can be referenced directly from frontend applications.
Editing entries with uploads
When editing an entry that already has an upload:
- The current file is shown as a link
- A hidden input preserves the current upload reference
- If you upload a new file, it replaces the reference
- If you leave the file input empty, the existing upload is kept
Upload references
Substrukt tracks which content entries reference which uploads via the upload_references table in SQLite. This mapping is maintained automatically on content create, update, and delete.
Upload reference tracking enables:
- Knowing which entries use a given file
- Cleaning up references when entries are deleted
Filename sanitization
Uploaded filenames are sanitized:
- Path components are stripped (no directory traversal)
- Unsafe characters are replaced with underscores
- Leading dots are removed (no hidden files)
- Empty filenames default to “upload”
API uploads
Files can also be uploaded via the API. The maximum upload size is 50 MB.
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@photo.jpg" \
http://localhost:3000/api/v1/uploads
Response:
{
"hash": "a1b2c3d4...",
"filename": "photo.jpg",
"mime": "image/jpeg",
"size": 245760
}
Use the returned hash when creating content entries via the API. See the Uploads API for details.
Import and Export
Substrukt supports exporting all content as a tar.gz bundle and importing it on another instance. This enables workflows where you edit content locally and push it to production, or back up an instance.
What’s included in a bundle
A bundle contains:
schemas/– all JSON Schema filescontent/– all content entries (directory and single-file)uploads/– all uploaded files (content-addressed)uploads-manifest.json– upload metadata (filenames, MIME types, sizes)
The bundle does not include users, sessions, API tokens, or audit logs – only content data.
CLI usage
Export
substrukt export backup.tar.gz
Creates a tar.gz bundle at the specified path with all schemas, content, and uploads.
Import
substrukt import backup.tar.gz
Extracts the bundle into the data directory. Import behavior:
- Schemas, content, and uploads are unpacked (overwrite strategy)
- Upload metadata is imported into SQLite from the manifest
- Upload references are rebuilt by scanning all content files
- All imported content is validated against its schema
- Validation warnings are printed (import proceeds even if content doesn’t validate)
API usage
Export via API
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
http://localhost:3000/api/v1/export \
-o backup.tar.gz
Returns a application/gzip response with the bundle.
Import via API
curl -X POST \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "bundle=@backup.tar.gz" \
http://localhost:3000/api/v1/import
Response:
{
"status": "ok",
"warnings": []
}
The warnings array contains any content validation issues found during import.
After an API import, the in-memory cache is fully rebuilt.
Sync workflow
A typical sync workflow with CI:
- Run Substrukt locally, edit content
- Export a bundle:
substrukt export bundle.tar.gz - Commit the bundle to your git repository
- In CI, import the bundle on the production instance:
curl -X POST \
-H "Authorization: Bearer $DEPLOY_TOKEN" \
-F "bundle=@bundle.tar.gz" \
https://cms.example.com/api/v1/import
This approach keeps content in version control and makes deployments reproducible.
Legacy format support
Older versions of Substrukt stored upload metadata as .meta.json sidecar files next to each upload. When importing a bundle with sidecar files (no uploads-manifest.json), Substrukt automatically migrates them to SQLite and deletes the sidecar files.
Webhooks
Substrukt can fire webhooks when content changes, enabling automatic rebuilds of your frontend. There are two webhook environments: staging and production.
Configuration
Configure webhook URLs via CLI flags:
substrukt serve \
--staging-webhook-url https://api.example.com/hooks/staging-build \
--staging-webhook-auth-token "$STAGING_WEBHOOK_TOKEN" \
--production-webhook-url https://api.example.com/hooks/production-deploy \
--production-webhook-auth-token "$PRODUCTION_WEBHOOK_TOKEN" \
--webhook-check-interval 300
| Flag | Description |
|---|---|
--staging-webhook-url | URL to POST when content changes are detected |
--staging-webhook-auth-token | Bearer token sent with staging webhook requests |
--production-webhook-url | URL to POST on manual publish |
--production-webhook-auth-token | Bearer token sent with production webhook requests |
--webhook-check-interval | Seconds between dirty-checks for staging (default: 300) |
When an auth token is configured, Substrukt sends it as an Authorization: Bearer <token> header with each webhook request.
How it works
Staging (automatic)
A background task runs on a timer (default: every 5 minutes). It checks whether any content mutations (create, update, delete for content or schemas) have occurred since the last webhook fire. If the staging environment is “dirty”, the webhook fires automatically.
The dirty check compares the timestamp of the last webhook fire against the most recent mutation in the audit log. Non-mutation events (logins, exports) do not trigger the webhook.
Production (manual)
Production webhooks are never fired automatically. They require an explicit action:
- Via the UI: Click the “Publish” button on the dashboard
- Via the API:
POST /api/v1/publish/productionwith a bearer token
Webhook payload
When fired, Substrukt sends a POST request with a JSON body:
{
"event_type": "substrukt-publish",
"environment": "staging",
"triggered_at": "2026-03-13T10:30:00+00:00",
"triggered_by": "cron"
}
| Field | Values |
|---|---|
event_type | Always "substrukt-publish" |
environment | "staging" or "production" |
triggered_at | ISO 8601 timestamp |
triggered_by | "cron" (automatic) or "manual" (UI/API) |
Staging and production are independent
Each environment tracks its dirty state separately. Firing the staging webhook does not affect the production dirty state, and vice versa. This means content can be previewed on staging before being published to production.
Error handling
- If the webhook URL returns a non-2xx status, the error is logged and the dirty state is not cleared (the webhook will be retried on the next check)
- If no webhook URL is configured for an environment, the publish action returns an error
- Webhook fires are logged in the audit log with success/failure status
Using with CI/CD
A common pattern is pointing the staging webhook at a CI pipeline that rebuilds and deploys a preview site.
GitHub Actions
To trigger a GitHub Actions repository_dispatch workflow, configure the webhook URL and a personal access token (classic with repo scope, or fine-grained with Contents write permission):
substrukt serve \
--staging-webhook-url https://api.github.com/repos/org/site/dispatches \
--staging-webhook-auth-token "$GITHUB_PAT"
Substrukt’s payload already includes event_type: "substrukt-publish", which GitHub uses to match workflows:
# .github/workflows/deploy.yml
on:
repository_dispatch:
types: [substrukt-publish]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Environment: ${{ github.event.client_payload.environment }}"
The environment and triggered_by fields are available in the payload for conditional logic.
API Authentication
All API endpoints under /api/v1/ require a bearer token in the Authorization header.
Creating tokens
Via the UI
- Log in to the web interface
- Go to Settings > API Tokens
- Enter a name for the token and click Create
- Copy the displayed token immediately – it is shown only once
Via the CLI
substrukt create-token "My token name"
Prints the raw token to stdout. Requires at least one user to exist.
Using tokens
Include the token in the Authorization header:
curl -H "Authorization: Bearer YOUR_TOKEN" \
http://localhost:3000/api/v1/schemas
Token storage
Tokens are hashed with SHA-256 before storage. The raw token is never stored – only the hash. This means:
- Lost tokens cannot be recovered; create a new one
- Token names are for your reference only
Managing tokens
Tokens are scoped to the user who created them. Each user can:
- View their own tokens (name and creation date)
- Delete their own tokens
Token management is available at Settings > API Tokens in the UI.
Rate limiting
API requests are rate-limited to 100 requests per minute per IP address. When the limit is exceeded, the API returns:
HTTP/1.1 429 Too Many Requests
{
"error": "Rate limit exceeded"
}
The rate limiter uses a sliding window per IP, determined by the X-Forwarded-For header (for requests behind a reverse proxy) or falls back to a default identifier.
Error responses
| Status | Meaning |
|---|---|
401 Unauthorized | Missing or invalid bearer token |
404 Not Found | Schema or entry not found |
429 Too Many Requests | Rate limit exceeded |
400 Bad Request | Invalid request body or validation errors |
500 Internal Server Error | Server-side error |
Schemas API
Read-only access to schema definitions.
List schemas
GET /api/v1/schemas
Returns all schemas.
Response
[
{
"title": "Blog Posts",
"slug": "blog-posts",
"storage": "directory",
"kind": "collection",
"schema": { ... }
},
{
"title": "Site Settings",
"slug": "site-settings",
"storage": "directory",
"kind": "single",
"schema": { ... }
}
]
Each object includes the full JSON Schema document in the schema field.
Example
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/schemas
Get a schema
GET /api/v1/schemas/:slug
Returns the full JSON Schema document for a single schema.
Response
{
"x-substrukt": {
"title": "Blog Posts",
"slug": "blog-posts",
"storage": "directory"
},
"type": "object",
"properties": {
"title": { "type": "string", "title": "Title" },
"body": { "type": "string", "format": "textarea" }
},
"required": ["title"]
}
Example
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/schemas/blog-posts
Returns 404 if the schema does not exist.
Content API
Full CRUD for content entries. Endpoints differ slightly for collection vs single schemas.
Collection endpoints
List entries
GET /api/v1/content/:schema_slug
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/content/blog-posts
Response:
[
{
"id": "my-first-post",
"data": {
"title": "My First Post",
"body": "Hello world",
"published": true
}
}
]
Get an entry
GET /api/v1/content/:schema_slug/:entry_id
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/content/blog-posts/my-first-post
Response – the entry data directly (no wrapper):
{
"title": "My First Post",
"body": "Hello world",
"published": true
}
Returns 404 if the entry does not exist.
Create an entry
POST /api/v1/content/:schema_slug
Content-Type: application/json
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "New Post", "body": "Content here"}' \
http://localhost:3000/api/v1/content/blog-posts
Response (201 Created):
{
"id": "new-post"
}
The entry ID is generated from the content (see entry ID generation).
Validation errors return 400:
{
"errors": ["title: \"title\" is a required property"]
}
Update an entry
PUT /api/v1/content/:schema_slug/:entry_id
Content-Type: application/json
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Updated Post", "body": "New content", "published": true}' \
http://localhost:3000/api/v1/content/blog-posts/new-post
Returns 200 OK on success.
Delete an entry
DELETE /api/v1/content/:schema_slug/:entry_id
curl -X DELETE \
-H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/content/blog-posts/new-post
Returns 204 No Content on success.
Single endpoints
For schemas with kind: "single", use the /single endpoints instead:
Get
GET /api/v1/content/:schema_slug/single
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/content/site-settings/single
Create or update
PUT /api/v1/content/:schema_slug/single
Content-Type: application/json
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"site_name": "My Site", "tagline": "A great site"}' \
http://localhost:3000/api/v1/content/site-settings/single
Delete
DELETE /api/v1/content/:schema_slug/single
Working with uploads in content
When creating or updating content that includes upload fields, use the upload hash reference format:
{
"title": "Post with Image",
"cover": {
"hash": "a1b2c3d4e5f6...",
"filename": "photo.jpg",
"mime": "image/jpeg"
}
}
Upload the file first via the Uploads API, then use the returned hash in your content.
Uploads API
Upload and retrieve files via the API.
Upload a file
POST /api/v1/uploads
Content-Type: multipart/form-data
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-F "file=@photo.jpg" \
http://localhost:3000/api/v1/uploads
Response:
{
"hash": "a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890",
"filename": "photo.jpg",
"mime": "image/jpeg",
"size": 245760
}
Use the hash value when referencing this upload in content entries.
If the same file is uploaded again (identical content, same SHA-256 hash), the existing file is reused. Only one copy is stored on disk.
Download a file
GET /api/v1/uploads/:hash
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/uploads/a1b2c3d4e5f67890... \
-o photo.jpg
Returns the file with the correct Content-Type header.
Returns 404 if no upload with that hash exists.
Public file access
Uploads are also available without authentication at:
/uploads/file/:hash/:filename
This public URL is used by the web UI to display uploaded images and link to files. The filename in the URL is cosmetic – the hash is what identifies the file.
Sync API
Export and import content bundles via the API.
Export
POST /api/v1/export
Downloads a tar.gz bundle containing all schemas, content, and uploads.
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/export \
-o backup.tar.gz
Response headers:
Content-Type: application/gzip
Content-Disposition: attachment; filename="bundle.tar.gz"
Import
POST /api/v1/import
Content-Type: multipart/form-data
Uploads and extracts a tar.gz bundle, replacing existing content.
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-F "bundle=@backup.tar.gz" \
http://localhost:3000/api/v1/import
Response:
{
"status": "ok",
"warnings": [
"blog-posts/bad-entry: /title: \"title\" is a required property"
]
}
The warnings array lists any content validation issues found during import. The import proceeds regardless of validation warnings – data is still imported.
After import:
- Upload metadata is synced to SQLite (from manifest or legacy sidecars)
- Upload references are rebuilt from content files
- The in-memory cache is fully rebuilt
See Import and Export for the full sync workflow.
Publish API
Trigger webhook notifications to rebuild your frontend.
Trigger a publish
POST /api/v1/publish/:environment
Fires the configured webhook for the specified environment.
| Environment | Webhook flag | Behavior |
|---|---|---|
staging | --staging-webhook-url | Also fired automatically by the background cron |
production | --production-webhook-url | Only fired manually via this endpoint or the UI |
Example
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/v1/publish/production
Responses
Success:
{
"status": "triggered"
}
Webhook URL not configured:
404 Not Found
{
"error": "Webhook URL not configured"
}
Webhook endpoint returned an error:
502 Bad Gateway
{
"error": "Webhook returned HTTP 500"
}
Invalid environment (not “staging” or “production”):
404 Not Found
{
"error": "Unknown environment"
}
See Webhooks for details on how the webhook system works.
Deployment
Docker
The recommended way to deploy Substrukt in production.
Build the image
docker build -t substrukt .
The Dockerfile uses a multi-stage build:
- Builder stage: Compiles the Rust binary with
cargo build --releaseusing a nightly Rust image. Dependencies are cached separately for faster rebuilds. - Runtime stage: Copies the binary and templates into a minimal Debian image with only
ca-certificatesinstalled.
Run the container
docker run -p 3000:3000 -v substrukt-data:/data substrukt
All persistent data is stored in the /data volume:
substrukt.db– users, sessions, API tokensaudit.db– audit logschemas/– JSON Schema filescontent/– content entriesuploads/– uploaded files
Configuration
Override defaults with command arguments:
docker run -p 8080:8080 \
-v substrukt-data:/data \
substrukt serve \
--data-dir /data \
--port 8080 \
--secure-cookies \
--staging-webhook-url https://api.example.com/hooks/build
Docker Compose example
services:
substrukt:
build: .
ports:
- "3000:3000"
volumes:
- substrukt-data:/data
command: >
serve
--data-dir /data
--db-path /data/substrukt.db
--port 3000
--secure-cookies
restart: unless-stopped
volumes:
substrukt-data:
Binary deployment
For environments without Docker:
cargo build --release
Copy the binary and templates directory to your server:
/opt/substrukt/
substrukt # binary
templates/ # template files
Run it:
/opt/substrukt/substrukt serve \
--data-dir /var/lib/substrukt \
--secure-cookies
Systemd service
[Unit]
Description=Substrukt CMS
After=network.target
[Service]
Type=simple
User=substrukt
ExecStart=/opt/substrukt/substrukt serve \
--data-dir /var/lib/substrukt \
--secure-cookies
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Reverse proxy
Substrukt listens on 0.0.0.0:3000 by default. In production, place it behind a reverse proxy for TLS termination.
Nginx example
server {
listen 443 ssl;
server_name cms.example.com;
ssl_certificate /etc/letsencrypt/live/cms.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cms.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100M;
}
}
When using HTTPS, pass --secure-cookies to Substrukt so that session cookies have the Secure flag set.
Production checklist
- Use
--secure-cookieswhen behind HTTPS - Set
X-Forwarded-Forheader in your reverse proxy (used for rate limiting) - Mount persistent storage for
/data(Docker) or--data-dir(binary) - Back up
substrukt.dbregularly (contains users and tokens) - Set
RUST_LOG=substrukt=infofor production logging level - Configure webhook URLs if using the publish workflow
Security
Authentication
Web UI
The web UI uses session-based authentication with cookies. Sessions are stored in SQLite via tower-sessions-sqlx-store.
- First visit redirects to
/setupif no users exist - Subsequent visits redirect to
/loginif not authenticated - Sessions are managed via secure cookies (when
--secure-cookiesis enabled)
API
API endpoints use bearer token authentication. Tokens are generated as 32 random bytes (hex-encoded, 64 characters) and stored as SHA-256 hashes. The raw token is shown once at creation time and never stored.
Public paths
The following paths do not require authentication:
/loginand/setup– authentication pages/api/v1/*– API routes (use bearer tokens instead)/uploads/file/*– public file serving/metrics– Prometheus metrics endpoint
CSRF protection
All mutating requests (POST, PUT, DELETE) through the web UI require a valid CSRF token. The token is:
- Generated per-session and stored in the session store
- Included in forms as a hidden
_csrffield - Sent in
X-CSRF-Tokenheader for JavaScript (fetch/DELETE) requests
CSRF verification flow:
- GET/HEAD/OPTIONS requests pass through
- For URL-encoded forms: the
_csrffield is extracted and verified - For JavaScript requests: the
X-CSRF-Tokenheader is checked - For multipart forms: handlers verify the
_csrffield from parsed form data - API routes (under
/api/v1/) use bearer tokens and are exempt from CSRF
Invalid CSRF tokens return 403 Forbidden.
Rate limiting
Per-IP sliding window rate limiters protect against brute force and abuse:
| Endpoint | Limit | Window |
|---|---|---|
Login (/login) | 10 requests | 1 minute |
API (/api/v1/*) | 100 requests | 1 minute |
The client IP is determined from the X-Forwarded-For header (first IP in the chain). Configure your reverse proxy to set this header correctly.
When rate limited, login returns an error page and the API returns 429 Too Many Requests.
Input sanitization
Schema slugs
Slugs are validated to contain only:
- Lowercase ASCII letters
- Digits
- Hyphens and underscores
Slugs cannot start with a hyphen or dot, cannot contain .., and are limited to 128 characters. This prevents path traversal and filesystem issues.
Upload filenames
Uploaded filenames are sanitized:
- Directory components stripped (no
/or\) - Non-alphanumeric characters (except
.,-,_) replaced with_ - Leading dots removed
- Empty names default to
"upload"
Upload hash validation
Upload hashes are validated as hexadecimal strings of sufficient length before being used in filesystem paths, preventing path traversal via the upload retrieval endpoints.
Session cookies
The --secure-cookies flag sets the Secure attribute on session cookies, ensuring they are only sent over HTTPS. Always enable this in production behind TLS.
Without --secure-cookies, cookies work over plain HTTP (suitable for local development).
Password hashing
User passwords are hashed with Argon2 (via the argon2 crate) with a random salt generated from OsRng.
Observability
Prometheus metrics
Substrukt exposes a /metrics endpoint in Prometheus text format. This endpoint is unauthenticated (intended for internal scraping by your monitoring stack).
Available metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
http_requests_total | Counter | method, path, status | Total HTTP requests |
http_request_duration_seconds | Histogram | method, path | Request latency |
http_connections_active | Gauge | – | Currently active connections |
content_entries_total | Gauge | schema | Number of entries per schema |
uploads_total | Gauge | – | Total uploaded files |
uploads_size_bytes | Gauge | – | Total size of all uploads |
Content and upload gauges are updated on each scrape (when /metrics is requested).
Scraping
Add Substrukt to your Prometheus configuration:
scrape_configs:
- job_name: 'substrukt'
static_configs:
- targets: ['localhost:3000']
metrics_path: '/metrics'
scrape_interval: 30s
Example output
# HELP http_requests_total Total HTTP requests
http_requests_total{method="GET",path="/",status="200"} 42
http_requests_total{method="POST",path="/content/{schema_slug}/{entry_id}",status="302"} 7
# HELP http_request_duration_seconds Request duration in seconds
http_request_duration_seconds_bucket{method="GET",path="/",le="0.01"} 40
# HELP content_entries_total Content entries per schema
content_entries_total{schema="blog-posts"} 15
content_entries_total{schema="faq"} 8
# HELP uploads_total Total uploaded files
uploads_total 23
# HELP uploads_size_bytes Total upload storage in bytes
uploads_size_bytes 15728640
Audit logging
Substrukt maintains an audit log in a separate SQLite database (audit.db in the data directory). Audit writes are asynchronous – they do not block request handling.
Logged actions
| Action | Resource type | When |
|---|---|---|
login | session | User logs in |
logout | session | User logs out |
user_create | user | Admin account created during setup |
schema_create | schema | Schema created via UI |
schema_update | schema | Schema updated via UI |
schema_delete | schema | Schema deleted via UI |
content_create | content | Entry created via UI or API |
content_update | content | Entry updated via UI or API |
content_delete | content | Entry deleted via UI or API |
token_create | api_token | API token created |
token_delete | api_token | API token deleted |
import | bundle | Content bundle imported |
export | bundle | Content bundle exported |
webhook_fire | webhook | Webhook fired (with success/failure status) |
Audit log schema
Each audit entry contains:
| Column | Description |
|---|---|
timestamp | ISO 8601 timestamp |
actor | User ID, "api", or "system" |
action | Action name (see table above) |
resource_type | Type of resource affected |
resource_id | Identifier of the affected resource |
details | Optional JSON string with additional context |
Querying the audit log
There is no UI for the audit log. Query it directly with SQLite:
sqlite3 data/audit.db "SELECT timestamp, actor, action, resource_type, resource_id FROM audit_log ORDER BY timestamp DESC LIMIT 20"
Dirty tracking for webhooks
The audit database also stores webhook_state – the timestamp of the last webhook fire for each environment (staging, production). This is how Substrukt determines whether content has changed since the last deploy.
Structured logging
HTTP request/response tracing is provided by tower-http::TraceLayer. Each request is logged with method, path, status code, and duration.
Control log verbosity with the RUST_LOG environment variable:
RUST_LOG=substrukt=debug,tower_http=debug ./substrukt serve
Data Directory Layout
All persistent data lives under the data directory (default: data/, configurable via --data-dir).
data/
substrukt.db # Main SQLite database
audit.db # Audit log database
schemas/ # JSON Schema files
blog-posts.json
site-settings.json
faq.json
content/ # Content entries
blog-posts/ # Directory mode: one file per entry
my-first-post.json
another-post.json
site-settings/ # Single kind uses directory mode internally
_single.json
faq.json # Single-file mode: all entries in one file
uploads/ # Content-addressed file storage
a1/ # First 2 hex chars of SHA-256 hash
b2c3d4e5f6... # Remaining hash chars (file data)
c7/
d8e9f0a1b2...
Databases
substrukt.db
The main SQLite database. Stores:
- users – usernames and Argon2 password hashes
- sessions – active login sessions (managed by tower-sessions)
- api_tokens – hashed bearer tokens with names and creation dates
- uploads – upload metadata (hash, filename, MIME type, size)
- upload_references – mapping of which content entries reference which uploads
This database is created automatically on first run. Migrations run at startup.
audit.db
A separate SQLite database for audit logging. Stores:
- audit_log – timestamped records of all mutations
- webhook_state – last-fired timestamps for staging and production webhooks
Separated from the main database so audit writes (async) don’t contend with request-handling queries.
Schemas directory
Each schema is a single JSON file named <slug>.json. The file contains the full JSON Schema document including the x-substrukt extension.
Content directory
Content layout depends on the schema’s storage mode:
Directory mode
content/<slug>/
<entry-id>.json
Each entry is a standalone JSON object.
Single-file mode
content/<slug>.json
All entries in a JSON array, each with an _id field.
Single kind
Single schemas (where kind: "single") use directory mode with a fixed entry ID of _single:
content/<slug>/
_single.json
Uploads directory
Files are stored by their SHA-256 hash split into a 2-character prefix and the remaining characters:
uploads/
<hash[0..2]>/
<hash[2..]> # The file data
This directory structure prevents any single directory from having too many files. Upload metadata (original filename, MIME type, size) is stored in substrukt.db, not on the filesystem.
Backup
To back up a Substrukt instance, you need:
- The data directory (schemas, content, uploads)
substrukt.db(users and tokens)- Optionally,
audit.db(audit history)
Or use substrukt export to create a tar.gz bundle of schemas, content, and uploads. Note that the export does not include users or tokens.
Architecture
Overview
Substrukt is a single Rust binary that handles everything: web UI, REST API, file storage, background tasks, and database management. There are no external services beyond the filesystem and embedded SQLite.
+------------------+
| Web Browser |
| (htmx + twind) |
+--------+---------+
|
+--------+---------+
| Axum Router |
| (tower layers) |
+--------+---------+
|
+--------------------+--------------------+
| | |
+--------+--------+ +-------+--------+ +---------+---------+
| UI Routes | | API Routes | | Metrics/Health |
| (SSR + htmx) | | (/api/v1/*) | | (/metrics) |
+--------+---------+ +-------+--------+ +-------------------+
| |
+--------+--------+ +-------+--------+
| Session Auth | | Bearer Token |
| (cookies) | | Auth |
+--------+---------+ +-------+--------+
| |
+--------------------+
|
+--------------+--------------+
| | |
+------+------+ +-----+-----+ +-----+------+
| Schemas | | Content | | Uploads |
| (JSON | | (JSON | | (SHA-256 |
| files) | | files) | | files) |
+------+------+ +-----+-----+ +-----+------+
| | |
+--------------+--------------+
|
+-----+------+
| Filesystem |
| (data dir) |
+-----+------+
|
+--------------+--------------+
| |
+------+------+ +--------+--------+
| substrukt.db| | audit.db |
| (users, | | (audit log, |
| tokens, | | webhook state) |
| uploads) | +-----------------+
+-------------+
Request flow
- HTTP request arrives at the Axum router
- Tower layers run in order: CatchPanic, TraceLayer, metrics tracking, session management
- Route matching: UI routes go through session auth middleware; API routes go through bearer token extraction
- CSRF verification runs for mutating UI requests (POST/PUT/DELETE)
- Handler processes the request, interacting with schemas, content, or uploads
- Response is rendered (HTML via minijinja for UI, JSON for API)
Key modules
| Module | Responsibility |
|---|---|
main.rs | CLI parsing, server startup, shutdown signal |
config.rs | Configuration struct and directory helpers |
state.rs | Shared application state (AppState) |
templates.rs | minijinja environment with auto-reload |
cache.rs | DashMap content cache, file watcher, populate/rebuild |
rate_limit.rs | Per-IP sliding window rate limiter |
metrics.rs | Prometheus recorder and metrics middleware |
audit.rs | Audit logger with async writes, dirty tracking |
webhooks.rs | Webhook firing and background cron |
db/ | SQLite pool initialization and migrations |
db/models.rs | User, ApiToken queries |
auth/ | Session management, CSRF, login/logout |
auth/token.rs | Bearer token generation, hashing, extraction |
schema/ | Schema file CRUD and validation |
schema/models.rs | SubstruktMeta, StorageMode, Kind types |
content/ | Content entry CRUD (directory and single-file) |
content/form.rs | JSON Schema to HTML form generation and parsing |
uploads/ | Content-addressed file storage |
sync/ | tar.gz export/import |
routes/ | All HTTP route handlers |
Technology choices
| Component | Technology | Why |
|---|---|---|
| Web framework | Axum 0.8 | Tower middleware ecosystem, async, type-safe extractors |
| Database | SQLite via sqlx | Embedded, no external service, WAL mode for concurrency |
| Templating | minijinja | Fast, safe, Jinja2-compatible, auto-reload support |
| Frontend | htmx + twind | No build step, minimal JS, responsive styling |
| Content cache | DashMap | Concurrent reads without locks, lock-free updates |
| File watching | notify | Cross-platform filesystem events with debouncing |
| Metrics | metrics + metrics-exporter-prometheus | Standard Prometheus format |
| Password hashing | Argon2 | Memory-hard, recommended by OWASP |
| Session storage | tower-sessions-sqlx-store | SQLite-backed, integrates with Axum |
Concurrency model
- tokio async runtime handles all I/O
- DashMap provides lock-free concurrent reads for the content cache
- Async audit writes – audit log entries are spawned as fire-and-forget tasks
- File watcher runs in a background thread with debounced events
- Webhook cron runs as a background tokio task on a timer
- Rate limiters use DashMap for lock-free per-IP tracking
Data ownership
| Data | Stored in | Managed by |
|---|---|---|
| Users, passwords | substrukt.db | db/models.rs |
| Sessions | substrukt.db | tower-sessions |
| API tokens | substrukt.db | db/models.rs |
| Upload metadata | substrukt.db | uploads/mod.rs |
| Upload references | substrukt.db | uploads/mod.rs |
| Schemas | JSON files | schema/mod.rs |
| Content entries | JSON files | content/mod.rs |
| Upload files | Binary files | uploads/mod.rs |
| Audit log | audit.db | audit.rs |
| Webhook state | audit.db | audit.rs |