Add Docker support: Arch/Apache/PHP image, three volumes, docs

Adds a root Dockerfile (Arch Linux + Apache + PHP) and docker-compose.yml
with separate cache/data/images volumes, so a project's own content,
page cache, and future uploaded images stay out of paths a framework
update would wipe. App/ is baked into the image but can be bind-mounted
to override without a rebuild. Renames the Sass build-tool Dockerfile
example in the styling doc to Dockerfile.sass to avoid colliding with
the new app Dockerfile, adds a new /admin/docs/docker page (linked from
the docs index and nav), and documents the reserved images/ directory
in AGENTS.md and README.

Also records the user's git/docker execution permission boundaries in
CLAUDE.md for future sessions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
code
2026-07-15 07:15:17 +00:00
parent f6fb2f12c6
commit 6624c4fdc3
12 changed files with 159 additions and 2 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.gitignore
graphify-out/
.claude/
public/cache/*
!public/cache/.gitkeep
data/*.sqlite*
images/*
!images/.gitkeep
docker-compose.yml
Dockerfile
Dockerfile.sass
+2
View File
@@ -7,3 +7,5 @@
/data/*.sqlite-shm /data/*.sqlite-shm
.claude/ .claude/
/graphify-out/ /graphify-out/
/images/*
!/images/.gitkeep
+13
View File
@@ -246,6 +246,19 @@ index below) before `App/migrations/` (project schema) — see the
project adds still defaults to no `migrations_dir` at all unless it sets project adds still defaults to no `migrations_dir` at all unless it sets
one; the two-root default is specific to `default`. one; the two-root default is specific to `default`.
A sibling top-level `images/` directory (same gitignore-wholesale-with-
`.gitkeep` treatment as `public/cache/`) exists as **reserved scaffolding
for a future image-upload feature — no config key, no upload code, nothing
reads or writes it yet.** It's deliberately a separate directory from
`data/`, not nested under it, even though both are "project data that
survives a framework update": a project may run MySQL, or no database at
all, so coupling image storage to the SQLite volume would be wrong. See
`novaconium/pages/admin/docs/docker/index.twig` for how the Docker Compose
setup gives it its own volume. Don't add an `images_dir` config key until
an actual feature reads it — an unused key would misrepresent the
framework's state, the same reasoning that keeps every other config key
tied to real consuming code.
`Lib\Session` (`novaconium/lib/Session.php`) is a thin wrapper around `Lib\Session` (`novaconium/lib/Session.php`) is a thin wrapper around
native PHP sessions (`session_start()`/`$_SESSION`, not a custom store), native PHP sessions (`session_start()`/`$_SESSION`, not a custom store),
all-static and lazy-start like `Lib\Csrf` — nothing calls `session_start()` all-static and lazy-start like `Lib\Csrf` — nothing calls `session_start()`
+5
View File
@@ -1 +1,6 @@
# Agent permissions
- Only run `git` commands with the user's explicit permission for that specific command/action.
- Never run `docker` commands (build, compose up, run, etc.) — leave all Docker execution to the user.
@AGENTS.md @AGENTS.md
+40
View File
@@ -0,0 +1,40 @@
# 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.
FROM archlinux:base
RUN pacman -Syu --noconfirm --needed apache php php-apache sqlite \
&& pacman -Scc --noconfirm
# php-apache on Arch is built against mpm_prefork, not httpd's default
# mpm_event — swap MPMs, enable mod_rewrite, point DocumentRoot at
# public/, and wire in mod_php.
RUN sed -i \
-e 's/^LoadModule mpm_event_module/#LoadModule mpm_event_module/' \
-e 's/^#LoadModule mpm_prefork_module/LoadModule mpm_prefork_module/' \
-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/' \
/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 \
&& sed -i \
-e 's/^;extension=pdo_sqlite/extension=pdo_sqlite/' \
-e 's/^;extension=pdo_mysql/extension=pdo_mysql/' \
/etc/php/php.ini
WORKDIR /var/www/html
COPY novaconium/ ./novaconium/
COPY public/ ./public/
COPY App/ ./App/
# Runtime-writable paths not covered by named volumes in docker-compose.yml.
RUN mkdir -p public/cache data images \
&& touch novaconium/contact-log.txt \
&& chown -R http:http public/cache data images App novaconium/contact-log.txt
EXPOSE 80
CMD ["httpd", "-D", "FOREGROUND"]
+9
View File
@@ -43,6 +43,14 @@ Visit `http://127.0.0.1:8000/` for the static home page, then click around — `
Point the vhost's document root at `public/`, make sure `mod_rewrite` is enabled and `AllowOverride All` is set for that directory so `public/.htaccess` takes effect, and it just works — no build step required. Point the vhost's document root at `public/`, make sure `mod_rewrite` is enabled and `AllowOverride All` is set for that directory so `public/.htaccess` takes effect, and it just works — no build step required.
### Run it in Docker
```
docker compose up --build
```
Builds an Arch Linux-based Apache/PHP image and serves the site at `http://localhost:8080/`. Three named volumes (`cache`, `data`, `images`) keep the page cache, SQLite database, and a reserved-for-later images directory out of paths the "Updating the framework" workflow below would wipe; `App/` is baked into the image but can be bind-mounted for edits without a rebuild. See [Docker](http://127.0.0.1:8000/admin/docs/docker) for details.
### Starting a new project ### Starting a new project
Clone this repo and drop its Git history — no Composer scaffold or installer: Clone this repo and drop its Git history — no Composer scaffold or installer:
@@ -86,6 +94,7 @@ php novaconium/bin/create-static-page.php blog/my-new-post
The full framework documentation — routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, draft pages, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights: The full framework documentation — routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, draft pages, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights:
- [Getting started](http://127.0.0.1:8000/admin/docs/getting-started) - [Getting started](http://127.0.0.1:8000/admin/docs/getting-started)
- [Docker](http://127.0.0.1:8000/admin/docs/docker)
- [Routing](http://127.0.0.1:8000/admin/docs/routing) - [Routing](http://127.0.0.1:8000/admin/docs/routing)
- [Sidecars](http://127.0.0.1:8000/admin/docs/sidecars) - [Sidecars](http://127.0.0.1:8000/admin/docs/sidecars)
- [Libraries](http://127.0.0.1:8000/admin/docs/libraries) - [Libraries](http://127.0.0.1:8000/admin/docs/libraries)
+29
View File
@@ -0,0 +1,29 @@
services:
web:
build: .
ports:
- "8080:80"
volumes:
- cache:/var/www/html/public/cache
- data:/var/www/html/data
- images:/var/www/html/images
# Uncomment to override the baked-in App/ with a host copy, no rebuild:
# - ./App:/var/www/html/App
# Optional — only needed if App/config.php adds a db_connections entry
# with driver: mysql. See /admin/docs/database.
# db:
# image: mysql:8
# environment:
# MYSQL_DATABASE: novaconium
# MYSQL_USER: novaconium
# MYSQL_PASSWORD: change-me
# MYSQL_ROOT_PASSWORD: change-me
# volumes:
# - mysql-data:/var/lib/mysql
volumes:
cache:
data:
images:
# mysql-data:
View File
@@ -8,6 +8,7 @@
<ul> <ul>
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Overview</a></li> <li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Overview</a></li>
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a></li> <li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a></li>
<li><a class="icon-link" href="/admin/docs/docker">{{ icons.book() }}Docker</a></li>
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a></li> <li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a></li>
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a></li> <li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a></li>
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a></li> <li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a></li>
@@ -0,0 +1,45 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% 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 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 content, cache, and (future) images never live inside a path that gets wiped by the <a href="/admin/docs/getting-started">"Updating the framework"</a> workflow.</p>
<pre><code>docker compose up --build</code></pre>
<p>Visit <code>http://localhost:8080/</code>.</p>
<h2>The volumes</h2>
<ul>
<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>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>images</code> → <code>images/</code> — a reserved, empty directory for a future image-upload feature. Nothing in the framework reads from or writes to it yet — there's no config key or upload code — it's scaffolding only, kept on its own volume deliberately, since a project might use MySQL or no database at all and shouldn't have image storage coupled to the SQLite volume.</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>
<pre><code>volumes:
- cache:/var/www/html/public/cache
- data:/var/www/html/data
- images:/var/www/html/images
- ./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>
<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>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/data/images/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>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
View File
@@ -28,6 +28,7 @@
<ul> <ul>
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a> — requirements, running locally, deploying on Apache.</li> <li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a> — requirements, running locally, deploying on Apache.</li>
<li><a class="icon-link" href="/admin/docs/docker">{{ icons.book() }}Docker</a> — the Apache/PHP image, its three volumes, and overriding <code>App/</code> without a rebuild.</li>
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> — how a URL maps to a directory under <code>App/pages/</code>.</li> <li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> — how a URL maps to a directory under <code>App/pages/</code>.</li>
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> — where your PHP logic goes.</li> <li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> — where your PHP logic goes.</li>
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a> — building a custom form with a sidecar, using the novaconium validation/spam libraries.</li> <li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a> — building a custom form with a sidecar, using the novaconium validation/spam libraries.</li>
@@ -15,7 +15,7 @@
<p>There's no PHP-based Sass compiler in this project (PHP options like <code>scssphp</code> only understand SCSS syntax) — this is a manual/CI build step, not something the app does at runtime.</p> <p>There's no PHP-based Sass compiler in this project (PHP options like <code>scssphp</code> only understand SCSS syntax) — this is a manual/CI build step, not something the app does at runtime.</p>
<p>Don't have Dart Sass installed? Run it via Docker instead — copy-paste this Dockerfile, which installs the same official standalone Dart Sass release used in this environment (<code>1.101.0</code>, via <code>pacman -S dart-sass</code> on Arch), not the npm-wrapped build:</p> <p>Don't have Dart Sass installed? Run it via Docker instead — copy-paste this into a file named <code>Dockerfile.sass</code> (the project's own root <code>Dockerfile</code> is the app container, see <a href="/admin/docs/docker">Docker</a> — this is a separate, one-off build tool, so it gets its own filename), which installs the same official standalone Dart Sass release used in this environment (<code>1.101.0</code>, via <code>pacman -S dart-sass</code> on Arch), not the npm-wrapped build:</p>
<pre><code>FROM debian:bookworm-slim <pre><code>FROM debian:bookworm-slim
@@ -38,7 +38,7 @@ ENTRYPOINT ["sass"]</code></pre>
<p>Build it once, then run it the same way you'd run the local <code>sass</code> CLI (skip the leading <code>sass</code> in the command — the image's <code>ENTRYPOINT</code> already supplies it):</p> <p>Build it once, then run it the same way you'd run the local <code>sass</code> CLI (skip the leading <code>sass</code> in the command — the image's <code>ENTRYPOINT</code> already supplies it):</p>
<pre><code>docker build -t novaconium-sass -f Dockerfile . <pre><code>docker build -t novaconium-sass -f Dockerfile.sass .
docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app novaconium-sass \ docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app novaconium-sass \
--load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code></pre> --load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code></pre>