Nano CMS
A flat-file PHP blog engine for adding SEO blog content to existing static HTML/CSS sites. No database, no framework, no scope creep - just Markdown files, hand-written PHP, and a portable admin you upload only when you need it.
The problem Nano CMS solves
Many client websites should stay as fast, simple static HTML, but still need a steady stream of fresh blog content for search ranking. Nano CMS exists for that exact gap. Rather than rebuilding a perfectly good static site as a WordPress installation - inheriting a database, plugin updates, brute-force attempts, and a permanent admin panel for the sake of four blog posts a year - Nano CMS slots a small blog engine into an existing static site with minimal disruption. It is deliberately not a general-purpose CMS. It does one thing - serve a blog with strong SEO output - and tries to do it well in as little code as possible. Total target size: roughly 2,200-2,500 lines across the frontend and admin codebases combined.
How it works
Five design choices that define the whole project.
Markdown files are the database
One post = one .md file in /posts/ with YAML-style frontmatter. No MySQL, no SQLite, no setup wizard for a database connection. Backups are a folder copy.
Frontend lives inside the site
The reader-facing code sits permanently inside the client's webroot. It renders posts, builds indexes, generates a sitemap and RSS feed, and emits full SEO metadata on every page.
Admin is portable, not permanent
The admin folder is identical for every deployment. Upload it via SFTP when you want to publish, remove it afterwards. No persistent admin = drastically reduced attack surface on client sites.
Secrets live outside webroot
The password hash, site settings, and rate-limit state sit in JSON files above the public folder - structurally unreachable via HTTP, even if Apache misconfigures itself.
No frameworks, no build step
No Bootstrap, no Tailwind, no React, no jQuery, no webpack. Hand-written PHP, scoped CSS, minimal vanilla JavaScript. The whole project is small enough to read in a single sitting.
Inherits the host site's CSS
Posts share the existing site's stylesheet, so the blog matches the design without a theme system. A small nano.css ships as a neutral default if the host site is unstyled.
Architecture in one diagram
The whole system in two boxes - what stays on the client server permanently, and what only exists during publishing.
+-----------------------------------------------------------------+ | CLIENT SITE (permanent) | | | | /public_html/blog/ | | |-- posts/ <-- Markdown files (the "database") | | |-- media/ <-- uploaded images | | |-- assets/ <-- nano.css, optional theme overrides | | |-- core.php <-- parser, renderer | | |-- index.php <-- blog listing | | |-- post.php <-- single post | | |-- template.php <-- per-site HTML wrapper | | |-- bootstrap.php <-- per-site config paths | | |-- sitemap.xml <-- regenerated on save | | +-- feed.xml <-- regenerated on save | | | | /blog-config/ <-- OUTSIDE webroot | | |-- config.json <-- password hash, site settings | | +-- rate-limit.json <-- login attempt tracking | +-----------------------------------------------------------------+ +-----------------------------------------------------------------+ | UNIVERSAL PORTABLE ADMIN (ephemeral) | | | | Uploaded to /public_html/blog/admin/ when publishing. | | Removed afterwards. Identical for every deployment. | | Contains zero site-specific data. | +-----------------------------------------------------------------+
Who Nano CMS is for
Nano CMS is built for web developers who maintain static sites for clients and want to add ranking blog content without taking on the weight of a full CMS. The tool assumes operators are fluent with Markdown and comfortable with SFTP - it is not aimed at non-technical end users. For a non-technical site owner who needs a WYSIWYG editor and a permanent admin login, WordPress is the right answer. If, on the other hand, you've ever installed WordPress just to publish four blog posts a year on a client site - and then spent two years keeping it patched - this is for you.
SEO baked in, not bolted on
Every published post ships with the technical SEO most WordPress sites need three plugins to achieve.
Full metadata pass
Custom <title> and meta description from frontmatter, canonical URL, and a clean <head> with no plugin-style noise.
Open Graph & Twitter Cards
Posts unfurl correctly on Facebook, LinkedIn, X, Slack, and Discord without any extra configuration. Hero images and alt text propagate automatically.
JSON-LD BlogPosting
Every post emits valid BlogPosting structured data with author, publisher, logo, and dates - the data Google needs for rich results.
Clean URLs via .htaccess
No ?id=42. Posts live at /blog/your-slug/ with a slug authoritative in the frontmatter, not the filename.
Sitemap and RSS, regenerated
An XML sitemap and an RSS 2.0 feed are rewritten on every save, with drafts excluded automatically. Submit once, forget about it.
Lazy images with proper alt
Every <img> ships with loading="lazy" and descriptive alt text from frontmatter, falling back to the post title for accessibility.
Semantic HTML5 structure
Posts render with <article>, <header>, <time datetime>, and proper heading hierarchy. Search crawlers and screen readers get the structure they expect, no <div> soup.
Category archive pages
Every category in the frontmatter union becomes its own paginated archive page at /blog/category/<name>/ - automatic topic clustering, indexed in the sitemap, no plugin needed.
Why not WordPress, Joomla, Pico, or Grav?
Both WordPress and Joomla are excellent for sites that need them - but for a static HTML client site that needs occasional SEO content, both fundamentally convert the site into a database-driven application; the static HTML is gone. Pico, Bludit, and Grav are good flat-file CMSes that influenced this project: Pico is closest in spirit but designed as a standalone site builder rather than a drop-in for existing static sites; Bludit adds multi-user, plugins, and themes - useful in many cases, scope creep here; Grav is significantly larger and more feature-rich, with its own template language and plugin architecture. Nano CMS deliberately stays smaller than all of them, and the portable admin pattern - admin uploaded temporarily, removed after use - is unusual in this category.
Side-by-side
| Nano CMS | WordPress | Pico / Grav | |
|---|---|---|---|
| Database | None | MySQL required | None |
| Permanent admin | No - portable | Yes | Yes (Pico admin / Grav admin) |
| Drop-in to existing site | Yes | No - replaces site | Designed as standalone |
| Codebase size | ~2,400 lines | ~500,000+ | 5-50k |
| Plugin / theme system | No (intentional) | Yes | Yes |
| Backup | rsync the folder | DB dump + files | rsync the folder |
What a post looks like on disk
One post = one file. The frontmatter slug field is authoritative for URLs - the date prefix in the filename is for human readability only.
/posts/2026-05-06-bridging-static-sites.md --- title: Bridging Static Sites and SEO slug: bridging-static-sites-seo date: 2026-05-06 updated: 2026-05-08 category: web-design description: A 150-character meta description targeting your keyword. image: 2026-05-06-a4f8b2.jpg image_alt: Diagram showing a static site with a blog directory bolted on draft: false --- Post content in Markdown here. Embed a video with: [video:youtube:dQw4w9WgXcQ] Or: [video:vimeo:123456789]
Frontmatter fields
title- post title, used in<title>and headings.slug- URL slug, authoritative,[a-z0-9-]+only.date- original publish date (YYYY-MM-DD).updated- last meaningful edit, auto-set by the admin on save.category- single category per post, free-form.description- meta description, ~150 characters.image/image_alt- hero image filename in/media/and accessible alt text.draft-truehides the post from the public listing, sitemap, and feed.
Body and shortcodes
The body is rendered with Parsedown in safe mode - any raw HTML is stripped. After Markdown rendering, two shortcodes expand for trusted output:
[video:youtube:VIDEO_ID]- responsive YouTube iframe embed.[video:vimeo:VIDEO_ID]- responsive Vimeo iframe embed.
The render order is mandatory: safe-mode pass first, shortcode expansion second. That ensures iframes produced by shortcodes are not stripped by safe mode.
Security model
Five concrete protections - no marketing words about being "secure", just the actual mechanisms.
Admin removed when not in use
The admin folder is uploaded via SFTP to publish, then deleted. There is no persistent admin URL to brute-force, scrape, or scan with WordPress wordlists.
Password hash outside webroot
The bcrypt password hash and rate-limit state live in a folder above public_html/. Even with a misconfigured Apache, nothing serves them over HTTP.
Rate-limited logins
Five failed attempts in 15 minutes triggers a one-hour IP block. The lockout state survives the admin folder being removed and re-uploaded - attackers cannot reset their lockout by forcing a fresh install.
Markdown rendered in safe mode
Parsedown's safe mode strips raw HTML and dangerous URLs. Shortcodes expand after safe mode, so even a malicious post body cannot inject script tags.
Uploaded images re-encoded
Every uploaded image is verified with finfo_file() and then re-encoded through GD or Imagick - stripping anything smuggled into EXIF. /media/ also has PHP execution disabled by .htaccess.
Format-version compatibility check
The admin records its version on every save. If an older admin is uploaded over a newer site, it refuses to operate rather than silently corrupting data.
Requirements & backups
- PHP 8.1 or later
- Apache with
mod_rewrite(for clean URLs) - HTTPS (required for the admin login)
- SFTP access to the client site (for deploying the frontend and uploading the admin)
Tested on shared hosting (cPanel-style). No special privileges are required.
Backups are a folder copy
Markdown files in /posts/ and uploaded media in /media/ are the only persistence - there is no database to dump and restore. A simple cron + rsync line on a backup machine handles it for any number of client sites:
# Daily 03:00 backup of one client's blog content and config 0 3 * * * rsync -az -e "ssh -p 22" \ user@clientsite.com:/home/clientuser/public_html/blog/posts/ \ /backups/clientname/posts/ 0 3 * * * rsync -az -e "ssh -p 22" \ user@clientsite.com:/home/clientuser/public_html/blog/media/ \ /backups/clientname/media/ 0 3 * * * rsync -az -e "ssh -p 22" \ user@clientsite.com:/home/clientuser/blog-config/ \ /backups/clientname/config/
Adapt to your preferred target, cloud sync, restic, tarballs, anything works because all state is files.
Roadmap
Where the project is now, and where it's going.
v0.1:In development
Frontend rendering, parser, and on-disk file format. Basic single-post and index pages. nano.css neutral default stylesheet.
v0.2:Admin & generators
Sitemap and RSS generators. Universal portable admin (login, post editor, media manager). Setup wizard for first-time deployment.
v0.3:Docs & first deployment
Documentation, deployment guide, an example installation. First production deployment on a real client site to find anything the spec missed.
v1.0:Stable release
Stable file format. Polished admin UX. Public release on GitHub with tagged binaries and an installation guide.
v1.1+:Possible additions
No commitment, but on the maybe-list: two-factor authentication for the admin, tag support alongside categories, an image gallery shortcode, and dark-mode variants of nano.css. Whatever ships will respect the format-stability rules.
Forward-compatible 1.x
Once v1.0 ships, an installation set up under format 1.0 should remain readable by all 1.x admin versions without manual migration. Adding fields is a minor bump; renaming or removing requires a major version and a migration path.
Explicitly not planned: multi-user accounts, plugin system, theme system, WYSIWYG editor, comments, scheduled publishing, post revisions. Feature requests for these will be politely declined, the small surface area is the product.
Follow Nano CMS
The project is in early-stage solo development and not yet ready for outside contributions, but bug reports and architectural feedback are welcome on GitHub Issues. Once v1 is stable, contribution guidelines will be added.
View on GitHub Get in Touch