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

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

  1. You create schemas that describe your content types (blog posts, settings, pages, etc.)
  2. The CMS generates forms from those schemas for editing content through the web UI
  3. Content is saved as JSON files on disk and cached in memory for fast reads
  4. A REST API serves the content to your frontend, static site generator, or mobile app
  5. Import/export bundles let you sync content between local and production environments
  6. Webhooks can trigger rebuilds of your frontend when content changes

Core concepts

ConceptDescription
SchemaA JSON Schema document with an x-substrukt extension that defines a content type
Content entryA JSON object conforming to a schema, stored as a file on disk
UploadA file (image, document, etc.) stored with content-addressed deduplication
BundleA tar.gz archive containing all schemas, content, and uploads for syncing
API tokenA 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.

  1. Open http://localhost:3000 in your browser
  2. Enter a username and password on the setup page
  3. 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

FlagDefaultDescription
--data-dir <PATH>dataRoot directory for schemas, content, uploads, and databases
--db-path <PATH><data-dir>/substrukt.dbPath to the main SQLite database
-p, --port <PORT>3000HTTP listen port
--secure-cookiesoffSet the Secure flag on session cookies (required for HTTPS)
--staging-webhook-url <URL>noneWebhook URL fired automatically when content changes
--staging-webhook-auth-token <TOKEN>noneBearer token sent with staging webhook requests
--production-webhook-url <URL>noneWebhook URL fired on manual publish
--production-webhook-auth-token <TOKEN>noneBearer token sent with production webhook requests
--webhook-check-interval <SECONDS>300How 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:

  1. x-substrukt – metadata that tells Substrukt how to handle this content type
  2. 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

FieldRequiredDefaultDescription
titleyesDisplay name shown in the UI sidebar and headings
slugyesURL-safe identifier used in file paths and API endpoints
storagenodirectoryHow content is stored on disk: directory or single-file
kindnocollectionWhether the schema holds multiple entries (collection) or a single document (single)
id_fieldnoauto-detectedWhich 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:

  1. If id_field is set in x-substrukt, use the value of that field (slugified)
  2. Otherwise, find the first string property (that isn’t an upload) and slugify its value
  3. If neither works, generate a UUID

Managing schemas

Via the web UI

  1. Go to Schemas in the sidebar
  2. Click New Schema to create, or click a schema’s Edit button
  3. Edit the JSON Schema in the interactive editor (powered by vanilla-jsoneditor)
  4. 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:

  1. The x-substrukt extension is present with a title and slug
  2. The slug is valid (see rules above)
  3. 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 SchemaFormatUI ElementStored 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)Checkboxtrue / 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 entries
  • POST /api/v1/content/:slug – create an entry
  • GET /api/v1/content/:slug/:id – get one entry
  • PUT /api/v1/content/:slug/:id – update one entry
  • DELETE /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 document
  • PUT /api/v1/content/:slug/single – create or update the document
  • DELETE /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

  1. Click a content type in the sidebar
  2. Click New Entry
  3. Fill in the form (generated from the schema)
  4. Click Save

For schemas with kind: "single", clicking the content type in the sidebar goes directly to the edit form.

Editing an entry

  1. Click a content type in the sidebar
  2. Click Edit next to the entry
  3. Modify the fields
  4. Click Save

Deleting an entry

  1. Click a content type in the sidebar
  2. Click Delete next to the entry
  3. 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

  1. The file is hashed with SHA-256
  2. The hash determines the storage path: data/uploads/<first-2-hex>/<remaining-hex>
  3. Upload metadata (hash, filename, MIME type, size) is stored in SQLite
  4. 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 files
  • content/ – 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:

  1. Schemas, content, and uploads are unpacked (overwrite strategy)
  2. Upload metadata is imported into SQLite from the manifest
  3. Upload references are rebuilt by scanning all content files
  4. All imported content is validated against its schema
  5. 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:

  1. Run Substrukt locally, edit content
  2. Export a bundle: substrukt export bundle.tar.gz
  3. Commit the bundle to your git repository
  4. 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
FlagDescription
--staging-webhook-urlURL to POST when content changes are detected
--staging-webhook-auth-tokenBearer token sent with staging webhook requests
--production-webhook-urlURL to POST on manual publish
--production-webhook-auth-tokenBearer token sent with production webhook requests
--webhook-check-intervalSeconds 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/production with 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"
}
FieldValues
event_typeAlways "substrukt-publish"
environment"staging" or "production"
triggered_atISO 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

  1. Log in to the web interface
  2. Go to Settings > API Tokens
  3. Enter a name for the token and click Create
  4. 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

StatusMeaning
401 UnauthorizedMissing or invalid bearer token
404 Not FoundSchema or entry not found
429 Too Many RequestsRate limit exceeded
400 Bad RequestInvalid request body or validation errors
500 Internal Server ErrorServer-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.

