Configuration

Configuration#

Lesstruct has two layers of configuration, each for a different concern:

  • config.toml — your site’s content schema: languages, custom post types, user profile fields, thumbnail variants. Edited by hand, version-controlled.
  • Environment variables (in .env or the process env) — your deployment configuration: host, port, database, secrets, SMTP, AI integrations. Treated as deployment state, not committed.

This document covers both.

Table of Contents#

Where the Files Live#

ConcernDefault locationOverride via env
Content schema./config.toml in the working directoryCONFIG_DIR (directory), CONFIG_FILE (filename)
Deployment config.env in the working directoryprocess env wins over .env
Custom themeempty (uses embedded theme)THEME_DIR=themes/<name>
Pluginsplugins/ in the working directorynot configurable (hard-coded)
Databasedata/lesstruct.db (SQLite)DB_DRIVER, DB_PATH, DB_DSN

config.toml is loaded once at startup from CONFIG_DIR/CONFIG_FILE. The runtime does not auto-merge or auto-generate anything; the file is read as-is. If the file is missing, the runtime falls back to defaults (one language: English, default post and page post types, default thumbnail at 370 px). Validation errors at startup are reported with a clear message; the server does not start with an invalid config.toml.

Historical note. Early Lesstruct releases shipped a config.toml whose header claimed it was “generated by merging all TOML files in config/” — there was never such an auto-merge. The shipped header now states this correctly. If you are upgrading from an old release, you can safely delete any leftover config/ merge instructions from your config.toml; edit config.toml directly.

Environment Variables#

All env vars are loaded by internal/config/config.go:Load() and override the corresponding defaults. The runtime also loads a .env file in the working directory via godotenv (env vars in the actual process env take precedence over .env).

Server#

VariableDefaultDescription
HOST0.0.0.0Bind address for the HTTP server.
PORT8080Bind port. Validated to be in [1, 65535].

Database#

VariableDefaultDescription
DB_DRIVERsqliteOne of sqlite, postgres, mysql.
DB_PATHdata/lesstruct.dbSQLite file path (used when DB_DRIVER=sqlite).
DB_DSNemptyRequired for postgres and mysql. See per-driver requirements below.
DB_POOL_MAX_CONNS20Max connections in the pool (used with postgres and mysql). Must be ≥ 1 when set.

Per-driver requirements (enforced at startup):

  • SQLite — no extra requirements. Just set DB_PATH (or use the default).
  • PostgresDB_DSN is required. Format: postgres://user:password@host:port/db?sslmode=disable. DB_POOL_MAX_CONNS must be ≥ 1 if set.
  • MySQLDB_DSN is required. The DSN must contain parseTime=true and multiStatements=true. Without them, DATE columns scan as []byte and migrations with multiple statements fail. clientFoundRows=true is automatically injected if missing — it ensures RowsAffected() returns the number of rows matched by the WHERE clause rather than rows whose values changed, which prevents spurious content_not_found errors on no-op updates. Format: user:password@tcp(host:port)/db?parseTime=true&multiStatements=true&charset=utf8mb4&collation=utf8mb4_general_ci.

Storage#

Media files and profile pictures are stored through a single Storage interface (internal/storage/) with two backends: local (default) and s3. The s3 backend works against both AWS S3 and MinIO — they speak the same S3 API, so one driver serves either; the difference is configuration (endpoint + path style), not code.

The driver also decides the shape of stored media URLs: the local backend produces root-relative URLs (/uploads/media/<file>) — they resolve against whatever origin serves the page, so reverse proxies and HTTPS need no extra config — while the s3 backend produces absolute URLs (<STORAGE_S3_PUBLIC_BASE_URL>/<key>). SEO contexts that require absolute values (Open Graph/Twitter images, JSON-LD) are absolutized with SITE_URL.

VariableDefaultDescription
STORAGE_DRIVERlocalOne of local, s3.
STORAGE_S3_ENDPOINTemptyS3-compatible endpoint URL. Empty = AWS S3 (uses default AWS endpoints). Set for MinIO, e.g. http://localhost:9000.
STORAGE_S3_REGIONus-east-1AWS region; MinIO deployments usually keep us-east-1 (MinIO’s default).
STORAGE_S3_BUCKETempty (required for s3)Bucket holding media and profile pictures.
STORAGE_S3_ACCESS_KEY_IDempty (required for s3)Static access key.
STORAGE_S3_SECRET_ACCESS_KEYempty (required for s3)Static secret key.
STORAGE_S3_USE_PATH_STYLEfalsetrue for MinIO (path-style addressing), false for AWS (virtual-host style).
STORAGE_S3_PUBLIC_BASE_URLempty (required for s3)Public base URL served to clients — the bucket’s public URL or a CDN/CloudFront distribution, e.g. https://cdn.example.com. GetURL returns this prefix + object key, so the bucket must be publicly readable (or fronted by a CDN with bucket access).

