Make Docker bind mounts actually work; refresh homepage feature grid

App/, cache/, uploads/, and data/ are now bind-mounted by default, so
docker-entrypoint.sh seeds an empty App/ from a build-time backup and
re-chowns the mounted paths to http:http on every start (a bind mount
doesn't inherit a named volume's ownership or get seeded from the image
the way COPY does). Also fixes AllowOverride never actually being
enabled (the sed pattern didn't account for httpd.conf's indentation,
so only DirectoryIndex-served routes worked) and pins Apache/PHP/SQLite
to a dated Arch Linux Archive snapshot for reproducible builds.

Homepage's feature grid was six cards behind what's actually shipped;
brought it in line with the features blog post.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
code
2026-07-17 02:43:03 +00:00
parent d1ce803412
commit 15b32ed256
9 changed files with 146 additions and 35 deletions
+1
View File
@@ -9,3 +9,4 @@
/graphify-out/
/public/uploads/*
!/public/uploads/.gitkeep
.env
+30 -2
View File
@@ -56,8 +56,36 @@
<p>Meta description, canonical links, Open Graph, and Twitter Card tags ship by default, all overridable per page.</p>
</article>
<article class="feature-card">
<h2>Matomo &amp; admin auth</h2>
<p>Built-in analytics tracking and a multi-user session login for <code>/admin/*</code> — both off until you turn them on in <code>App/config.php</code>.</p>
<h2>Admin authentication</h2>
<p>A multi-user session login gates every <code>/admin/*</code> route, with email verification for new accounts. Off by default in <code>App/config.php</code>.</p>
</article>
<article class="feature-card">
<h2>Access control &amp; drafts</h2>
<p>Gate a page to a user or group with one <code>Lib\Access</code> call in its sidecar, or preview an unfinished page as an admin-only draft.</p>
</article>
<article class="feature-card">
<h2>Media manager &amp; comments</h2>
<p>An upload/browse/delete UI at <code>/admin/media</code>, and <code>Lib\Comments</code> for a moderated comment thread on any page.</p>
</article>
<article class="feature-card">
<h2>Database, zero setup</h2>
<p><code>Lib\Db</code> wraps PDO for SQLite or MySQL, multiple named connections at once, migrating automatically on first use.</p>
</article>
<article class="feature-card">
<h2>Search, sitemap &amp; tags</h2>
<p>One content index backs full-text <code>/search</code>, <code>/sitemap.xml</code>, and blog tag browsing — reindexed lazily, no extra steps.</p>
</article>
<article class="feature-card">
<h2>Blog RSS feed</h2>
<p><code>/blog/feed</code> is built from the same hand-written post list <code>App/pages/blog/index.php</code> renders from — no database required.</p>
</article>
<article class="feature-card">
<h2>Matomo &amp; dark/light theme</h2>
<p>Built-in analytics tracking, off by default, alongside a nav toggle that swaps every color via CSS custom properties.</p>
</article>
<article class="feature-card">
<h2>Form security by default</h2>
<p><code>Lib\Csrf</code>, <code>Lib\SpamGuard</code>'s honeypot check, and cleaning input accessors — wired into the contact form and every admin form.</p>
</article>
<article class="feature-card">
<h2>Override anything</h2>
+29 -5
View File
@@ -1,9 +1,20 @@
# Arch Linux + Apache + PHP image for running novaconium in production.
# See /admin/docs/docker for volumes, overriding App/ without a rebuild,
# and optional MySQL wiring.
# See /admin/docs/docker for the bind-mounted paths, docker-entrypoint.sh's
# seeding/permissions behavior, and optional MySQL wiring.
FROM archlinux:base
RUN pacman -Syu --noconfirm --needed apache php php-apache sqlite \
# Arch's repos are rolling-release with no version-pinned packages, so
# `pacman -S apache=X.Y` isn't reliably available — point pacman at a dated
# Arch Linux Archive (https://archive.archlinux.org) snapshot instead of the
# live mirror so a rebuild months from now installs the same Apache/PHP/
# SQLite versions instead of whatever happens to be current that day. Bump
# ARCH_SNAPSHOT deliberately (e.g. to pick up a PHP security release), not
# as a side effect of an unrelated rebuild.
ARG ARCH_SNAPSHOT=2026/07/01
RUN echo "Server=https://archive.archlinux.org/repos/${ARCH_SNAPSHOT}/\$repo/os/\$arch" \
> /etc/pacman.d/mirrorlist
RUN pacman -Syyu --noconfirm --needed apache php php-apache sqlite \
&& pacman -Scc --noconfirm
# php-apache on Arch is built against mpm_prefork, not httpd's default
@@ -15,7 +26,7 @@ RUN sed -i \
-e '/^#LoadModule rewrite_module/s/^#//' \
-e 's#DocumentRoot "/srv/http"#DocumentRoot "/var/www/html/public"#' \
-e 's#<Directory "/srv/http">#<Directory "/var/www/html/public">#' \
-e 's/^AllowOverride None/AllowOverride All/' \
-e 's/^\( *\)AllowOverride None$/\1AllowOverride All/' \
/etc/httpd/conf/httpd.conf \
&& printf '\nLoadModule php_module modules/libphp.so\nAddHandler php-script .php\nDirectoryIndex index.php index.html\nServerName localhost\n' \
>> /etc/httpd/conf/httpd.conf \
@@ -30,11 +41,24 @@ COPY novaconium/ ./novaconium/
COPY public/ ./public/
COPY App/ ./App/
# Runtime-writable paths not covered by named volumes in docker-compose.yml.
# Pristine copy of the starter App/, kept outside /var/www/html so
# docker-entrypoint.sh can reseed a bind-mounted (but empty/missing) App/ on
# first start — see docker-entrypoint.sh and /admin/docs/docker.
RUN cp -a App/ /opt/novaconium-app-default/
# Runtime-writable paths — cache/uploads/App/data are bind-mounted from the
# host by docker-compose.yml, so docker-entrypoint.sh re-chowns them at
# every container start (a build-time chown only survives on the image
# layer, not on a host bind mount). This chown still covers a fresh
# container with no bind mounts configured at all.
RUN mkdir -p public/cache public/uploads data \
&& touch novaconium/contact-log.txt \
&& chown -R http:http public/cache public/uploads data App novaconium/contact-log.txt
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 80
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["httpd", "-D", "FOREGROUND"]
+11 -1
View File
@@ -13,7 +13,11 @@ For a full tour of what's included — routing, sidecars, caching, admin auth, a
## Getting started
**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) and, for production, Apache with `mod_rewrite` and `AllowOverride All`. A few optional features (database, content index/search, admin authentication) need the `pdo_sqlite` extension — see `/admin/docs` for details once running.
### Requirements:
PHP 8.1+ (uses `readonly` constructor-promoted properties) and, for production, Apache with `mod_rewrite` and `AllowOverride All`. A few optional features (database, content index/search, admin authentication) need the `pdo_sqlite` extension — see `/admin/docs` for details once running.
### Developers
Run it locally, no Apache needed:
@@ -23,6 +27,12 @@ php -S 127.0.0.1:8000 -t public public/router.php
Visit `http://127.0.0.1:8000/` — click around the example pages, then open `http://127.0.0.1:8000/admin/docs` for the complete documentation, rendered live from this same instance.
### Webmasters
- Clone this repo.
- docker build: ``` docker build -t novaconium . ```
- docker compose up -d
## Documentation
The full framework documentation lives inside the framework itself, at `/admin/docs` on any running instance — so it travels with the code, no internet connection needed. That's the canonical reference for everything: requirements, running locally, deploying on Apache or Docker, starting a new project, updating the framework, adding a page, routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, access control, draft pages, media manager, styling, and project layout.
+5 -12
View File
@@ -1,14 +1,13 @@
services:
web:
build: .
image: novaconium-beta
ports:
- "8080:80"
volumes:
- cache:/var/www/html/public/cache
- uploads:/var/www/html/public/uploads
- data:/var/www/html/data
# Uncomment to override the baked-in App/ with a host copy, no rebuild:
# - ./App:/var/www/html/App
- ${VOL_PATH:-/data}/novaconium/cache:/var/www/html/public/cache
- ${VOL_PATH:-/data}/novaconium/uploads:/var/www/html/public/uploads
- ${VOL_PATH:-/data}/novaconium/App:/var/www/html/App
- ${VOL_PATH:-/data}/novaconium/data:/var/www/html/data
# Optional — only needed if App/config.php adds a db_connections entry
# with driver: mysql. See /admin/docs/database.
@@ -21,9 +20,3 @@ services:
# MYSQL_ROOT_PASSWORD: change-me
# volumes:
# - mysql-data:/var/lib/mysql
volumes:
cache:
uploads:
data:
# mysql-data:
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# Runs once per container start, before Apache — see /admin/docs/docker.
#
# docker-compose.yml bind-mounts App/, public/cache/, public/uploads/, and
# data/ from the host so a project's content/db survive a rebuild and can be
# edited without one. Two problems a plain COPY-at-build-time image can't
# solve on its own:
#
# 1. A bind mount to an empty (or not-yet-created) host directory shadows
# whatever COPY baked into that path in the image, replacing it with
# nothing — Docker does not seed bind mounts from image content the way
# it seeds a fresh named volume. App/ is only ever the docs/starter
# content wanted on host: seed it from the pristine copy stashed
# at build time (/opt/novaconium-app-default) if the mounted dir is
# empty, so `docker compose up` produces a working site on a first run
# with no manual copy step.
# 2. A bind-mounted host directory keeps the host's ownership, not the
# image's — the build-time `chown` in the Dockerfile never applies to
# it. Re-chown the mounted paths to the Apache worker user on every
# start so they're writable regardless of the host-side UID/GID.
set -e
if [ -z "$(ls -A /var/www/html/App 2>/dev/null)" ]; then
cp -a /opt/novaconium-app-default/. /var/www/html/App/
fi
chown -R http:http /var/www/html/public/cache /var/www/html/public/uploads /var/www/html/App /var/www/html/data
exec "$@"
+22 -14
View File
@@ -2,44 +2,52 @@
{% block title %}Docker{% endblock %}
{% block description %}Running novaconium in a container: the Apache/PHP image, its three volumes, and overriding App/ without a rebuild.{% endblock %}
{% block description %}Running novaconium in a container: the Apache/PHP image, its four bind-mounted paths, and how docker-entrypoint.sh seeds and permissions them.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Docker</h1>
<p>The root <code>Dockerfile</code> builds an Apache + PHP image on an <a href="https://archlinux.org/">Arch Linux</a> base, with <code>mod_rewrite</code>, <code>AllowOverride All</code>, and <code>pdo_sqlite</code>/<code>pdo_mysql</code> already enabled — nothing else in <a href="/admin/docs/getting-started">Getting started</a>'s "Deploy on Apache" section needs configuring by hand. <code>docker-compose.yml</code> wires it up with three named volumes so a project's own cache, uploads, and database never live inside a path that gets wiped by the <a href="/admin/docs/getting-started">"Updating the framework"</a> workflow.</p>
<p>The root <code>Dockerfile</code> builds an Apache + PHP image on an <a href="https://archlinux.org/">Arch Linux</a> base, with <code>mod_rewrite</code>, <code>AllowOverride All</code>, and <code>pdo_sqlite</code>/<code>pdo_mysql</code> already enabled — nothing else in <a href="/admin/docs/getting-started">Getting started</a>'s "Deploy on Apache" section needs configuring by hand.</p>
<pre><code>docker compose up --build</code></pre>
<p>Visit <code>http://localhost:8080/</code>.</p>
<h2>The volumes</h2>
<h2>The bind mounts</h2>
<p><code>docker-compose.yml</code> bind-mounts four host paths under <code>${VOL_PATH:-/data}/novaconium/</code> (override <code>VOL_PATH</code> in the environment to relocate all four at once) so a project's content, uploads, cache, and database live on the host — editable without a rebuild, and untouched by the <a href="/admin/docs/getting-started">"Updating the framework"</a> workflow:</p>
<ul>
<li><code>App</code> → <code>/var/www/html/App</code> — the project itself (pages, lib, config, migrations). Edit it on the host; changes show up after <code>docker compose restart web</code>, no rebuild.</li>
<li><code>cache</code> → <code>public/cache/</code> — the static HTML page cache (see <a href="/admin/docs/caching">Static caching</a>). Disposable; clear it from inside the container with <code>docker compose exec web php novaconium/bin/clear-cache.php</code>.</li>
<li><code>uploads</code> → <code>public/uploads/</code> — files uploaded through <a class="icon-link" href="/admin/docs/media-manager">Media manager</a> (<code>/admin/media</code>). Needs its own volume specifically because <code>public/</code> is otherwise baked into the image with <code>COPY</code> at build time — without this, an upload would only survive until the next <code>docker compose up --build</code>. Kept separate from the <code>data</code> volume below deliberately, since a project might use MySQL or no database at all and shouldn't have upload storage coupled to the SQLite volume.</li>
<li><code>data</code> → <code>data/</code> — holds <code>data/novaconium.sqlite</code> if <a href="/admin/docs/database">Database</a>-backed features (admin auth, content index) are enabled. Persists across <code>docker compose down</code>/<code>up</code> as long as you don't pass <code>-v</code>.</li>
<li><code>uploads</code> → <code>public/uploads/</code> — files uploaded through <a class="icon-link" href="/admin/docs/media-manager">Media manager</a> (<code>/admin/media</code>).</li>
<li><code>data</code> → <code>data/</code> — holds <code>data/novaconium.sqlite</code> if <a href="/admin/docs/database">Database</a>-backed features (admin auth, content index) are enabled.</li>
</ul>
<p><code>App/</code> itself is <strong>not</strong> a named volume — it's baked into the image with <code>COPY</code> at build time, so <code>docker compose up --build</code> alone produces a working site with no extra steps. A named volume seeded from <code>COPY App/</code> would only populate once, on first container creation, and would silently go stale on every later rebuild. To edit content without rebuilding, uncomment the bind mount in <code>docker-compose.yml</code>:</p>
<p>A bind mount to a host directory shadows whatever the image's <code>COPY</code> put at that path — including with nothing at all, if the host directory doesn't exist yet or is empty. Docker doesn't seed a bind mount from image content the way it seeds a fresh named volume. Two consequences the plain image can't handle by itself, both worked around by <code>docker-entrypoint.sh</code> (the container's <code>ENTRYPOINT</code>, runs once per start before Apache):</p>
<pre><code>volumes:
- cache:/var/www/html/public/cache
- uploads:/var/www/html/public/uploads
- data:/var/www/html/data
- ./App:/var/www/html/App</code></pre>
<p>A bind mount at the same path as a <code>COPY</code>'d directory shadows the image layer at container start, so a host-side edit under <code>App/pages/</code> shows up after <code>docker compose restart web</code> — no rebuild, no custom entrypoint logic.</p>
<ul>
<li><strong>Empty <code>App/</code> on first run.</strong> The Dockerfile stashes a pristine copy of the baked-in <code>App/</code> at <code>/opt/novaconium-app-default</code> at build time. If the bind-mounted <code>/var/www/html/App</code> is empty when the container starts, the entrypoint copies that pristine copy in — so a first <code>docker compose up</code> against a fresh, empty <code>${VOL_PATH}/novaconium/App</code> on the host produces a working site instead of a blank one. It only seeds when the directory is empty, so it never clobbers content you've already put there.</li>
<li><strong>Permissions.</strong> A bind mount keeps the host directory's ownership, not the image's — the build-time <code>chown -R http:http</code> in the Dockerfile only applies to the image layer, not to whatever gets mounted over it. The entrypoint re-runs <code>chown -R http:http</code> on all four mounted paths on every container start, so Apache's worker user (<code>http</code> on Arch) can always write to them regardless of the host-side UID/GID — no manual <code>chmod</code> on the host required.</li>
</ul>
<h2>MySQL</h2>
<p><code>Lib\Db</code> supports MySQL alongside or instead of SQLite (see <a href="/admin/docs/database">Database</a>). <code>docker-compose.yml</code> has a commented-out <code>db</code> service and <code>mysql-data</code> volume — uncomment both, add a <code>db_connections</code> entry with <code>driver: mysql</code> and matching credentials in <code>App/config.php</code>, and the app container can reach it at hostname <code>db</code>.</p>
<h2>Pinned package versions</h2>
<p>Arch's repos are rolling-release, so the Dockerfile doesn't install <code>apache</code>/<code>php</code>/<code>sqlite</code> from the live mirror — it points <code>pacman</code> at a dated <a href="https://archive.archlinux.org/">Arch Linux Archive</a> snapshot via the <code>ARCH_SNAPSHOT</code> build arg (a <code>YYYY/MM/DD</code> path), so a rebuild next month installs the exact same versions as today instead of whatever's newest that day. To move to a newer snapshot deliberately (e.g. to pick up a PHP security release):</p>
<pre><code>docker build --build-arg ARCH_SNAPSHOT=2026/08/01 --no-cache -t novaconium-beta .</code></pre>
<p><code>--no-cache</code> is worth using any time you change the Dockerfile itself (not just <code>App/</code> content) — Docker otherwise reuses a cached layer for an unchanged-looking <code>RUN</code> step, and <code>docker-compose.yml</code> here uses <code>image:</code> rather than <code>build:</code>, so <code>docker compose up</code> alone never rebuilds at all; you have to <code>docker build</code> (and re-tag, if needed) yourself first.</p>
<h2>Arch-specific notes</h2>
<p>Arch's <code>php-apache</code> package is built against <code>mpm_prefork</code>, not <code>httpd</code>'s default <code>mpm_event</code> — the Dockerfile swaps MPMs as part of the build. The Apache/PHP worker user on Arch is <code>http</code>, not Debian's <code>www-data</code>; the Dockerfile <code>chown</code>s the cache/uploads/data/App directories and <code>novaconium/contact-log.txt</code> to <code>http:http</code> at build time so a freshly created named volume (which inherits the image mountpoint's ownership) is writable immediately. If you swap a named volume for a bind mount pointing at a host directory with different ownership, that automatic chown doesn't apply — you may need to adjust permissions on the host side.</p>
<p>Arch's <code>php-apache</code> package is built against <code>mpm_prefork</code>, not <code>httpd</code>'s default <code>mpm_event</code> — the Dockerfile swaps MPMs as part of the build. The Apache/PHP worker user on Arch is <code>http</code>, not Debian's <code>www-data</code>; both the build-time <code>chown</code> and <code>docker-entrypoint.sh</code>'s startup <code>chown</code> (see above) target <code>http:http</code> for this reason.</p>
<p>This is a separate Dockerfile from the one-off Dart Sass build tool described in <a href="/admin/docs/styling">Styling</a> — that one lives at <code>Dockerfile.sass</code>, a Debian-based image whose only job is running the <code>sass</code> CLI, not serving the app.</p>
{% endblock %}
+1 -1
View File
@@ -386,7 +386,7 @@ button
opacity: 0
animation: fade-in-up 0.5s ease-out forwards
@for $i from 1 through 6
@for $i from 1 through 12
&:nth-child(#{$i})
animation-delay: #{0.3 + $i * 0.06}s
+18
View File
@@ -424,6 +424,24 @@ button:hover {
.feature-card:nth-child(6) {
animation-delay: 0.66s;
}
.feature-card:nth-child(7) {
animation-delay: 0.72s;
}
.feature-card:nth-child(8) {
animation-delay: 0.78s;
}
.feature-card:nth-child(9) {
animation-delay: 0.84s;
}
.feature-card:nth-child(10) {
animation-delay: 0.9s;
}
.feature-card:nth-child(11) {
animation-delay: 0.96s;
}
.feature-card:nth-child(12) {
animation-delay: 1.02s;
}
.feature-card h2 {
font-size: 1.1rem;
margin: 0 0 0.5rem;