A self-hosted CMS built on Astro. SSR public site, admin panel at /admin, MySQL + Drizzle, session-cookie auth, a TipTap block editor, and a runtime theme system with a hooks API.
- Astro 7 with Node adapter (
output: 'server') - MySQL via
mysql2+ Drizzle ORM - React island for the TipTap rich-text editor
- Eta templates for runtime themes
- Argon2 password hashing (
@node-rs/argon2) - HTML sanitization with DOMPurify
- Zod for form validation
- Posts — draft/publish workflow, slug auto-generation, rich-text editor (headings, lists, blockquotes, code blocks, links, inline code)
- Categories — built-in
news,travel,gadgets,reviewswith a per-post select in the editor - Media library — upload images, video, and PDFs (10 MB limit) stored under
public/uploads/with metadata in MySQL - Users & roles — four system roles (
admin,editor,author,subscriber) plus custom roles with per-permission grants, managed from the admin - Comments — per-post comments on the public site with a moderation queue in the admin and optional Google reCAPTCHA v2 spam protection
- Settings — site title/description, active theme, reCAPTCHA keys, password change
- Collapsible sidebar — full or rail mode, persisted per-user via cookie
- View site — one-click open of the public site in a new tab
- Analytics — cookieless, server-side page-view stats with a views-per-day chart, top pages, referrers, and device split (see Analytics)
- Form-POST mutations throughout — works without JavaScript, plays nicely with progressive enhancement
- Runtime theme system — themes are folders of Eta templates plus a
theme.jsonmanifest, loaded at request time (no rebuild needed) - Uploadable themes — drop a zip in the admin; install/uninstall from the Themes page. Bundled themes can't be uninstalled.
- Hooks API —
addFilter/applyFilters/addAction/doActionwith priorities, wired by core forthe_title,the_content,posts_list, andpost_render - Security — themes ship templates and assets only. JavaScript inside theme zips is never loaded server-side; that would be RCE-by-design.
- Dark / light slider switch in the header with no first-paint flash (inline blocking script reads
localStorage+prefers-color-scheme) - Featured grid of image cards on the homepage with a hover effect
- Tabbed category section — News / Travel / Gadgets / Reviews with horizontal swipe transitions and full keyboard navigation
- Responsive header with a hamburger nav at narrow widths
- Token-driven CSS — components use design tokens so dark mode is one selector
- Server-rendered post list at
/and post detail at/posts/[slug] - Full-text search at
/search - Visitor accounts — public login/register pages; self-signups land in the
subscriberrole - Drafts are never exposed publicly
- Node
>=22.22.2 - MySQL 8+ (any flavor: MySQL Community Edition, MariaDB, Amazon RDS, PlanetScale via the standard driver, etc.)
You need a running MySQL 8+ server with an empty database (Zyphora won't create or drop databases).
No env vars, no CLI scripts:
npm install
npm run dev # http://localhost:4321 — the installer takes overEvery request is funneled to /install until the CMS is set up. The wizard collects your DB credentials (tests the connection, then writes them to .env), applies migrations, seeds the system roles, and creates your admin account from a form. When it finishes you're logged in and /install disappears.
Set the DB env vars and run the CLI scripts directly — they share their logic with the installer, so the end state is identical:
export DB_HOST=localhost
export DB_PORT=3306 # optional, defaults to 3306
export DB_USER=zyphora
export DB_PASS=...
export DB_NAME=zyphoranpm install
npm run db:migrate
npm run db:seed # creates system roles + admin@zyphora.local / changeme123
npm run db:seed-posts # optional: ~7 demo posts spread across categories
npm run dev # http://localhost:4321Then visit http://localhost:4321/admin/login and sign in. Change the password in Settings → Change your password immediately.
You can override the seed credentials with environment variables:
SEED_ADMIN_EMAIL=you@example.com SEED_ADMIN_PASSWORD=secret SEED_ADMIN_NAME="Your Name" npm run db:seedBoth seed scripts are idempotent — re-running them is safe.
| Command | Description |
|---|---|
npm run dev |
Start the dev server at http://localhost:4321 |
npm run build |
Build to ./dist/ (Node standalone server) |
npm run preview |
Run the production build locally |
npm run db:generate |
Generate a new Drizzle migration from schema.ts |
npm run db:migrate |
Apply pending migrations to the configured MySQL database |
npm run db:seed |
Idempotent — seeds system roles, first admin, default settings |
npm run db:seed-posts |
Idempotent — inserts ~7 demo posts spread across categories |
npm run db:studio |
Open the Drizzle Studio DB browser |
There is no test runner, linter, or formatter installed by default. npm run astro -- check runs the Astro / TypeScript checker.
src/
├── components/ React islands (TipTap editor)
├── db/ Drizzle schema, client, migrate / seed scripts
├── layouts/ AdminLayout, SiteLayout
├── lib/ auth, posts, media, settings, sanitize, slug
│ └── themes/ registry, install, render, hooks (WP-style)
├── middleware.ts session lookup + admin route guard
├── pages/
│ ├── admin/ dashboard, posts CRUD, media, comments, themes, users, roles, settings
│ ├── install/ first-run web installer (DB credentials, migrate, seed, admin account)
│ ├── posts/[slug] public post detail
│ ├── themes/[…] theme asset serving
│ ├── search.astro public full-text search
│ ├── login.astro visitor login (register.astro for signups)
│ └── index.astro public home (post list)
└── styles/
themes/
└── default/ bundled theme (theme.json + Eta templates + assets)
drizzle/ generated SQL migrations
public/ static assets and uploads (uploads gitignored)
Database connection — written to .env by the web installer, or set manually for headless deploys. The server boots without them (so the installer can run); anything that actually touches the DB fails with a clear error until they're present:
| Variable | Default | Description |
|---|---|---|
DB_HOST |
— | MySQL host |
DB_PORT |
3306 |
MySQL port |
DB_USER |
— | MySQL user |
DB_PASS |
— | MySQL password |
DB_NAME |
— | MySQL database / schema |
Seed script (all optional):
| Variable | Default | Description |
|---|---|---|
SEED_ADMIN_EMAIL |
admin@zyphora.local |
First admin email (seed script) |
SEED_ADMIN_PASSWORD |
changeme123 |
First admin password (seed script) |
SEED_ADMIN_NAME |
Admin |
First admin display name |
A theme is a directory under themes/<slug>/ containing:
theme.json— manifest (slug,name,version,author,description)templates/*.eta— at minimumindex.eta(post list) andpost.eta(single post);404.etais optionalassets/— anything served at/themes/<slug>/<path>(CSS, JS, images, fonts)
Templates render against a typed RenderContext (see src/lib/themes/types.ts) and emit content through the hooks pipeline. Post HTML is sanitized server-side on write, so the templates can render it raw with <%~ post.contentHtml %>.
To install a third-party theme, zip the folder so theme.json is at the top level (or under a single wrapper directory) and upload from Admin → Themes. Zip-slip and zip-bomb guards apply (5 MB compressed / 25 MB uncompressed).
Zyphora counts page views on the public site itself — no third-party script, no client-side tracker, no cookies. The report lives at Admin → Analytics (7 / 30 / 90-day views, unique visitors, views per day, top pages, top referrers, devices) and a 7-day views tile appears on the dashboard. Admins and editors get the view_analytics permission by default; grant it to other roles from Admin → Roles.
What's recorded, per view of / or a published post (/posts/<slug>) that renders successfully:
- the path (and the post it belongs to)
- the referring hostname only — never the full URL, which can carry search terms or tokens; same-site navigation counts as direct
- a coarse device class (desktop / mobile / tablet)
- a visitor hash: SHA-256 of a per-site secret salt, the current UTC date, the IP address, and the user-agent
What's not recorded: IP addresses, user-agents, cookies, or anything that links a visitor across days. The date in the hash means the same person gets an unrelated hash tomorrow, so "unique visitors" is counted per day (someone visiting on three days counts three times). Bots and link-preview fetchers are skipped by user-agent, and browsers sending DNT: 1 or Sec-GPC: 1 are not counted at all.
Settings (Admin → Settings → Analytics):
| Setting | Default | Description |
|---|---|---|
| Record page views | on | Master switch. Off stops recording; existing data stays viewable |
| Don't count logged-in users | on | Keeps staff previewing their own posts out of the numbers |
| Keep data for (days) | 365 |
30–3650. Older rows are deleted automatically (checked hourly) |
To disable analytics entirely, untick Record page views. To wipe what's been collected, TRUNCATE TABLE pageviews;.
Behind a reverse proxy, make sure it sets X-Forwarded-For — the first address is used (only as hash input) so visitors aren't all collapsed into the proxy's IP.
npm run build
node ./dist/server/entry.mjsThe built server is a standalone Node process. Place it behind a reverse proxy (nginx, Caddy) and serve public/uploads/ either from the same Node server (default) or from a static file server / CDN.
Sessions currently live in MySQL alongside everything else. The app server itself is stateless (modulo connection pooling), so multiple app nodes can point at the same MySQL primary — but for horizontal scaling where every login/logout round-trip would otherwise hit the DB, moving sessions to Redis is the obvious next step.
Major work on deck:
- Plugins — uploadable plugin system on top of the existing hooks registry. Themes will stay runtime-template-only; plugins get their own threat model and a clear extension API.
- Email — outbound SMTP for transactional notifications (new comment, password reset, mentions) and an admin section for templates and delivery logs.
- Default theme — keep iterating: pagination, tags, author pages, archives by category, OG/Twitter card metadata.
Smaller items still on the list:
- Pages (vs. posts) with hierarchy
- Tags (categories already shipped)
- RSS / Atom feed
- Image resizing and responsive
srcset - Pluggable storage adapter (S3, R2) for media