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.
output: 'server')mysql2 + Drizzle ORM@node-rs/argon2)news, travel, gadgets, reviews with a per-post select in the editorpublic/uploads/ with metadata in MySQLadmin, editor, author, subscriber) plus custom roles with per-permission grants, managed from the admintheme.json manifest, loaded at request time (no rebuild needed)addFilter / applyFilters / addAction / doAction with priorities, wired by core for the_title, the_content, posts_list, and post_renderlocalStorage + prefers-color-scheme)/ and post detail at /posts/[slug]/searchsubscriber role>=22.22.2You 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 over
Every 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=zyphora
npm 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:4321
Then 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:seed
Both 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 minimum index.eta (post list) and post.eta (single post); 404.eta is 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:
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.mjs
The 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:
Smaller items still on the list:
srcset