Per-driver requirements (enforced at startup):

  • Local — files live under data/uploads/media/ and data/uploads/profile_pictures/, served by the server’s built-in fileserver at /uploads/media/* and /uploads/profile_pictures/*. GetURL returns root-relative URLs (/uploads/media/<file>). No configuration needed.
  • S3STORAGE_S3_REGION, STORAGE_S3_BUCKET, STORAGE_S3_ACCESS_KEY_ID, STORAGE_S3_SECRET_ACCESS_KEY, and STORAGE_S3_PUBLIC_BASE_URL are required. Files are stored under the media/ and profile_pictures/ key prefixes; GetURL returns absolute URLs (<STORAGE_S3_PUBLIC_BASE_URL>/<key>); the local fileserver is not mounted — clients fetch bytes directly from the public base URL.

Switching drivers after data exists: media_files.file_path/url and the profile picture column store whatever the active backend produced (root-relative URL paths for local, object keys + public URLs for s3). Rows created under one driver are not readable under the other — switching requires a one-time migration that re-uploads the existing files and rewrites the stored paths/URLs (there is no built-in migration tool yet). Rows written by older Lesstruct versions under the local driver contain absolute http://host:port URLs; they keep rendering as-is (both shapes resolve), and new uploads use the relative form.

Authentication#

VariableDefaultDescription
JWT_SECRETempty (required)HMAC secret for JWTs. Required. Must be at least 32 characters.
API_KEY_PEPPERemptyPepper prepended to API key secrets before hashing. Adding or changing it invalidates all existing API keys.

SMTP#

VariableDefaultDescription
SMTP_HOSTemptySMTP server hostname. When unset, the email-verification and password-reset flows will not work.
SMTP_PORT587SMTP port.
SMTP_USERemptySMTP auth username.
SMTP_PASSWORDemptySMTP auth password.
SMTP_FROMemptyFrom: address for outbound emails.

For local development, Mailtrap or any sandbox SMTP service is a safe choice.

CORS#

VariableDefaultDescription
CORS_ALLOWED_ORIGINShttp://localhost:5173Comma-separated list of allowed origins. Each must be a valid http:// or https:// URL with a non-empty host. The default is suitable for local admin dev.

A typical production value:

1
CORS_ALLOWED_ORIGINS=https://example.com,https://www.example.com,https://admin.example.com

Site#

VariableDefaultDescription
SITE_URLhttp://localhost:8080Canonical public origin of the site. Used for email verification/reset links, SEO metadata (Open Graph/Twitter/JSON-LD image absolutization), the XML sitemap, robots.txt, static-site generation, and author profile links in the public API. Set it to the public https:// origin when running behind a reverse proxy — HOST/PORT only bind the listener and never appear in generated links.
DEV_MODEfalseWhen true, the admin panel is served from the Vite dev server (ADMIN_DEV_URL) instead of the embedded build. Same env var enables plugin hot-reload — see the plugin skill.
ADMIN_DEV_URLhttp://localhost:5173URL of the Vite dev server (only used when DEV_MODE=true).
THEME_DIRemptyPath to a custom theme directory. Empty uses the embedded theme. See the theme skill.
POSTS_PER_PAGE50Number of posts per page on public listing pages (homepage, author, tag, and post-type listings). Must be between 1 and 100; 0 falls back to the default. Pagination links (?page=N) appear automatically.

Rate limits#

VariableDefaultDescription
RATE_LIMIT_ENABLEDtrueMaster toggle. Set to false to disable all rate limiting (not recommended in production).
RATE_LIMIT_AUTH_PER_MINUTE5Per-IP cap on auth endpoints (login, register, forgot-password, reset-password).
RATE_LIMIT_API_PER_MINUTE100Per-token cap on authenticated API endpoints.
RATE_LIMIT_PUBLIC_PER_MINUTE60Per-IP cap on public endpoints (search, content listing, etc.).

Imports#

VariableDefaultDescription
IMPORT_MAX_SIZE_MB100Max upload size (in megabytes) for any importer. Shared by all import types — the WordPress WXR importer and the Hugo archive importer. WordPress exports commonly reach tens or hundreds of MB for real sites, so the default is generous; raise it for very large sites.
WORDPRESS_IMPORT_TIMEOUT2hMax duration (Go duration string, e.g. 4h, 90m) for a single WordPress import job. Imports run asynchronously in a background goroutine after the HTTP request returns 202 Accepted. Set higher for very large exports with many images.
HUGO_IMPORT_TIMEOUT10mMax duration (Go duration string, e.g. 10m, 30m) for a single Hugo import job. Imports run asynchronously in a background goroutine after the HTTP request returns 202 Accepted. Set higher for sites with many images to migrate.

Server timeouts#

VariableDefaultDescription
SERVER_READ_HEADER_TIMEOUT15sMax duration to read request headers (Slowloris protection). Recommended.
SERVER_READ_TIMEOUT0 (off)Max duration to read the entire request including the body. A zero value means no timeout — per-handler MaxBytesReader / maxBodySizeMiddleware caps body size on each route. Leave at 0 to allow large uploads (WordPress import, media upload).
SERVER_WRITE_TIMEOUT0 (off)Max duration to write the response after headers have been read. A zero value means no timeout — per-handler context deadlines (e.g. WORDPRESS_IMPORT_TIMEOUT) bound long-running operations. Leave at 0 to avoid interrupting large uploads or slow clients.

Logging#

VariableDefaultDescription
LOG_LEVELinfoOne of debug, info, warn, error.

AI image generation (optional)#

When AI_IMAGE_GENERATION_API_KEY is set, the admin panel shows a “Generate with AI” button in the media library and content editor.

Reference images (optional uploads or media-library picks shown as thumbnails in the generate dialog, up to 3 images of 10 MB each) are supported by the gemini-* and gpt-image-* models only — the default Imagen models are text-to-image, so the reference section is hidden when one of them is configured. Picking a library image as a reference fetches its bytes in the browser: with the S3 storage driver the bucket must allow cross-origin GET (Access-Control-Allow-Origin) for this to work.

The “Open Graph image” checkbox generates an exact 1200x630 social preview: the prompt gains composition guidance and the output is center-cropped and resized server-side, so it works with every model. Set AI_IMAGE_GENERATION_ASPECT_RATIO=16:9 to minimize the crop. Generated from the content editor, the image is inserted at the top of the content — og:image is always the content’s first image.

VariableDefaultDescription
AI_IMAGE_GENERATION_API_KEYemptyAPI key for the image provider.
AI_IMAGE_GENERATION_MODELimagen-4.0-fast-generate-001Model name. Examples: imagen-4.0-fast-generate-001, imagen-4.0-ultra-generate-001, gpt-image-1, gpt-image-1-mini, gemini-2.5-flash-image.
AI_IMAGE_GENERATION_SIZEemptyPixel size, e.g. 1024x1024.
AI_IMAGE_GENERATION_ASPECT_RATIOemptyAspect ratio, e.g. 16:9, 1:1, 4:3.

AI text generation (optional)#

When AI_TEXT_GENERATION_API_KEY is set, the admin panel enables AI text enhancement (TipTap), HTML/CSS generation, and translation for both formats. Works with any OpenAI-compatible API.

VariableDefaultDescription
AI_TEXT_GENERATION_API_KEYemptyAPI key for the text provider.
AI_TEXT_GENERATION_BASE_URLemptyOverride the API base URL. Default is OpenAI’s API. For other providers: https://api.deepseek.com, https://openrouter.ai/api/v1, http://localhost:11434/v1 (Ollama), etc.
AI_TEXT_GENERATION_MODELgpt-5-miniChat model name. Examples by provider: OpenAI gpt-5-mini, DeepSeek deepseek-chat, OpenRouter openai/gpt-5-mini, Together meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8, Ollama llama3.

Admin frontend (Vite)#

The admin SPA (served at /admin/) loads its API base URL from a Vite env var. In production (embedded assets, same origin), the SPA uses a relative base and follows whatever host:port the user opens in the browser — no config needed. Override only for cross-origin dev setups.

VariableDefaultDescription
VITE_API_BASE_URL'' (same-origin)Override the backend API URL. Set in web/admin/.env.local (gitignored). Used in dev mode when the Vite dev server and the Go backend run on different ports. Example: http://localhost:8081.

Duplicate-key gotcha#

.env files are parsed by godotenv, which is a last-value-wins parser. If the same variable appears twice, only the second value is used:

1
2
THEME_DIR=themes/dark-warm
THEME_DIR=

The first line is silently overridden by the empty second line — Lesstruct ends up using the embedded theme. This is a common operator mistake. Check your .env for duplicate keys if a setting appears to have no effect.

config.toml Reference#

config.toml is loaded at startup. If the file is missing, the runtime uses defaults: English only, the built-in post, page, media, and comment post types, default thumbnail at 370 px. If the file is present but invalid, the server fails to start with a validation error.

Top-level keys#

KeyTypeDefaultDescription
languages[]string["en"]ISO 639-1 language codes. The first is the primary language. Used by the i18n catalog, the admin language switcher, and the content language switcher. Public listings (homepage, sections, tag/author pages, static export) list each translation group once, preferring languages in this order — a post missing from the primary language falls back to the next configured one.
[site_config]tableemptySite-wide identity: name and logo. See below.
[user_fields]tableemptyGlobal user profile fields. Applies to all users.
[[post_type]]array of tablesfour built-in typesCustom post types, or extensions to built-in types (see below). Add as many as needed.
[[homepage_section]]array of tablesemptyPer-post-type groupings rendered on the homepage in addition to the latest-posts list. See below.
[[public_field]]array of tablesemptyAllowlist of custom/system fields that may be filtered or sorted on the public query endpoints. See below.
[csp]tableempty (default CSP applied)Content-Security-Policy configuration — per-directive source appends, extra directives, report-only mode, and a full-override escape hatch. See below.
[[thumbnail]]array of tables[{max_width=370, suffix="_thumb"}]Image processing variants. See below.
[headless]tabledisabledHeadless-mode toggle. When enabled, the server-rendered content site is not served. See below.
[comments]tableenabledComment-system toggle. When disabled, the comment system is hard-disabled end to end. See below.
[[role]]array of tablesthree built-in rolesCustom user roles, or overrides of the built-in roles. See below.
[registration]tablefollows commentsSelf-registration toggle, default role, and admin-approval gate. See below.

[user_fields]#

KeyTypeDescription
fields[]FieldSchemaUser-editable fields shown in the user profile.
system_fields[]FieldSchemaRead-only fields managed by plugins or operators.

The schema for each field is the same as post-type fields (below).

[site_config]#

Optional site-wide identity. Two fields, both optional:

KeyTypeDescription
namestringThe site name. Drives the browser-tab title suffix (e.g. My Post - <name>), the og:site_name meta tag, the default logo text, and the footer. When unset, defaults to Lesstruct.
logostringOptional logo image URL or path (e.g. /uploads/logo.png). When set, the default theme renders <img src="…" alt="{name}">; when empty, it renders name as text.
1
2
3
[site_config]
name = "Astra Motor Kalbar"
logo = "/uploads/logo.png"

[site_config] exists because the site name is otherwise baked into the binary’s PageTitle strings (which a THEME_DIR override cannot reach). Everything beyond identity — social links, Google Analytics, site-verification meta tags, footer copyright text, custom <head> injection, image/multi-logo layouts — is a theme concern: override layout.html in your THEME_DIR for those. See docs/theme-development.md.

[[post_type]]#

Each entry defines one custom post type, or extends a built-in one. Four post types are always present: post, page, media, and comment. To add custom fields to a built-in type, reuse its slug — the entry’s fields and system_fields are merged into the built-in type (by slug; an incoming field replaces an existing one of the same slug). When extending, only fields/system_fields are read; name, description, and supports are ignored (and may be omitted), and the built-in’s identity is preserved. To define an entirely new type, use a slug that is not one of the built-ins.

KeyTypeRequiredDescription
namestringnew typesDisplay name (e.g. "Product"). 1-200 characters. Ignored when extending a built-in type.
slugstringyesURL slug (e.g. "product"). 1-200 characters. Kebab-case only: lowercase letters, digits, hyphens, underscores. No leading/trailing hyphens, no consecutive hyphens. Reusing a built-in slug (post, page, media, comment) extends that type instead of defining a new one.
descriptionstringnoHuman-readable description. Shown in the admin panel. Ignored when extending a built-in type.
supports[]stringnew types (non-empty)Features the post type supports. Each entry must be one of: title, content, tags, featured_image, excerpt. At least one is required for new types. Ignored when extending a built-in type (the built-in’s supports are kept).
fields[]FieldSchemanoUser-editable custom fields. Merged by slug when extending a built-in type.
system_fields[]FieldSchemanoRead-only system fields. Often set by plugins via before_save hooks. Merged by slug when extending a built-in type.
hiddenboolnoWhen true, the post type is hidden from the admin panel and the public post-type list. Allowed only on the post built-in and custom post types — page, media, and comment reject hidden = true (disable the comment system via the [comments] block instead). Hidden types remain valid for content and the registry keeps serving them (e.g. to the API); only the presentation surfaces drop them.

Field schema (FieldSchema)#

Used in [user_fields].fields, [user_fields].system_fields, [[post_type]].fields, and [[post_type]].system_fields.

KeyTypeRequiredDescription
namestringyesDisplay name. 1-200 characters.
slugstringyesIdentifier. 1-200 characters, snake_case (regex-enforced). Must be unique within the parent (user fields or a post type).
typestringyesOne of text, textarea, number, date, datetime, email, url, select, checkbox.
requiredboolnoWhen true, the field must have a value when saving.
options[]stringfor selectThe list of allowed values. Required and non-empty for select.
minfloatfor numberMinimum allowed value.
maxfloatfor numberMaximum allowed value.
max_lengthintfor text/textareaMaximum character count.

Reserved slug post_script: When a post type declares a post_script field (recommended type textarea), its raw HTML is emitted verbatim at the end of the post via {{.PostScripts}} — excluded from the visible custom-fields section and stripped from AMP. Declaration is the operator’s opt-in (like Ghost’s per-post code injection): only declare it on types whose editors are fully trusted, and ensure your CSP allows what you emit (external src is 'self'-clean; inline needs 'unsafe-inline').

[[homepage_section]]#

Each entry tells the public homepage to render a per-post-type grouping (for example, a magazine-style “Latest Articles” or “Upcoming Events” block) in addition to the flat latest-posts list. Sections are opt-in: when no [[homepage_section]] blocks are configured, the homepage renders only the latest-posts list (fully backward compatible).

The latest-posts list and each section are both scoped to the configured languages (in priority order) and the post type at the database level: every translation group appears once, under its best-ranked available language, so a post without a primary-language version is not dropped from the list. Section items use the same PostItem shape as the post grid, and the homepage template exposes them via .Sections (see the theme development guide).

KeyTypeRequiredDescription
post_typestringyesThe post-type slug to feature (e.g. "article"). Must match a configured post type.
limitintnoNumber of items to show. Defaults to 6.
offsetintnoNumber of items to skip. Defaults to 0. Use a non-zero offset to avoid duplicating items shown in an earlier section (e.g. a “Recommendations” carousel that starts after the “Featured” section).
titlestringnoOverride the section heading. When omitted, the post type’s display name is used.

Example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[[homepage_section]]
post_type = "article"
limit = 6
title = "Artikel Pilihan"

[[homepage_section]]
post_type = "article"
limit = 20
offset = 6
title = "Rekomendasi"

[[homepage_section]]
post_type = "event"
limit = 3

[[public_field]]#

The public query endpoints (GET /api/v1/public/content_items and GET /api/v1/public/authors) accept cf_<field>, cf_<field>_min, cf_<field>_max, and sort_by=cf:<field> parameters. These are off by default — every such parameter that references a field not declared in a [[public_field]] block is rejected with a 400 field_not_queryable error. This is the fail-closed default; the operator must explicitly opt fields in.

Admin-managed system fields ([[post_type.system_fields]], [user_fields].system_fields) are also queryable via the same cf_* / sort_by=cf:* parameters — they share the same custom_fields JSON column as regular custom fields. A [[public_field]] entry with the system-field slug is all that is needed to expose it on the public API.

The "expose" operation additionally includes the field’s value in the response body. When "expose" is not present (the default), the field’s value is never sent to the client — it can only be used for sorting/filtering queries. Currently only the "user" resource supports the "expose" operation.

Admin endpoints (e.g. GET /api/v1/content_items) are not gated by this allowlist — they remain unrestricted, matching pre-existing behaviour.

KeyTypeRequiredDescription
resourcestringyesEither "user" or "content". Selects which public endpoint the entry applies to.
fieldstringyesThe custom-field or system-field slug. Must match ^[a-z][a-z0-9_]*$.
post_typestringnoWhen resource = "content", scopes the entry to one post type. Empty (the default) matches every post type. Ignored (and silently cleared) when resource = "user".
operations[]stringyesA non-empty subset of ["sort", "filter", "expose"]. Declares which public query operations are allowed on this field. The "expose" operation includes the field’s value in the response body (currently only "user" resource).

resource, field, post_type, and operations are matched case-insensitively after normalisation. Duplicate operations in the list are collapsed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# A user system field that powers a "Top Contributors by points" sidebar widget.
[[public_field]]
resource   = "user"
field      = "points"
operations = ["sort"]

# A user system field that is both sortable and included in the response body.
[[public_field]]
resource   = "user"
field      = "tier_point"
operations = ["sort", "expose"]

# A content custom field scoped to one post type, both sortable and filterable.
[[public_field]]
resource   = "content"
field      = "views"
post_type  = "article"
operations = ["sort", "filter"]

# A content custom field that applies to every post type, filter only.
[[public_field]]
resource   = "content"
field      = "category"
post_type  = ""
operations = ["filter"]

With the configuration above:

  • GET /api/v1/public/authors?sort_by=cf:points&order=desc200
  • GET /api/v1/public/authors?sort_by=cf:email400 field_not_queryable
  • GET /api/v1/public/authors → response includes "publicFields": {"tier_point": 500} for each author (if tier_point is not empty)
  • GET /api/v1/public/content_items?post_type=article&cf_views_min=100&sort_by=cf:views200
  • GET /api/v1/public/content_items?post_type=page&sort_by=cf:views400 field_not_queryable (the views entry is scoped to article)

[csp]#

Optional Content-Security-Policy configuration. When the section is absent, Lesstruct applies its built-in default CSP (backward compatible, exactly today’s policy plus https://www.youtube-nocookie.com in frame-src — the privacy-enhanced variant of the already-allowed youtube.com). All fields are optional.

The default CSP (structured as an ordered directive table in the binary) is:

1
default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data: blob: https:; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; connect-src 'self'; frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'

Each _src list appends to the directive’s built-in sources — the policy can only become more permissive; nothing existing is replaced. The exceptions are frame_ancestors (dedicated replace knob for that directive — appending to the default 'none' is meaningless per the CSP spec) and policy (complete override, documented as “advanced” — the operator takes ownership). Use extra_directives to add wholly new directives (e.g. worker-src, report-uri).

KeyTypeDefaultDescription
disableboolfalseWhen true, no CSP header is emitted at all. For operators behind a CDN/WAF that manages CSP. The framing floor still applies: X-Frame-Options keeps following frame_ancestors (default DENY).
report_onlyboolfalseWhen true, emits Content-Security-Policy-Report-Only instead, for safe rollout testing.
script_src[]string[]Appended to the directive’s default sources.
style_src[]string[]Appended to the directive’s default sources.
img_src[]string[]Appended to the directive’s default sources.
font_src[]string[]Appended to the directive’s default sources.
connect_src[]string[]Appended to the directive’s default sources.
frame_src[]string[]Appended to the directive’s default sources. Also feeds the HTML sanitizer’s iframe allowlist: <iframe> embeds in HTML-format content are only kept when their host appears in frame-src (defaults + appends). A https://*.host entry allows subdomains only — the apex host must be listed separately (e.g. https://disqus.com); a port or path in an entry narrows matching to it. Root-relative src (same-origin embeds) is always allowed; scheme-relative //host srcs are host-checked like absolute URLs. With a policy override the sanitizer follows the override’s frame-src (an override without frame-src keeps iframes stripped); disable/report_only keep the default-based allowlist as a safety net.
media_src[]string[]Appended to the directive’s default sources.
object_src[]string[]Appended to the directive’s default sources.
worker_src[]string[]Appended to the directive’s default sources.
frame_ancestors[]string[]Replaces the default frame-ancestors 'none' (the only directive with replace semantics — appending to 'none' is meaningless). ["'self'"] allows same-origin framing (demo pages, interactive helpers). Also drives X-Frame-Options: no sources → DENY; "['self']"SAMEORIGIN; a host list (e.g. ["https://embedder.example.com"]) → the header is omitted because X-Frame-Options cannot express host allowlists (the CSP directive is the control then) — except under report_only, where a host list floors to DENY so a rollout trial never silently drops all framing protection. A policy override takes precedence over the knob for this derivation (mirroring the emitted CSP, where the policy replaces everything); 'none' anywhere in the sources maps to DENY; 'self' is matched case-insensitively and duplicates are ignored. Validation rejects entries containing whitespace and lists mixing 'none' with other sources (browsers discard such directives entirely, opening framing up).
extra_directivesmap[string]string{}New directives not in the defaults. Key = directive name, value = sources (or empty for flag directives like upgrade-insecure-requests). Note: adding frame-ancestors here creates a duplicate directive, which browsers ignore in favor of the first occurrence (the default 'none') — use frame_ancestors instead.
policystring""Complete override. When non-empty, replaces the safe builder. The operator takes full responsibility for the resulting policy.

Example — allow same-origin framing (embedded demo pages), Google Analytics, data: URI fonts, and the privacy-enhanced YouTube host:

1
2
3
4
[csp]
frame_ancestors = ["'self'"]
script_src      = ["https://www.googletagmanager.com"]
font_src        = ["data:"]

The CSP is built once at startup. Restart the server to pick up changes.

[[thumbnail]]#

Each entry defines one image processing variant. When media is uploaded, Lesstruct generates one file per variant.

KeyTypeRequiredDescription
max_widthintyesMaximum width in pixels. Must be > 0.
suffixstringnoFilename suffix. Must be unique. The default thumbnail has suffix _thumb.

If no [[thumbnail]] entries are defined, the runtime uses a single default variant: max_width = 370, suffix = "_thumb".

[headless]#

Optional. When enabled = true, the instance serves only the admin panel and the REST API — the server-rendered content site is not served at all. This is the configuration for using Lesstruct purely as a headless CMS with a separate frontend.

1
2
[headless]
enabled = true

Effects of headless mode:

  • The content-site catch-all (/*) and /static/* (theme assets) are not mounted; any non-API path returns 404.
  • /admin/*, /api/*, and /uploads/* keep working.
  • sitemap.xml returns 404 and robots.txt returns Disallow: / (no sitemap reference). The JSON sitemap (GET /api/v1/sitemap) is unaffected — a headless consumer typically reads it from the API.
  • The admin SPA, SSG export, WordPress/Hugo import, and the agent API all keep working unchanged.

Absent block → headless disabled (the default; fully backward compatible).

[comments]#

Optional. When enabled = false, the comment system is hard-disabled end to end. This is intended for instances that do not want comments at all — most importantly it stops self-registration (see below), which otherwise lets anyone create a Commentator account (a role that only exists for commenting).

1
2
[comments]
enabled = false

Effects of disabling comments:

  • All comment routes are unmounted in all three realms: agent/Bearer (/api/v1/content/{id}/comments), public (/api/v1/public/content_items/{slug}/comments), and browser admin (moderation, /api/v1/my-comments). Requests to them 404.
  • Without a [registration] override, POST /api/auth/register returns 403 REGISTRATION_DISABLED, the /register page returns 404, and the login page hides the “create account” link. A [registration] block re-enables all three for the role it names.
  • Admins can no longer assign the Commentator role when creating users (ErrInvalidRole).
  • New content always stores allowComments = false, even if a request explicitly sends allowComments: true; the admin editor hides the “Allow comments” checkbox.
  • The admin UI hides the Comments nav item and redirects comment routes.

Absent block (or enabled omitted) → comments enabled (the default; fully backward compatible). The comment post type itself cannot be hidden via hidden = true — use this block instead.

[[role]]#

Optional. Roles gate what a user may do: which post types they can manage (create/edit/delete), whether they can publish content directly, and whether they can upload media and post comments. Three built-in roles always exist:

RolePost typesPublishMediaCommentsNotes
AdminallyesyesyesReserved superuser. Cannot be redefined.
ContributorallyesyesyesThe default content author.
CommentatornonenoyesyesExists for the comment system.

A [[role]] entry either overrides a built-in (except Admin) or adds a custom role. Overriding a built-in narrows/widens its capabilities in place; overriding Contributor without an explicit post_types keeps its manage-all-types behavior. A new custom role with no post_types manages no content types.

Override semantics. An override replaces the built-in’s capabilities wholesale: keys you omit (publish, media, comments, post_types) are reset to false/empty. To widen a built-in, spell out every capability you want to keep. (This always fails toward less privilege — it can never silently grant more.)

KeyTypeRequiredDescription
namestringyesRole name stored in users.role. 1-200 characters. Admin is reserved and rejected.
post_types[]stringnoPost-type slugs the role may manage (own-content CRUD). Each must reference a post type defined in this file — a typo fails closed at startup.
publishboolnoWhen true, the role may publish content directly; otherwise content is saved as a draft (admins publish).
mediaboolnoWhen true, the role may upload and generate media.
commentsboolnoWhen true, the role may post comments.

Example — a journalist who manages only article content, publishes directly, and comments, but cannot touch media:

1
2
3
4
5
6
[[role]]
name = "Journalist"
post_types = ["article"]
publish = true
media = false
comments = true

Effects:

  • GET /api/v1/post_types (admin) returns only the role’s manageable types; the admin sidebar, content list tabs, and editor type select follow suit.
  • Creating/editing/deleting content of a type the role does not manage returns 403 forbidden (ErrForbiddenPostType).
  • Publishing without the publish capability is rejected (ErrForbiddenPublish); a non-publishing role’s new content is forced to draft.
  • The admin content editor hides the Publish button and the Published status option for roles without the publish capability (the button stays visible for admins). The Unpublish action remains available (see below), and editing an item that is already published keeps its current status selectable.
  • Media endpoints return 403 for roles without media; comment endpoints return 403 for roles without comments.
  • Admins can assign any registered role in the user management UI (the dropdown is populated from GET /api/v1/roles).

Note: a non-publishing role may still set its own published content back to draft (unpublish) — the publish capability gates draft→published only. It cannot re-publish; that requires the publish capability or an admin.

Absent block → only the three built-in roles (the default; fully backward compatible).

[registration]#

Optional. Decouples self-registration from the comment system. Historically registration was enabled iff comments were enabled, because the only self-registerable role — Commentator — was meaningless without them. With custom [[role]] entries a site may want public registration for a different role (e.g. a journalist), so this block overrides the coupling.

KeyTypeDefaultDescription
enabledboolfollows [comments]When set, overrides the comment-system coupling. true = registration allowed (the /register page renders and the login page shows the “create account” link), false = POST /api/auth/register returns 403 REGISTRATION_DISABLED and /register 404s.
default_rolestring"Commentator"Role assigned to new registrants. Must be a registered role (built-in or [[role]]) and cannot be an admin role — a typo or an Admin default fails closed at startup.
admin_approvalboolfalseWhen true, email verification is required before an admin can activate a registrant: approving a user whose email is still unverified fails with 409 EMAIL_NOT_VERIFIED. When false (default), admins may approve pending registrants regardless of email verification (legacy behavior).

Email verification is always mandatory: every registrant must click the link in the verification email before the account can become active. The admin_approval flag only decides when verification happens relative to activation:

admin_approvalVerify-email link resultActivation path
false (default)Marks the email verified and activates the account (verified)Email link alone is enough
trueMarks the email verified; account stays pending with the message “Email verified. An administrator will activate your account.”Admin approval in the registration queue is the only path from pending to active

Example — public registration for a journalist role, with email verification plus admin approval, comments disabled site-wide:

1
2
3
4
5
6
7
[comments]
enabled = false

[registration]
enabled = true
default_role = "Journalist"
admin_approval = true

Absent block → registration enabled iff comments are enabled, default role Commentator, pending until approved (the default; fully backward compatible).

Validation Rules#

These are enforced at startup by the runtime. Violations cause the server to fail to start with a clear error.

Post type rules#

  • name must be 1-200 characters (internal/domain/posttype/types.go:93-99).
  • slug must be 1-200 characters, contain only lowercase letters, digits, hyphens, and underscores; cannot start or end with a hyphen; cannot contain consecutive hyphens (types.go:102-126).
  • supports must be non-empty and each entry must be one of: title, content, tags, featured_image, excerpt (types.go:26-32, 129-146).
  • Duplicate post-type slugs are rejected for new types (types.go:20). Reusing a built-in slug (post, page, media, comment) is not a duplicate — it extends the built-in type by merging fields/system_fields (service.go:Register).

Field rules#

  • name must be 1-200 characters (internal/domain/customfield/types.go:98-104).
  • slug must be 1-200 characters and match the snake-case regex (types.go:106-115).
  • type must be one of: text, textarea, number, date, datetime, email, url, select, checkbox (types.go:35-44, 118-123).
  • select fields must have a non-empty options list (types.go:125-128).
  • number fields can have min and max; text and textarea fields can have max_length. Other combinations are rejected (types.go:141-159).
  • Duplicate field slugs within the same parent (user fields or a single post type) are rejected (types.go:90-93).

File rules#

  • CONFIG_FILE must not contain path separators or .. (internal/config/posttypes.go:19-21).
  • The config directory must exist and be readable; the file is optional (defaults apply if missing).

Role rules#

  • name must be 1-200 characters (internal/domain/role/types.go:43-48).
  • Admin is reserved and cannot be redefined (ErrAdminRoleReserved).
  • A duplicate name is rejected for new roles (ErrDuplicateRole); reusing a built-in name overrides it instead.
  • Every post_types entry must reference a registered post type — a typo fails closed at startup (internal/config/roles.go:62-67).
  • A role entry cannot declare all_types (that flag is internal and derived).

Worked Examples#

Example A — Minimal blog#

A personal blog with one language and the default post types. config.toml only sets the language; everything else falls back to defaults.

1
2
# ── Languages ───────────────────────────────────────────────────────
languages = ["en"]

That’s it. You can omit [[thumbnail]] entirely (the runtime uses the default 370 px _thumb variant). No custom post types, no custom user fields, no custom themes, no plugins.

For a more useful starting point, add a [[thumbnail]] for medium-sized previews:

1
2
3
4
5
6
7
8
9
languages = ["en"]

[[thumbnail]]
max_width = 370
suffix = "_thumb"

[[thumbnail]]
max_width = 800
suffix = "_medium"

Example B — Multilingual site (English + Indonesian)#

A two-language site with a custom user profile (system fields for gamification, regular fields for bio/links).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# ── Languages ───────────────────────────────────────────────────────
# English is primary; Indonesian is supported.
# Both must have translation TOML files in internal/i18n/translations/
# (en.toml, id.toml) — these ship with Lesstruct.
languages = ["en", "id"]

# ── User Profile Fields ─────────────────────────────────────────────
[user_fields]
fields = [
  { name = "Job Title",  slug = "job_title",  type = "text" },
  { name = "Company",    slug = "company",    type = "text" },
  { name = "Website",    slug = "website",    type = "url" },
  { name = "Bio",        slug = "bio",        type = "textarea", max_length = 500 },
]
system_fields = [
  { name = "Points",        slug = "points",        type = "number",   min = 0, max = 100000 },
  { name = "Account Tier",  slug = "account_tier",  type = "select",   options = ["free", "basic", "pro", "enterprise"] },
  { name = "Internal Notes", slug = "internal_notes", type = "textarea", max_length = 1000 },
]

# ── Thumbnail Sizes ─────────────────────────────────────────────────
[[thumbnail]]
max_width = 370
suffix = "_thumb"

[[thumbnail]]
max_width = 800
suffix = "_medium"

[[thumbnail]]
max_width = 1600
suffix = "_large"

The runtime falls back to the default post and page post types (in both languages) for the content schema. Users can write posts and pages; the i18n switcher in the layout lets visitors pick English or Indonesian.

Example C — Shop with custom post types#

A two-post-type content schema: product for a storefront and portfolio for a work showcase. Both have realistic field combinations.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# ── Languages ───────────────────────────────────────────────────────
languages = ["en"]

# ── User Profile Fields ─────────────────────────────────────────────
[user_fields]
fields = [
  { name = "Job Title", slug = "job_title", type = "text" },
  { name = "Company",   slug = "company",   type = "text" },
  { name = "Website",   slug = "website",   type = "url" },
]
system_fields = [
  { name = "Points",        slug = "points",        type = "number", min = 0, max = 100000 },
  { name = "Account Tier",  slug = "account_tier",  type = "select", options = ["free", "basic", "pro", "enterprise"] },
]

# ── Custom Post Types ───────────────────────────────────────────────
# A product catalog with pricing, stock, and category.
[[post_type]]
name = "Product"
slug = "product"
description = "E-commerce product listings"
supports = ["title", "content", "excerpt"]

[[post_type.fields]]
name = "SKU"
slug = "sku"
type = "text"
required = true

[[post_type.fields]]
name = "Price"
slug = "price"
type = "number"
min = 0.01
max = 999999.99
required = true

[[post_type.fields]]
name = "Sale Price"
slug = "sale_price"
type = "number"
min = 0.01
max = 999999.99

[[post_type.fields]]
name = "Stock Quantity"
slug = "stock_quantity"
type = "number"
min = 0
max = 100000

[[post_type.fields]]
name = "Product Details"
slug = "product_details"
type = "textarea"
max_length = 2000

[[post_type.fields]]
name = "Release Date"
slug = "release_date"
type = "date"

[[post_type.fields]]
name = "Category"
slug = "category"
type = "select"
options = ["Electronics", "Clothing", "Home & Garden", "Books", "Toys"]
required = true

[[post_type.fields]]
name = "Size"
slug = "size"
type = "select"
options = ["XS", "S", "M", "L", "XL", "XXL"]

[[post_type.fields]]
name = "On Sale"
slug = "on_sale"
type = "checkbox"

[[post_type.fields]]
name = "Free Shipping"
slug = "free_shipping"
type = "checkbox"

[[post_type.system_fields]]
name = "Fulfillment Status"
slug = "fulfillment_status"
type = "select"
options = ["unfulfilled", "partial", "fulfilled", "returned"]

[[post_type.system_fields]]
name = "Warehouse Code"
slug = "warehouse_code"
type = "text"

# A portfolio / case-study post type with project metadata.
[[post_type]]
name = "Portfolio"
slug = "portfolio"
description = "Portfolio items to showcase work"
supports = ["title", "content", "tags", "featured_image", "excerpt"]

[[post_type.fields]]
name = "Client Name"
slug = "client_name"
type = "text"
required = true

[[post_type.fields]]
name = "Project Description"
slug = "project_description"
type = "textarea"
max_length = 1000

[[post_type.fields]]
name = "Project Date"
slug = "project_date"
type = "date"
required = true

[[post_type.fields]]
name = "Budget"
slug = "budget"
type = "number"
min = 0
max = 1000000

[[post_type.fields]]
name = "Project Type"
slug = "project_type"
type = "select"
options = ["Website", "Mobile App", "Desktop App", "API", "Consulting"]

[[post_type.fields]]
name = "Published"
slug = "published"
type = "checkbox"

[[post_type.system_fields]]
name = "Portfolio Status"
slug = "portfolio_status"
type = "select"
options = ["draft", "in_review", "approved", "archived"]

[[post_type.system_fields]]
name = "Internal Notes"
slug = "internal_notes"
type = "textarea"
max_length = 500

# ── Thumbnail Sizes ─────────────────────────────────────────────────
[[thumbnail]]
max_width = 370
suffix = "_thumb"

[[thumbnail]]
max_width = 800
suffix = "_medium"

[[thumbnail]]
max_width = 1600
suffix = "_large"

Pair this with a .env that enables the AI integrations, e.g.:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Database (SQLite is fine for a small shop)
DB_DRIVER=sqlite
DB_PATH=data/shop.db

# JWT
JWT_SECRET=replace_me_with_a_32_plus_character_random_string

# SMTP
SMTP_HOST=sandbox.smtp.mailtrap.io
SMTP_PORT=587
SMTP_USER=your_user
SMTP_PASSWORD=your_pass
SMTP_FROM=orders@example.com

# CORS
CORS_ALLOWED_ORIGINS=https://shop.example.com,https://admin.example.com

# Site
SITE_URL=https://shop.example.com

# Theme (optional)
THEME_DIR=themes/custom

# AI image generation (optional)
AI_IMAGE_GENERATION_API_KEY=sk-...
AI_IMAGE_GENERATION_MODEL=gpt-image-1-mini

# AI text generation (optional, e.g. DeepSeek)
AI_TEXT_GENERATION_API_KEY=sk-...
AI_TEXT_GENERATION_BASE_URL=https://api.deepseek.com
AI_TEXT_GENERATION_MODEL=deepseek-chat

Example D — Role-scoped journalism site with open registration#

A magazine where registered readers can write articles, a small editorial team publishes them, and media stays admin-only. Registration is decoupled from the comment system and auto-verified.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
languages = ["en"]

[user_fields]
fields = [
  { name = "Bio", slug = "bio", type = "textarea", max_length = 500 },
]

[[post_type]]
name = "Article"
slug = "article"
description = "Long-form articles"
supports = ["title", "content", "tags", "featured_image", "excerpt"]

# Readers submit drafts; the editorial team publishes them.
[[role]]
name = "Journalist"
post_types = ["article"]
publish = false
media = false
comments = true

# The editor role manages everything except media, and publishes directly.
[[role]]
name = "Editor"
post_types = ["article", "page"]
publish = true
media = false
comments = true

[registration]
enabled = true
default_role = "Journalist"
admin_approval = true

With this config: new registrants become Journalist (articles only, drafts, comments allowed, no media); editors publish articles/pages; the built-in Admin keeps the full surface including media and user management.

What is NOT Configurable from config.toml#

config.toml and the env vars cover deployment and content schema, but several other surfaces are configured elsewhere:

SurfaceHow to configureReference
Public site theme (CSS, JS, HTML templates)THEME_DIR=themes/<name> env var → a themes/<name>/ directoryskills/lesstruct-theme-development/
WASM plugins (custom hooks, external API calls)<name>.wasm and <name>.manifest in plugins/skills/lesstruct-plugin-development/
Admin panel branding (logo, colors, copy)Edit web/admin/ source and rebuildmake build-admin
API response shapesEdit internal/api/handlers/source only
CLI flagslesstruct-cli --helpbuilt-in

Plugins and themes are loaded at startup and hot-reloaded only when DEV_MODE=true is set (and even then, only the plugin watcher is recursive; the theme is not). Admin and API changes always require a rebuild / redeploy.

Upgrading Lesstruct#

When you bump the Lesstruct version (via go install github.com/aristorinjuang/lesstruct@<version> or a new release tarball):

  1. Back up your config.toml and .env. New versions may add fields that your old config doesn’t have; the runtime applies sensible defaults for any field that is missing.
  2. Diff the new config.toml.example and .env.example against your files. Lesstruct’s release notes call out new env vars; copy them into your .env only if you need the feature.
  3. Validate before starting. Start the server with the new binary. If config.toml has a new validation rule (e.g. a new field type), the runtime reports it at startup. Fix and retry.
  4. New field types or supported features are documented here. If you see a new entry in the Validation Rules section, your existing config.toml will keep working; only new post types you add will need to use the new field types.
  5. Theme and plugin skills ship independently of the runtime. After a runtime upgrade, re-run the theme and plugin skills to compare your themes/<name>/ and plugins/<name>.wasm against any new defaults.

Troubleshooting#

JWT_SECRET is required at startup#

You didn’t set JWT_SECRET in .env (or it’s empty). It must be present and at least 32 characters:

1
JWT_SECRET=$(head -c 48 /dev/urandom | base64)

unsupported DB_DRIVER "X"#

DB_DRIVER must be sqlite, postgres, or mysql. The runtime rejects other values at startup.

DB_DSN must contain parseTime=true (MySQL)#

The MySQL DSN is missing the parseTime=true query parameter. Without it, DATE columns scan as []byte. Add it to the DSN:

1
user:pass@tcp(host:port)/db?parseTime=true&multiStatements=true&charset=utf8mb4&collation=utf8mb4_general_ci

DB_DSN must contain multiStatements=true (MySQL)#

Same fix as above — the multiStatements=true parameter is required for golang-migrate to run migrations with multiple SQL statements.

post type slug "X" is invalid#

X violates the slug rules: it must be kebab-case, lowercase letters/digits/hyphens/underscores only, no leading/trailing hyphens, no consecutive hyphens (--). Examples:

  • product, team-member, case_study
  • Product (uppercase), -product (leading hyphen), team--member (consecutive hyphens), team.member (period not allowed)

field "x": duplicate slug#

Two fields in the same parent (user fields, or a single post type) have the same slug. Slugs must be unique within a parent.

field type must be one of: text, textarea, number, date, datetime, email, url, select, checkbox#

Typo in the type field, or a new field type that this version of Lesstruct doesn’t support. Check the Field schema table for the current list.

select field requires non-empty options#

A select field has no options = [...] list. Add at least one option.

CONFIG_FILE must not contain path separators#

You tried to set CONFIG_FILE to a path like config/shop.toml. The runtime only supports a flat filename in CONFIG_DIR; subdirectories are not allowed.

Theme changes are not taking effect#

Cross-reference the lesstruct-theme-development skill. Common causes: THEME_DIR is empty, points to a missing directory, or the server was not restarted after the last change.

Plugin hooks are not firing#

Cross-reference the lesstruct-plugin-development skill. The currently-invoked hooks are before_save (create, update, and admin system-fields updates), after_create, after_publish, before_delete, and after_unpublish. on_plugin_loaded is defined but not invoked today.

Env var appears to have no effect (.env)#

Check for duplicate keys in .env. The godotenv parser uses last-value-wins, so a later THEME_DIR= line silently overrides an earlier THEME_DIR=themes/dark-warm.

Quick Reference#

All env vars (with defaults)#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
HOST=0.0.0.0
PORT=8080
DB_DRIVER=sqlite
DB_PATH=data/lesstruct.db
DB_DSN=
DB_POOL_MAX_CONNS=20
JWT_SECRET=                  # required, ≥ 32 chars
API_KEY_PEPPER=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
CORS_ALLOWED_ORIGINS=http://localhost:5173
SITE_URL=http://localhost:8080
DEV_MODE=false
ADMIN_DEV_URL=http://localhost:5173
THEME_DIR=
LOG_LEVEL=info
RATE_LIMIT_ENABLED=true
RATE_LIMIT_AUTH_PER_MINUTE=5
RATE_LIMIT_API_PER_MINUTE=100
RATE_LIMIT_PUBLIC_PER_MINUTE=60
IMPORT_MAX_SIZE_MB=100
AI_IMAGE_GENERATION_API_KEY=
AI_IMAGE_GENERATION_MODEL=imagen-4.0-fast-generate-001
AI_IMAGE_GENERATION_SIZE=
AI_IMAGE_GENERATION_ASPECT_RATIO=
AI_TEXT_GENERATION_API_KEY=
AI_TEXT_GENERATION_BASE_URL=
AI_TEXT_GENERATION_MODEL=gpt-5-mini

All config.toml keys#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
languages = ["en"]

[site_config]
name = ""                        # optional; defaults to "Lesstruct"
logo = ""                        # optional; image URL/path, empty = render name as text

[user_fields]
fields = [{ name, slug, type, required, ... }]
system_fields = [{ name, slug, type, ... }]

[[post_type]]
name = ""                        # required, 1-200 chars
slug = ""                        # required, kebab-case
description = ""
supports = ["title", "content", "tags", "featured_image", "excerpt"]   # ≥ 1
fields = [{ name, slug, type, required, ... }]
system_fields = [{ name, slug, type, ... }]

[csp]
disable = false                  # omit CSP entirely when true
report_only = false              # emit Content-Security-Policy-Report-Only
script_src = []                  # appended to default sources (same for style/img/font/connect/frame/media/object/worker)
extra_directives = {}            # new directive: "directive-name" = "sources" (or "" for flag directives)
policy = ""                      # full override (operator takes ownership)

[[thumbnail]]
max_width = 370                  # > 0
suffix = "_thumb"                # unique

[[role]]
name = ""                        # required, 1-200 chars; "Admin" reserved
post_types = []                  # post-type slugs the role may manage
publish = false                  # may publish content directly
media = false                    # may upload / generate media
comments = false                 # may post comments

[registration]
enabled = true                   # absent: follows [comments]
default_role = "Commentator"
admin_approval = false           # true: admin must approve after email verification

All field types#

TypeRequired sub-keysOptional sub-keys
textname, slug, typerequired, max_length
textareaname, slug, typerequired, max_length
numbername, slug, typerequired, min, max
datename, slug, typerequired
datetimename, slug, typerequired
emailname, slug, typerequired
urlname, slug, typerequired
selectname, slug, type, options (non-empty)required
checkboxname, slug, typerequired

All supported supports values#

title, content, tags, featured_image, excerpt.