Pure Markdown content management for Apache and HestiaCP.
Drop .md files in your docroot and they are served as fully rendered HTML
pages - no build step, no CMS, no database. Pages are generated on first
request and cached as static HTML.
Most content management approaches force a choice between a dynamic CMS (database, runtime, security surface) and a static site generator (build pipeline, toolchain, deploy step). lazysite sits between the two.
Content management : Write pages in Markdown. Drop files in the docroot. Pages are live immediately - no publishing step, no build, no deploy command.
Design and content are separated
: The Template Toolkit layout owns the site design. Content authors work
only in .md files. Designers work only in layout.tt. Neither needs
to touch the other's files.
Version control ready : Everything is a file - Markdown sources, the layout template, the processor. The entire site lives in a VCS repository. Content changes, design changes, and code changes all have full history.
Fast by default
: Pages are dynamic only on the first request. After that, Apache serves
plain cached .html - no interpreter, no processing, no overhead.
The best blend of a static site and a dynamic system.
No build or make step
: Write a .md file, save it, it is live. Delete the cached .html to
republish after edits. That is the entire workflow.
No database : Files are the source of truth. Nothing to back up separately, nothing to migrate, nothing to corrupt.
Content is portable
: Plain .md files are not locked to this system. They work with any
Markdown processor, any static site generator, any editor. Switching
tools later does not mean rewriting content.
Works with any deployment workflow : rsync, git pull, sftp, FTP, scp - however files reach the server, lazysite picks them up. It integrates easily into CI/CD pipelines or manual workflows equally well.
Cache is transparent
: Generated .html files are readable, standard HTML. They can be
inspected, debugged, or served independently if needed.
Resilient
: If the processor fails for any reason, previously cached .html files
continue to be served unaffected.
Easy to audit : The processor is a single readable Perl script with no framework dependencies beyond three standard Debian packages.
Works alongside static files
: Mix hand-crafted .html files and .md files in the same docroot
freely. lazysite only activates when no matching file exists.
lazysite uses standard CGI and error handler mechanisms available in most web servers.
- Apache 2.4 - supported, HestiaCP installer provided
- Apache without HestiaCP - configure
ErrorDocument 403/404manually - Nginx - use
error_page 403 404to point to the CGI script - Any web server with CGI support and configurable error handlers should work
lazysite grew out of a specific frustration with the available options for managing a small set of sites on a personal hosting infrastructure.
The starting point was Apache Server Side Includes. SSI is elegant for what it does - a standard mechanism built into Apache for composing pages from fragments, with no runtime dependency beyond the web server itself. Header, footer, navigation as separate files, included at serve time. Fast, simple, no moving parts.
The problem is content management. SSI handles page composition well but has nothing to say about how you author or manage the content that goes into those pages. You end up writing HTML directly, which is fine for templates but poor for page content. Any non-trivial site accumulates HTML files that are tedious to write and update.
The requirements that shaped lazysite:
Speed : Pages should be fast. Not "fast enough" - actually fast. A CGI process on every request is not fast. Static file serving is fast. The caching model means the CGI fires once per page, then Apache serves static HTML. The common case is a file read, not a process fork.
Simplicity : No database. No admin interface. No framework to learn. No build pipeline to maintain. Drop a file, get a page. The entire system is one Perl script that can be read and understood in an afternoon.
Markdown : Content should be written in Markdown. Not because Markdown is perfect, but because it is the established lingua franca for structured plain text. It works in any editor, versions cleanly in git, and is readable without rendering. Pandoc-style fenced divs for the cases where you need a styled wrapper without writing HTML.
Control where you want it : The layout template is a file you own and edit directly. The CSS is your CSS. The HTML structure is yours. lazysite renders Markdown into a slot in your template - it does not impose a theme, a component model, or a styling convention. If you know HTML and CSS you are not constrained.
Sensible defaults : The parts you do not want to think about should work without configuration. Caching. Cache invalidation on file edit. Subdirectory creation with correct permissions. A starter 404 page. A starter layout. These should all just work on first install.
Same method everywhere : A page authored for one site should work on any other site running lazysite. The front matter format, the fenced div syntax, the URL structure - all consistent. Moving content between sites is a file copy.
Version control as the content store : The entire site - content, templates, variables, processor - lives in a git repository. Every change has history. Deploying is a file copy. Rolling back is a file copy. No database export/import, no CMS backup, no proprietary format.
HestiaCP is the control panel in use on the hosting infrastructure. It has a web template system that generates Apache vhost configs. lazysite plugs into this as a named template - apply it to a domain, rebuild, and the processor and starter files are installed automatically. The same installer also produces clean configurations for standalone Apache outside HestiaCP.
The HestiaCP integration is additive. lazysite works without it.
Several things were not in the original plan but followed naturally:
Remote sources via .url files - pulling documentation directly from a
GitHub repository rather than duplicating it. The documentation lives with
the code, the site always shows the current version.
Template Toolkit variables fetched from remote URLs - a version number from
a VERSION file, release metadata from a GitHub API endpoint, baked into
the cached page at render time rather than fetched client-side.
The registry system - llms.txt and sitemap.xml generated from page front
matter, updated automatically when pages are rendered. Adding a new registry
format requires only a template file.
oEmbed - embedding PeerTube and other video providers with a one-line syntax, the iframe baked into the cache.
The link audit tool - a maintenance utility that emerged from the need to identify orphaned pages and broken links as the site grew.
The Docker staging workflow - a natural consequence of the file-based architecture. Stage in a container, rsync the source files to production, let the cache warm on first visit.
Each of these followed from the same principle: the mechanism should be simple, the output should be static where possible, and the operator should retain control.
lazysite suits a specific use case. These alternatives may be a better fit depending on your requirements.
Hugo : A static site generator. Build step produces a complete static site from Markdown sources. Fast, mature, large ecosystem. Better choice if you want a full build pipeline, complex themes, multi-language support, or are comfortable with a Go toolchain. No server-side processing after build.
Pico CMS : A flat-file PHP CMS. Drop Markdown files in a directory and pages appear - similar philosophy to lazysite but PHP-based with a plugin ecosystem and admin themes. Better choice if you want a richer authoring experience or plugins for things like search, without a database. Requires PHP on every request.
Jekyll : Ruby-based static site generator, well-established in the GitHub Pages ecosystem. Good choice if your content lives on GitHub and you want free hosting with automatic builds on push. Build step required.
WordPress : Full CMS with database, admin UI, and vast plugin ecosystem. Better choice for non-technical authors, multi-user publishing workflows, e-commerce, or any site needing dynamic content beyond what static caching provides.
Publii : Desktop app that generates a static site. Good choice if authors prefer a GUI and the site is maintained by one person. No server-side processing.
lazysite is most appropriate when content is managed via VCS, authors are comfortable with Markdown and a text editor, and the simplicity of no database and no build step is valued over a richer feature set.
Pico content migrates directly to lazysite with minimal changes. Pico uses the same Markdown files with YAML front matter:
---
Title: My Page
Description: A short description
---
Content here.To migrate:
- Copy your Pico
content/files to the lazysite docroot - Rename
Title:totitle:andDescription:tosubtitle:in front matter (lazysite uses lowercase keys) - Remove any Pico-specific front matter keys that have no equivalent
- Replace Pico theme templates with a
layout.tttemplate
A one-liner to lowercase the common front matter keys across all files:
find public_html -name "*.md" | \
xargs sed -i 's/^Title:/title:/;s/^Description:/subtitle:/'Hugo Markdown content uses the same front matter format. The content files
themselves require no changes. What does need replacing is the Hugo template
system - Hugo uses Go templates, lazysite uses Template Toolkit. The
layout.tt file replaces your Hugo baseof.html or equivalent base template.
- Requests for pages with no matching file trigger Apache's 404/403 handler
- The handler runs
md-processor.plwhich looks for a.mdor.urlsource file - If found, the Markdown is converted to HTML and rendered through a Template Toolkit layout
- The result is cached as
.htmlalongside the source - Subsequent requests are served directly from the static cache
- Apache 2.4 with CGI support and
ErrorDocumentconfiguration - Debian / Ubuntu (or any Linux with the Perl modules below)
libtext-multimarkdown-perllibtemplate-perllibwww-perl(for remote.urlsources and oEmbed)JSON::PP(Perl core - no separate install needed)
HestiaCP is supported with a dedicated installer. For other environments see the manual installation section below.
The installer registers lazysite as a HestiaCP web template. Once installed, apply it to any domain from the control panel and the processor and starter files are deployed automatically on rebuild.
git clone https://github.com/OpenDigitalCC/lazysite.git
cd lazysite
sudo bash install.shThen in HestiaCP:
- Edit your domain
- Set the web template to
ssi-md - Save and rebuild
For Apache without HestiaCP, install the Perl dependencies and configure the vhost manually:
apt install libtext-multimarkdown-perl libtemplate-perl libwww-perlCopy md-processor.pl to your cgi-bin/ directory and make it executable:
cp template/files/md-processor.pl /var/www/example.com/cgi-bin/
chmod 755 /var/www/example.com/cgi-bin/md-processor.plCopy the starter templates to your docroot:
mkdir -p /var/www/example.com/public_html/templates/registries
cp template/files/layout.tt /var/www/example.com/public_html/templates/
cp template/files/layout.vars /var/www/example.com/public_html/templates/
cp template/files/registries/*.tt /var/www/example.com/public_html/templates/registries/
cp template/files/404.md /var/www/example.com/public_html/
cp template/files/index.md /var/www/example.com/public_html/Add to your Apache vhost configuration:
DirectoryIndex index.html index.htm
AddOutputFilter INCLUDES .shtml
ErrorDocument 403 /cgi-bin/md-processor.pl
ErrorDocument 404 /cgi-bin/md-processor.pl
<Directory /var/www/example.com/public_html>
Options +Includes -Indexes +ExecCGI
AllowOverride All
</Directory>Ensure the web server user can write to the docroot:
chown ispadmin:www-data /var/www/example.com/public_html
chmod g+ws /var/www/example.com/public_htmlThe setgid bit (s) ensures new subdirectories created by the processor
inherit the www-data group automatically.
After applying the template to a domain:
- Edit
public_html/templates/layout.ttto apply your site design - Edit
public_html/index.mdfor your home page content - Add pages by dropping
.mdfiles anywhere in the docroot
Pages are available immediately at their extensionless URL:
public_html/about.md -> https://example.com/about
public_html/services/hosting.md -> https://example.com/services/hosting
public_html/services/index.md -> https://example.com/services/
Directory index pages are served when a trailing slash URL is requested.
Create dirname/index.md for any directory that needs an index page.
docs/ai-briefing.md is a concise reference document covering the full
system - layout variables, front matter, Markdown elements, URL structure,
and file locations. Feed it to an AI assistant (Claude, ChatGPT, etc.) at
the start of a session to enable it to help with layout design, page
authoring, and layout.vars configuration without needing to explain the
system each time.
In Claude Projects, save it as a project document. For other AI tools, paste it as context at the start of the conversation.
templates/layout.tt is the single file that controls the appearance of
every page. It is the integration point for web designers. A minimal working
example is provided in template/files/layout.tt in this repository - it
produces a bare but functional HTML page and is intended as a starting point,
not a finished design.
Every page render passes these variables to the template:
[% page_title %]
: The page title from front matter.
[% page_subtitle %]
: The page subtitle from front matter. May be empty - test with [% IF page_subtitle %].
[% content %]
: The converted page body as HTML. Output with [% content %] - TT does not
escape this value, which is correct since it is already HTML.
Plus any site-wide variables defined in layout.vars.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>[% page_title %] — [% site_name %]</title>
<link rel="stylesheet" href="/assets/css/main.css">
</head>
<body>
<header>
<a href="/">[% site_name %]</a>
<nav>
<a href="/about">About</a>
<a href="/docs/">Docs</a>
</nav>
</header>
<main>
<h1>[% page_title %]</h1>
[% IF page_subtitle %]
<p class="subtitle">[% page_subtitle %]</p>
[% END %]
[% content %]
</main>
<footer>
<p>© 2026 [% site_name %]</p>
</footer>
</body>
</html>Delete all cached .html files to force regeneration:
find public_html -name "*.html" -deletePages regenerate on next request. On a live site with many pages, stagger this or use curl to pre-warm the cache after deploying a new template.
---
title: Page Title
subtitle: Optional subtitle
---
## Content heading
Page content in standard Markdown.
::: widebox
Styled div - class maps to your CSS.
:::Setting raw: true in front matter outputs the converted content body
without the layout template wrapper. TT variables still resolve. This is
useful for content fragments fetched by other pages, AJAX partials, or
API-style endpoints.
---
title: Fragment
raw: true
---
Content here - no `<html>`, `<head>`, or layout wrapper.
[% version %] resolves normally.An optional content_type key sets the HTTP Content-type header. Default
is text/html; charset=utf-8:
---
raw: true
content_type: application/json; charset=utf-8
---
{"version": "[% version %]", "status": "ok"}See docs/authoring.md for the full authoring and template integration guide.
All pages have access to these standard variables in layout.tt and page
content:
[% page_title %]- from front mattertitle[% page_subtitle %]- from front mattersubtitle[% content %]- the converted page body
Variables are processed in two passes - first in the page body, then in
layout.tt. This means [% version %] works both in .md content and
in the layout template.
Variables available on every page are defined in templates/layout.vars:
site_name: ctrl-exec
site_url: ${REQUEST_SCHEME}://${SERVER_NAME}
version: url:https://raw.githubusercontent.com/example/repo/main/VERSION
support_email: hello@example.comThree value types are supported:
Literal string
: key: value - used as-is
Environment variable
: key: ${ENV_VAR} - interpolated from the Apache CGI environment.
Multiple vars and mixed text are supported: ${REQUEST_SCHEME}://${SERVER_NAME}
Remote URL
: key: url:https://... - fetched, trimmed, and cached with the page TTL.
Env var interpolation works inside url: values too.
Useful Apache CGI environment variables:
${SERVER_NAME}- domain name e.g.example.com${REQUEST_SCHEME}-httporhttps${SERVER_PORT}- port number${HTTPS}-onif SSL${REDIRECT_URL}- the requested page path e.g./about- useful for active nav state
Variables available only on a specific page are defined in its front matter
under tt_page_var:
---
title: Downloads
tt_page_var:
release_notes: url:https://raw.githubusercontent.com/example/repo/main/CHANGES
beta: true
---Page variables override site variables of the same name.
Site vars → page vars → page_title, page_subtitle, content
TT variables are not limited to simple strings. A url: value that returns
JSON can be decoded and used as a data structure in templates, enabling loops,
conditionals, and dynamic list rendering - all baked into the cached page at
render time.
Fetch a JSON feed via tt_page_var and decode it in the template:
---
title: News
tt_page_var:
news_json: url:https://example.com/api/news.json
---In the page body, decode and loop:
[% USE JSON( pretty => 0 ) %]
[% news = JSON.deserialize(news_json) %]
[% FOREACH item IN news.items %]
<article>
<h2><a href="[% item.url %]">[% item.title %]</a></h2>
<p>[% item.summary %]</p>
<time>[% item.date %]</time>
</article>
[% END %]
The same approach works in layout.tt using a site-wide variable from
layout.vars:
version_json: url:https://api.github.com/repos/example/repo/releases/latestThen in layout.tt:
[% USE JSON( pretty => 0 ) %]
[% release = JSON.deserialize(version_json) %]
<span class="version">[% release.tag_name %]</span>
[% IF beta %]
<div class="notice">This page documents a beta feature.</div>
[% END %]
Define a nav structure in layout.vars:
nav_json: url:https://example.com/nav.jsonOr as a literal in layout.tt directly:
[% nav = [
{ label => 'Home', url => '/' },
{ label => 'Docs', url => '/docs/' },
{ label => 'Install', url => '/install' },
] %]
<nav>
[% FOREACH item IN nav %]
<a href="[% item.url %]">[% item.label %]</a>
[% END %]
</nav>
TT is processed in two passes - first in the page body, then in layout.tt.
The USE JSON directive and variable assignments made in the body are local
to that pass and not available in the layout. For data needed in both the
page body and the layout, set it as a site-wide variable in layout.vars.
Inline code elements (\like this`) and fenced code blocks are protected from TT processing - [% tags %]` inside code appear literally, which is
correct for documentation pages showing code examples.
TT variables in Markdown link URLs work when the URL is built as an HTML
anchor tag directly. Use HTML <a> tags rather than Markdown link syntax
when the href contains a TT variable:
<a href="[% download_base %]/release-[% version %].tar.gz">Download</a>Markdown link syntax [text]([% url %]) does not work reliably because the
Markdown parser processes the URL before TT resolves the variable.
For <dt> elements in definition lists, Markdown link syntax is supported
after TT resolution:
[release-[% version %].tar.gz]([% download_base %]/release-[% version %].tar.gz)
: Source tarball.Full Template Toolkit documentation is at https://template-toolkit.org/docs/.
Registries are generated files derived from page front matter - llms.txt,
sitemap.xml, or any other format. A page declares which registries it
belongs to via the register front matter key:
---
title: Installation Guide
subtitle: How to install and configure
register:
- llms.txt
- sitemap.xml
---Each registry name maps to a Template Toolkit template in
templates/registries/. The template filename without .tt is the output
filename written to the docroot root:
templates/registries/llms.txt.tt -> public_html/llms.txt
templates/registries/sitemap.xml.tt -> public_html/sitemap.xml
Registries are regenerated on the next page render after the registry TTL
expires (default 4 hours - $REGISTRY_TTL in md-processor.pl). To force
immediate regeneration delete the output file:
rm public_html/llms.txtRegistry templates receive these variables:
pages- array of registered page objects- All site-wide variables from
layout.vars
Each page object contains:
[% page.url %]- canonical URL path e.g./install[% page.title %]- from front matter[% page.subtitle %]- from front matter
Example llms.txt.tt:
# [% site_name %]
> [% site_name %] documentation and pages.
## Pages
[% FOREACH page IN pages %]
- [[% page.title %]]([% site_url %][% page.url %].md)[% IF page.subtitle %]: [% page.subtitle %][% END %]
[% END %]
Example sitemap.xml.tt:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
[% FOREACH page IN pages %]
<url>
<loc>[% site_url %][% page.url %]</loc>
</url>
[% END %]
</urlset>Drop a .tt file in templates/registries/ - no code changes needed. The
processor picks it up automatically on the next page render after the TTL
expires.
Pages can embed videos and other media using oEmbed. Any oEmbed-compatible provider is supported - YouTube, Vimeo, SoundCloud, and self-hosted PeerTube instances among others.
::: oembed
https://peertube.example.com/videos/watch/abc123
:::
::: oembed
https://www.youtube.com/watch?v=abc123
:::The processor fetches the oEmbed endpoint, extracts the provider's iframe HTML, and bakes it into the cached page. No client-side API calls are made - the embed is static HTML after the first render.
Known providers (YouTube, Vimeo, SoundCloud, Twitter/X) are looked up
directly. Unknown providers use oEmbed autodiscovery - the video page is
fetched and the <link rel="alternate" type="application/json+oembed"> tag
is followed to find the endpoint.
If the fetch fails the block renders as a plain link fallback with class
oembed--failed for CSS targeting.
Additional providers can be added to %OEMBED_PROVIDERS in md-processor.pl.
Pages can be sourced from remote Markdown files by creating a .url file
containing a URL instead of a .md file containing content.
public_html/install.url contains:
https://raw.githubusercontent.com/example/repo/main/docs/install.md
This makes /install fetch, render, and cache the remote Markdown file.
The remote source is processed through the same pipeline as local files -
YAML front matter, fenced divs, code blocks, and the site template are
all applied.
Remote content is cached as .html alongside the .url file. The cache
TTL defaults to 1 hour - configured as $REMOTE_TTL in md-processor.pl.
After the TTL expires the next request silently refetches the source.
If a remote fetch fails, the stale cache is served if available. If there is no cache, an error block is rendered in the page.
To force immediate refresh of a remote page, delete the cached .html:
rm public_html/install.htmlA GitHub Actions workflow can automate this after a push to the source
repository using ssh to delete the cached file on the server.
Local .md pages
: The cache is invalidated automatically by mtime comparison. When the
.md file is newer than the cached .html, the page is regenerated
on the next request. Editing a .md file and saving it is sufficient
to trigger regeneration - no manual step needed.
Remote .url pages
: The cache is invalidated by TTL (default 1 hour - $REMOTE_TTL in
md-processor.pl). The stale cache is always served immediately - the
refetch happens on the first request after TTL expiry, transparent to
the user.
Page-level TTL override
: Any page can set its own TTL via the ttl front matter key. When set,
the page uses TTL-based cache invalidation instead of mtime comparison.
Useful for pages that pull remote data via tt_page_var and need
frequent refresh.
---
title: Downloads
ttl: 300
---This page regenerates every 5 minutes regardless of whether the .md
file has changed.
To force regeneration of any page regardless of cache state:
rm public_html/about.htmlTo regenerate all pages (for example after a template change):
find public_html -name "*.html" -deleteIndex pages (index.md / index.html) are served directly by Apache via
DirectoryIndex and bypass the processor entirely when the cached
index.html exists. The mtime comparison that triggers automatic
regeneration only runs when the processor is invoked - which does not happen
for direct static file serves.
After editing index.md, delete the cached file manually:
rm public_html/index.html
rm public_html/docs/index.htmlAll non-index pages use extensionless URLs which never match a static file directly, so they always go through the processor and trigger automatic regeneration on edit.
The most direct way to diagnose any page error is to run the processor from the command line, simulating an Apache request:
REDIRECT_URL=/install \
DOCUMENT_ROOT=/home/username/web/example.com/public_html \
perl /home/username/web/example.com/cgi-bin/md-processor.plThis prints the full HTML output (or any Perl errors) directly to the
terminal. Replace /install with the failing page path and adjust the
DOCUMENT_ROOT to match your domain.
To inspect a specific section of the output:
REDIRECT_URL=/install \
DOCUMENT_ROOT=/home/username/web/example.com/public_html \
perl /home/username/web/example.com/cgi-bin/md-processor.pl | grep -A5 -B5 'keyword'perl -c /home/username/web/example.com/cgi-bin/md-processor.plsyntax OK means Perl can parse the script. Errors here will show the
line number and nature of the problem.
tail -50 /home/username/web/example.com/logs/example.com.error.logKey messages to look for:
End of script output before headers
: The script crashed before printing anything. Run the processor manually
(above) to see the Perl error.
Cannot serve directory ... No matching DirectoryIndex
: The index page is not being found. Ensure DirectoryIndex index.html
is in the vhost config and that the 403 handler is set.
AH01276: Cannot serve directory
: Same as above - the 403 error handler should fire and generate
index.html from index.md on first request.
lazysite: Cannot write cache file ... Fix with: chmod g+ws
: The web server cannot write the generated .html file to the docroot.
The page will still render correctly but will not be cached - every
request will regenerate it until permissions are fixed.
The most common setup issue. The web server user (www-data) needs write
permission on the docroot to create cached .html files.
When this happens the page renders correctly for visitors but the Apache error log will contain:
lazysite: Cannot write cache file /path/to/page.html: Permission denied
- page will render uncached. Fix with: chown ...&& chmod g+ws /path/to/
Fix with:
chown ispadmin:www-data /home/username/web/example.com/public_html
chmod g+ws /home/username/web/example.com/public_htmlThe setgid bit (s) ensures new subdirectories inherit the www-data
group automatically, so pages in subdirectories cache correctly without
further intervention.
This is reset on every HestiaCP domain rebuild. To reapply across all domains after a rebuild:
for d in /home/username/web/*/public_html; do
chown $(stat -c '%U' "$d"):www-data "$d"
chmod g+ws "$d"
doneNote: the ssi-md.sh hook sets this automatically when the template is
first applied to a domain. It only runs on apply, not on subsequent
rebuilds.
If the processor crashes with a Template error, check the layout file exists:
ls /home/username/web/example.com/public_html/templates/layout.ttIf missing, the ssi-md.sh hook did not run or the file was deleted.
Reinstall it from the package:
cp /usr/local/hestia/data/templates/web/apache2/php-fpm/files/layout.tt \
/home/username/web/example.com/public_html/templates/layout.ttWhen pages are in subdirectories (docs/, services/ etc.), the processor
creates those directories automatically with the setgid bit set, so they
inherit the www-data group from the docroot. If the docroot itself has the
setgid bit set correctly this should be transparent.
If pages in subdirectories render but don't cache, fix the directory:
chown $(stat -c '%U' public_html):www-data public_html/docs
chmod g+ws public_html/docsThe error log will contain the fix command if this is the cause:
lazysite: Cannot write cache file .../docs/install.html: Permission denied
- page will render uncached. Fix with: chown ...
The processor emits a Status: 200 OK CGI header for successfully rendered
pages and Status: 404 Not Found for genuine missing pages. Apache respects
these headers and logs the correct status.
A 404 in the access log is always a real missing page - no .md or .url
source file exists for that path. Pages rendered by the processor appear as
200.
Note: cached .html files served directly by Apache (on subsequent requests)
have always logged 200 correctly. The status header fix applies only to the
first render of each page.
Registries are only generated when a page is rendered - they are not generated on cached page serves. If registries are missing:
- Delete the registry file to clear any stale TTL state:
rm public_html/llms.txt
- Force a page render by deleting a cached page:
rm public_html/index.html
- Request the page via curl (bypasses browser cache):
curl -s https://example.com/ > /dev/null
Check the error log for any registry errors:
grep "Registry\|registry" logs/example.com.error.loglazysite-audit.pl scans your docroot and reports orphaned pages and broken
links.
Orphaned pages
: .md or .url files that exist but are not linked from any scanned file.
These may be redundant or simply missing from navigation.
Broken links
: Links in .md or template files pointing to pages that do not exist.
perl lazysite-audit.pl /home/username/web/example.com/public_htmlThe audit scans .md files, .tt templates (including layout.tt and
registry templates), and cached .html files for .url pages. External
links, assets, and image files are ignored.
index and 404 are always excluded from the orphan report. Additional
exclusions can be passed on the command line:
perl lazysite-audit.pl --exclude changelog,contributing /path/to/docrootOr via a file with one path per line:
perl lazysite-audit.pl --exclude-file exclusions.txt /path/to/docrootsanitise_uri rejects null bytes, .. path segments, and suspicious
characters before constructing filesystem paths. After construction, each
path is verified with realpath to confirm it resolves within $DOCROOT.
Symlinks pointing outside the docroot are rejected.
The same check is applied inside write_html before any file is written,
guarding against symlink-based overwrite attacks on the cache output path.
All values extracted from YAML front matter - including title, subtitle,
and tt_page_var entries - have TT directive markers ([% and %]) stripped
before entering the template context. register list items are stripped at
parse time. This prevents authored content from injecting TT directives into
the rendering pipeline.
The ${VAR} interpolation in layout.vars is restricted to an explicit
allowlist: SERVER_NAME, REQUEST_SCHEME, SERVER_PORT, HTTPS,
DOCUMENT_ROOT, SERVER_ADMIN. Request-supplied headers (HTTP_* variables)
are not interpolated regardless of what appears in layout.vars.
The class name following ::: is validated against /\A[\w][\w-]*\z/ before
use. Blocks with class names containing characters outside word characters and
hyphens are rejected - the content renders without a wrapper div and a warning
is written to the error log.
oEmbed provider responses are parsed with JSON::PP (Perl core module) rather
than regex extraction. The html field from the parsed response is injected
into the page. Provider responses are trusted as-is - restrict %OEMBED_PROVIDERS
in md-processor.pl to known hosts if untrusted providers are a concern in
your deployment.
lazysite can generate a complete static site - all pages pre-rendered to HTML - for deployment to static hosting such as GitHub Pages, Netlify, Cloudflare Pages, or any plain web server without CGI support.
build-static.sh processes all .md and .url files in the docroot,
simulating the Apache CGI environment so that layout.vars variables like
${SERVER_NAME} resolve correctly.
# Build in-place
bash build-static.sh https://example.com
# Build to a separate output directory
bash build-static.sh https://example.com ./distThe base URL argument sets REQUEST_SCHEME and SERVER_NAME for the build,
ensuring site_url and any other environment-derived variables in
layout.vars resolve to the correct values for the target deployment.
With a separate output directory, source .md and .url files are excluded
from the output - only the generated .html files, assets, and templates
are included.
Run without arguments for full usage instructions:
bash build-static.shrsync -av --delete ./dist/ user@host:/var/www/html/The output directory is a standard static site. Deploy to:
- GitHub Pages - push the output directory to a
gh-pagesbranch ordocs/ - Netlify / Cloudflare Pages - point to the output directory
- Amazon S3 - sync with
aws s3 sync - Any web server - rsync or copy the output directory
The static build also provides a simple staging approach. Build locally or in a container against the production URL, verify the output, then rsync only the source files to the live server:
# Verify locally against production URL
bash build-static.sh https://example.com ./dist
# Deploy source files only - live server generates its own cache
rsync -av --exclude="*.html" ./public_html/ user@host:/path/to/public_html/The live server regenerates .html cache files from the deployed .md
sources on first request. No build artefacts are transferred - the deploy
is purely source files.
A Docker Compose setup provides a self-contained lazysite environment without requiring Apache or HestiaCP on the host. This is useful for local development, testing, or as a simple standalone deployment.
It also provides a practical staging workflow: develop and preview content in the container, then deploy to production with a simple file copy.
services:
web:
image: debian:bookworm-slim
ports:
- "8080:80"
volumes:
- ./site:/var/www/html
- ./cgi-bin:/usr/lib/cgi-bin
command: >
bash -c "
apt-get update -qq &&
apt-get install -y -qq apache2 libtext-multimarkdown-perl
libtemplate-perl libwww-perl &&
a2enmod cgi includes &&
cp /usr/lib/cgi-bin/md-processor.pl /usr/lib/cgi-bin/ &&
cat > /etc/apache2/sites-available/000-default.conf << 'EOF'
<VirtualHost *:80>
DocumentRoot /var/www/html
ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/
DirectoryIndex index.html index.htm
ErrorDocument 403 /cgi-bin/md-processor.pl
ErrorDocument 404 /cgi-bin/md-processor.pl
<Directory /var/www/html>
Options +Includes -Indexes +ExecCGI
AllowOverride All
</Directory>
</VirtualHost>
EOF
chown -R www-data:www-data /var/www/html &&
chmod g+ws /var/www/html &&
apache2ctl -D FOREGROUND
"Mount your content directory as /var/www/html and the processor as
/usr/lib/cgi-bin/md-processor.pl.
The Docker volume is your working site. When ready to deploy, copy the
source files (not the cached .html files) to production:
rsync -av --exclude="*.html" \
./site/ \
user@production:/home/user/web/example.com/public_html/The --exclude="*.html" ensures cached pages are not copied - they
regenerate automatically on first request on the production server. This
keeps the deploy clean and avoids serving stale cached content.
To also exclude the Archive directory if present:
rsync -av --exclude="*.html" --exclude="Archive/" \
./site/ \
user@production:/home/user/web/example.com/public_html/The production server generates fresh .html cache files from the
deployed .md sources on first visit. The deploy is effectively a file
copy - no build step, no restart, no database migration.
sudo bash uninstall.shRemoves Hestia template files only. Deployed domain files are not touched.
lazysite/
install.sh
uninstall.sh
build-static.sh <- static site generator
lazysite-audit.pl <- link audit utility
template/
ssi-md.tpl <- Apache vhost template (HTTP)
ssi-md.stpl <- Apache vhost template (HTTPS)
ssi-md.sh <- Hestia domain hook
files/
md-processor.pl <- CGI processor
layout.tt <- starter site template
layout.vars <- starter site-wide TT variables
404.md <- starter 404 page
index.md <- starter index page
registries/
llms.txt.tt <- starter llms.txt registry template
sitemap.xml.tt <- starter sitemap registry template
docs/
authoring.md <- authoring and template integration guide
ai-briefing.md <- AI assistant briefing for site creation
MIT
lazysite was developed interactively with Claude (Anthropic). Architecture, design decisions, security review, and deployment were directed by the author. Claude assisted with code generation, documentation, and iterative refinement throughout development.