Content management system and blogging platform. Single binary, multiple database backends, static site generation with live admin.
- Dual database: PostgreSQL and SQLite, auto-detected from DSN
- Page types: Generic Page, Blog Index, Gallery
- Posts: Separate content type with categories and tags
- Per-page sidebar: Each page chooses sidebar mode — None, Categories, Blog, or Attachments
- Gallery: Dedicated gallery page type with sortable media picker, captions, and multiple layouts (grid, masonry, slideshow)
- Static publishing: Generates HTML output per page, served by the built-in file server
- Admin panel: SPA-style JSON API with vanilla JS frontend, drag-drop media uploads
- Media pipeline: Image upload, dimension extraction, automatic thumbnail generation; MIME type whitelist, configurable upload size and per-user quota
- Theme system: File-based (no database), Core and Neon stock themes on embedded FS, on-disk themes override embedded
- Taxonomies: Hierarchical categories and free-form tags
- Role-based permissions: Administrator, Editor, Author, Contributor — per-site role enforcement on every endpoint
- Attribution profiles: Personal profiles auto-created per user, group profiles for collaborative authorship; toggle between personal byline and generic attribution per site
- Page attachments: File download lists with configurable sidebar widget
and
[attachments]shortcode for inline content - Plugin system: Tengo-based scripting (embedded, no external runtime). Editor buttons, content filters, pre/post-publish triggers, admin tool pages, custom page types, sidebar widgets
- API key management: Create and revoke API keys with secret-once display
- Import/Export: Full JSON export/import of site content, plus WordPress WXR import (pages, posts, categories, tags)
- Multi-site: Domain-based site resolution, site switcher in admin panel, admin CRUD for sites
- Navigation: Customisable navbars and navbar items per site
- Hotlink protection: Configurable per-site, restricts media embedding to allowed domains
- Security: Rate limiting (token bucket), IP blocking (DB-backed with expiry), API key authentication, CSRF protection, bcrypt passwords
- Session management: Cookie-based sessions with sliding expiry and IP validation
- Notifications: Persistent error toasts with manual dismiss, auto-hiding success toasts
- Migrations: Versioned per-dialect
.sqlfiles, run automatically on startup
# Create an admin user (exits after creation)
nous --create-user admin@example.com:mypassword:admin --db /path/to/data.db
# Start the server (migrations run automatically)
nous --db /path/to/data.db
# Open http://127.0.0.1:62208/_admin/ and log inFor a running container, use docker exec or podman exec:
docker exec <container> nous --create-user email:password:admin
podman exec <container> nous --create-user email:password:adminIf the database uses a non-default DSN, pass the same --db the container
was started with.
In Kubernetes:
kubectl exec <pod> -- nous --create-user email:password:adminOr as a one-shot Job (needs the same DSN env vars as the deployment):
kubectl create job --image=<image> nous-bootstrap -- nous --create-user email:password:adminAll settings can be passed as CLI flags, environment variables, or both.
| Flag | Env Variable | Default | Description |
|---|---|---|---|
--db |
NOUS_DSN |
~/.nous/nous.db |
Database DSN |
--port, -p |
NOUS_PORT |
62208 |
Listen port |
--host |
NOUS_HOST |
127.0.0.1 |
Listen address |
--data |
NOUS_DATA |
~/.nous |
Data directory |
--admin-path |
NOUS_ADMIN_PATH |
/_admin/ |
Admin panel path |
--session-ttl |
NOUS_SESSION_TTL |
1440 |
Session TTL in minutes |
--site-name |
NOUS_SITE_NAME |
My Site |
Default site name |
--secure-cookies |
NOUS_SECURE_COOKIES |
false |
Set Secure flag on session and CSRF cookies |
--single-site |
NOUS_SINGLE_SITE |
false |
Disable multisite features |
Database DSNs starting with postgres:// or postgresql:// use PostgreSQL.
Anything else (including empty) uses SQLite.
export NOUS_DSN="postgres://user:password@localhost:5432/nous?sslmode=disable"
nous--dev— Development mode (live template reloading, verbose logging)--verbose,-v— Verbose output--version— Print version and exit--create-user,-C— Create user and exit. Format:email:password[:role[:display_name]]Role defaults tocontributor. Display name defaults to the email address.
Four role levels, enforced per-endpoint:
| Role | Capabilities |
|---|---|
| Administrator | Full access: users, sites, themes, settings, all content, publishing |
| Editor | Content management: pages, posts, categories, tags, navbars, galleries, API keys, attribution profiles, attachments, publishing |
| Author | Own posts (create, edit, publish own), media upload to own folder |
| Contributor | Own draft posts (cannot publish), media upload to personal folder (image/pdf/markdown/office types only, 10 MB max, 500 MB quota) |
Authors and contributors can only edit their own posts. The --create-user
flag defaults to contributor role.
Blog posts can show a personal byline or use generic attribution.
- Personal profiles: Auto-created per user per site when the user first interacts with the system. Profile name is synced with the user's display name.
- Group profiles: Created by editors in the Admin → Profiles tab. Multiple users can be members, useful for team-authored content.
- Site setting:
blog_attributioncan bepersonal(show profile name) orgeneric(no byline). Toggle in Admin → Settings → General. - Post editor: Shows available profiles based on the user's role. Editors see all profiles; authors/contributors see their own.
Pages and posts can have file attachments displayed as download links.
- Sidebar mode: Select "Attachments" in the page editor sidebar settings to show the attachment list as a sidebar widget. Title is configurable per page (defaults to "Downloads").
- Shortcode: Place
[attachments]in the page content to embed the download list inline. Works for all page types including posts. - Management: Add/remove attachments in the page editor. Each attachment has a media ID and display title. Order is preserved.
Nous includes an embedded Tengo (Go-based scripting language) runtime for plugins. Plugins run server-side during content editing and publishing — they are not executed on page hits.
Each plugin is a .tengo file placed in {data}/plugins/. The engine
compiles plugins to bytecode on startup, caches the compiled result, and
clones it for concurrent invocations.
Communication follows a simple globals protocol:
Go sets: action (string) — "register" or "invoke"
args (map) — per-invocation context (capability, method, ...)
Plugin sets: result (any) — output (html, content, data, ...)
During registration (action = "register"), the plugin sets metadata
globals (plugin_name, plugin_version, plugin_description) and
capability globals (editor_buttons, filters, triggers, admin_tools,
page_types, sidebar_widgets).
All subsequent calls use action = "invoke" with the capability dispatched
via args.capability.
The project ships an example in plugins/tip-box.tengo:
plugin_name := "Tip Box"
plugin_version := "1.0.0"
plugin_description := "Inserts a styled tip box into the editor content"
editor_buttons := [{id: "insert-tip", label: "Tip"}]
if action == "invoke" && args.capability == "editor_buttons" {
if args.button_id == "insert-tip" {
body := "<em>Tip</em>"
if args.selection != "" {
body = "<strong>Tip:</strong> " + args.selection
}
result = {html: '<div class="tip-box">' + body + '</div>'}
}
}
Place this in {data}/plugins/tip-box.tengo and restart the server.
A "Tip" button will appear in the content editor toolbar.
| Capability | args fields | expected result |
|---|---|---|
editor_buttons |
button_id, selection |
{ html: "..." } |
filters |
name, content, user_role |
{ content: "..." } |
triggers |
name, event, page, user |
{ allow: true/false, error: "..." } |
admin_tools |
method (page|rpc), tool_id |
{ html, js } or { data } |
page_types |
type_id, config, page |
{ content: "..." } |
sidebar_widgets |
widget_id, site_id |
{ html: "..." } |
Plugins can import these modules using the import() builtin:
| Module | Functions |
|---|---|
db |
query(sql, params) → [{col: val}] |
schema |
create_table(name, [{name, type}]), drop_table(name), exec(sql) |
log |
info(msg), warn(msg), error(msg) |
text |
replace(s, old, new [, n]), contains(s, sub), has_prefix, has_suffix, trim, lower, upper, split(s, sep), join(items..., sep) |
util |
slugify(s), truncate(s, max), json_encode(v), json_decode(s) |
- Editor buttons: Loaded dynamically into the Quill toolbar when editing pages or posts. Click invokes the plugin and inserts returned HTML at cursor.
- Pre-publish filters: Run on page content before save. Can sanitise or transform content.
- Pre-publish triggers: Run before publishing. Can block with an error message.
- Post-publish triggers: Fire-and-forget after publish succeeds. Useful for webhooks or notifications.
- Admin tools: Listed on the Plugins admin page. Opens in a modal with
a JSON-RPC helper (
pluginAdminRPC) for tool-specific UI. - Page types: Plugin-defined page types appear as selectable type cards in the page editor. The plugin renders the full page content at publish time.
Import and export are available through the admin panel. The admin API supports full JSON site export/import (pages, categories, tags, settings, navbars, sidebars) and WordPress WXR import (pages, posts, categories, tags).
Migrations run automatically at startup. They live in
internal/database/migrations/ as per-dialect .sql files
(001_postgres.sql, 001_sqlite.sql).
| Version | Changes |
|---|---|
| v1 | Initial schema: all core tables |
| v2 | Role remap (admin→administrator, user→contributor), attribution profiles, profile members |
| v9 | Attribution profiles for existing databases (backfills personal profiles) |
| v10 | Page attachments table, pages.attachments_title column |
The schema_migrations table tracks which versions have been applied.
nous
├── Config (flags + env vars)
├── Database (PostgreSQL or SQLite)
├── Session store (database-backed cookie sessions)
├── Template engine (embedded + filesystem fallback)
├── Publisher (static HTML generation, per-page sidebar composition)
├── Media processor (thumbnail generation, MIME/size enforcement)
├── Plugin engine (Tengo bytecode VM, module system)
├── Exchange (export/import)
└── HTTP server
├── Admin API (/_admin/)
│ ├── Pages, Posts, Media, Categories, Tags
│ ├── Galleries, Navbars, Sites, Themes, Users
│ ├── Settings, API Keys, Profiles, Attachments
│ └── Plugins (invoke, admin RPC)
├── Public files (/)
│ ├── Static files (from published output)
│ ├── /_search (HTMX)
│ ├── /_posts (HTMX)
│ └── /_media/ (file serving)
└── Middleware
├── Recovery
├── Logging
├── CORS
├── Security headers
├── Rate limiter
├── IP blocker
├── Session validation
└── CSRF protection
{data}/
nous.db (SQLite database)
plugins/ (Tengo plugin files, *.tengo)
tip-box.tengo
...
themes/ (on-disk theme overrides)
neon/
theme.json
...
sites/
{id}/
public/ (published static files, served at /)
index.html
{slug}/index.html
media/ (uploaded files, served at /_media/)
{id}-{filename}
thumb/{id}-{filename}
Themes live entirely on the filesystem — no database involved. Stock themes
(Core, Neon) are embedded in the binary at build time. On-disk themes under
{data}/themes/ take precedence; the admin "fork" button copies a stock
theme there for editing. A theme is stock if its name matches an embedded
directory; otherwise it is user-created.
Full API documentation including all endpoints, authentication, CSRF protection, and rate limiting is in API.md.
go build -ldflags="-s -w \
-X main.version=$(git describe --tags --always 2>/dev/null || echo dev) \
-X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo '') \
-X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo '') \
-X main.dirty=$(git diff-index --quiet HEAD 2>/dev/null || echo '1')"The project includes a fiat build file understood by
creo:
| Command | Description |
|---|---|
creo |
Production build (stripped, version injected) |
creo -F |
Force overwrite existing binary |
creo -c |
Clean build artifacts |
creo debug |
Debug build (no stripping) |
creo linux |
Cross-compile for linux/amd64 |
creo image |
Build OCI container image |
creo vet |
Run go vet ./... |
creo test |
Run vet then all tests |
nous --devDevelopment mode enables:
- Live template reloading from disk (no restart needed)
- Verbose logging output
Template overrides can be placed in {data}/templates/ and will take
precedence over the embedded defaults.
MIT