EnvironmentWebhook flagBehavior
staging--staging-webhook-urlAlso fired automatically by the background cron
production--production-webhook-urlOnly 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:

  1. Builder stage: Compiles the Rust binary with cargo build --release using a nightly Rust image. Dependencies are cached separately for faster rebuilds.
  2. Runtime stage: Copies the binary and templates into a minimal Debian image with only ca-certificates installed.

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 tokens
  • audit.db – audit log
  • schemas/ – JSON Schema files
  • content/ – content entries
  • uploads/ – 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-cookies when behind HTTPS
  • Set X-Forwarded-For header in your reverse proxy (used for rate limiting)
  • Mount persistent storage for /data (Docker) or --data-dir (binary)
  • Back up substrukt.db regularly (contains users and tokens)
  • Set RUST_LOG=substrukt=info for 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 /setup if no users exist
  • Subsequent visits redirect to /login if not authenticated
  • Sessions are managed via secure cookies (when --secure-cookies is 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:

  • /login and /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:

  1. Generated per-session and stored in the session store
  2. Included in forms as a hidden _csrf field
  3. Sent in X-CSRF-Token header for JavaScript (fetch/DELETE) requests

CSRF verification flow:

  • GET/HEAD/OPTIONS requests pass through
  • For URL-encoded forms: the _csrf field is extracted and verified
  • For JavaScript requests: the X-CSRF-Token header is checked
  • For multipart forms: handlers verify the _csrf field 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:

EndpointLimitWindow
Login (/login)10 requests1 minute
API (/api/v1/*)100 requests1 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

MetricTypeLabelsDescription
http_requests_totalCountermethod, path, statusTotal HTTP requests
http_request_duration_secondsHistogrammethod, pathRequest latency
http_connections_activeGaugeCurrently active connections
content_entries_totalGaugeschemaNumber of entries per schema
uploads_totalGaugeTotal uploaded files
uploads_size_bytesGaugeTotal 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

ActionResource typeWhen
loginsessionUser logs in
logoutsessionUser logs out
user_createuserAdmin account created during setup
schema_createschemaSchema created via UI
schema_updateschemaSchema updated via UI
schema_deleteschemaSchema deleted via UI
content_createcontentEntry created via UI or API
content_updatecontentEntry updated via UI or API
content_deletecontentEntry deleted via UI or API
token_createapi_tokenAPI token created
token_deleteapi_tokenAPI token deleted
importbundleContent bundle imported
exportbundleContent bundle exported
webhook_firewebhookWebhook fired (with success/failure status)

Audit log schema

Each audit entry contains:

ColumnDescription
timestampISO 8601 timestamp
actorUser ID, "api", or "system"
actionAction name (see table above)
resource_typeType of resource affected
resource_idIdentifier of the affected resource
detailsOptional 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:

  1. The data directory (schemas, content, uploads)
  2. substrukt.db (users and tokens)
  3. 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

  1. HTTP request arrives at the Axum router
  2. Tower layers run in order: CatchPanic, TraceLayer, metrics tracking, session management
  3. Route matching: UI routes go through session auth middleware; API routes go through bearer token extraction
  4. CSRF verification runs for mutating UI requests (POST/PUT/DELETE)
  5. Handler processes the request, interacting with schemas, content, or uploads
  6. Response is rendered (HTML via minijinja for UI, JSON for API)

Key modules

ModuleResponsibility
main.rsCLI parsing, server startup, shutdown signal
config.rsConfiguration struct and directory helpers
state.rsShared application state (AppState)
templates.rsminijinja environment with auto-reload
cache.rsDashMap content cache, file watcher, populate/rebuild
rate_limit.rsPer-IP sliding window rate limiter
metrics.rsPrometheus recorder and metrics middleware
audit.rsAudit logger with async writes, dirty tracking
webhooks.rsWebhook firing and background cron
db/SQLite pool initialization and migrations
db/models.rsUser, ApiToken queries
auth/Session management, CSRF, login/logout
auth/token.rsBearer token generation, hashing, extraction
schema/Schema file CRUD and validation
schema/models.rsSubstruktMeta, StorageMode, Kind types
content/Content entry CRUD (directory and single-file)
content/form.rsJSON Schema to HTML form generation and parsing
uploads/Content-addressed file storage
sync/tar.gz export/import
routes/All HTTP route handlers

Technology choices

ComponentTechnologyWhy
Web frameworkAxum 0.8Tower middleware ecosystem, async, type-safe extractors
DatabaseSQLite via sqlxEmbedded, no external service, WAL mode for concurrency
TemplatingminijinjaFast, safe, Jinja2-compatible, auto-reload support
Frontendhtmx + twindNo build step, minimal JS, responsive styling
Content cacheDashMapConcurrent reads without locks, lock-free updates
File watchingnotifyCross-platform filesystem events with debouncing
Metricsmetrics + metrics-exporter-prometheusStandard Prometheus format
Password hashingArgon2Memory-hard, recommended by OWASP
Session storagetower-sessions-sqlx-storeSQLite-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

DataStored inManaged by
Users, passwordssubstrukt.dbdb/models.rs
Sessionssubstrukt.dbtower-sessions
API tokenssubstrukt.dbdb/models.rs
Upload metadatasubstrukt.dbuploads/mod.rs
Upload referencessubstrukt.dbuploads/mod.rs
SchemasJSON filesschema/mod.rs
Content entriesJSON filescontent/mod.rs
Upload filesBinary filesuploads/mod.rs
Audit logaudit.dbaudit.rs
Webhook stateaudit.dbaudit.rs