#' \
- -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"]
diff --git a/README.md b/README.md
index 63a7c6c..78fbf61 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/docker-compose.yml b/docker-compose.yml
index 635a6c1..0236d17 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100644
index 0000000..c14e0e2
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -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 "$@"
diff --git a/novaconium/pages/admin/docs/docker/index.twig b/novaconium/pages/admin/docs/docker/index.twig
index 7cad083..ae5f49d 100644
--- a/novaconium/pages/admin/docs/docker/index.twig
+++ b/novaconium/pages/admin/docs/docker/index.twig
@@ -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 %}
Docker
- The root Dockerfile builds an Apache + PHP image on an Arch Linux base, with mod_rewrite, AllowOverride All, and pdo_sqlite/pdo_mysql already enabled — nothing else in Getting started's "Deploy on Apache" section needs configuring by hand. docker-compose.yml 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 "Updating the framework" workflow.
+ The root Dockerfile builds an Apache + PHP image on an Arch Linux base, with mod_rewrite, AllowOverride All, and pdo_sqlite/pdo_mysql already enabled — nothing else in Getting started's "Deploy on Apache" section needs configuring by hand.
docker compose up --build
Visit http://localhost:8080/.
- The volumes
+ The bind mounts
+
+ docker-compose.yml bind-mounts four host paths under ${VOL_PATH:-/data}/novaconium/ (override VOL_PATH 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 "Updating the framework" workflow:
+ App → /var/www/html/App — the project itself (pages, lib, config, migrations). Edit it on the host; changes show up after docker compose restart web, no rebuild.
cache → public/cache/ — the static HTML page cache (see Static caching). Disposable; clear it from inside the container with docker compose exec web php novaconium/bin/clear-cache.php.
- uploads → public/uploads/ — files uploaded through Media manager (/admin/media). Needs its own volume specifically because public/ is otherwise baked into the image with COPY at build time — without this, an upload would only survive until the next docker compose up --build. Kept separate from the data 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.
- data → data/ — holds data/novaconium.sqlite if Database-backed features (admin auth, content index) are enabled. Persists across docker compose down/up as long as you don't pass -v.
+ uploads → public/uploads/ — files uploaded through Media manager (/admin/media).
+ data → data/ — holds data/novaconium.sqlite if Database-backed features (admin auth, content index) are enabled.
- App/ itself is not a named volume — it's baked into the image with COPY at build time, so docker compose up --build alone produces a working site with no extra steps. A named volume seeded from COPY App/ 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 docker-compose.yml:
+ A bind mount to a host directory shadows whatever the image's COPY 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 docker-entrypoint.sh (the container's ENTRYPOINT, runs once per start before Apache):
- volumes:
- - cache:/var/www/html/public/cache
- - uploads:/var/www/html/public/uploads
- - data:/var/www/html/data
- - ./App:/var/www/html/App
-
- A bind mount at the same path as a COPY'd directory shadows the image layer at container start, so a host-side edit under App/pages/ shows up after docker compose restart web — no rebuild, no custom entrypoint logic.
+
+ - Empty
App/ on first run. The Dockerfile stashes a pristine copy of the baked-in App/ at /opt/novaconium-app-default at build time. If the bind-mounted /var/www/html/App is empty when the container starts, the entrypoint copies that pristine copy in — so a first docker compose up against a fresh, empty ${VOL_PATH}/novaconium/App 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.
+ - Permissions. A bind mount keeps the host directory's ownership, not the image's — the build-time
chown -R http:http in the Dockerfile only applies to the image layer, not to whatever gets mounted over it. The entrypoint re-runs chown -R http:http on all four mounted paths on every container start, so Apache's worker user (http on Arch) can always write to them regardless of the host-side UID/GID — no manual chmod on the host required.
+
MySQL
Lib\Db supports MySQL alongside or instead of SQLite (see Database). docker-compose.yml has a commented-out db service and mysql-data volume — uncomment both, add a db_connections entry with driver: mysql and matching credentials in App/config.php, and the app container can reach it at hostname db.
+ Pinned package versions
+
+ Arch's repos are rolling-release, so the Dockerfile doesn't install apache/php/sqlite from the live mirror — it points pacman at a dated Arch Linux Archive snapshot via the ARCH_SNAPSHOT build arg (a YYYY/MM/DD 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):
+
+ docker build --build-arg ARCH_SNAPSHOT=2026/08/01 --no-cache -t novaconium-beta .
+
+ --no-cache is worth using any time you change the Dockerfile itself (not just App/ content) — Docker otherwise reuses a cached layer for an unchanged-looking RUN step, and docker-compose.yml here uses image: rather than build:, so docker compose up alone never rebuilds at all; you have to docker build (and re-tag, if needed) yourself first.
+
Arch-specific notes
- Arch's php-apache package is built against mpm_prefork, not httpd's default mpm_event — the Dockerfile swaps MPMs as part of the build. The Apache/PHP worker user on Arch is http, not Debian's www-data; the Dockerfile chowns the cache/uploads/data/App directories and novaconium/contact-log.txt to http:http 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.
+ Arch's php-apache package is built against mpm_prefork, not httpd's default mpm_event — the Dockerfile swaps MPMs as part of the build. The Apache/PHP worker user on Arch is http, not Debian's www-data; both the build-time chown and docker-entrypoint.sh's startup chown (see above) target http:http for this reason.
This is a separate Dockerfile from the one-off Dart Sass build tool described in Styling — that one lives at Dockerfile.sass, a Debian-based image whose only job is running the sass CLI, not serving the app.
{% endblock %}
diff --git a/novaconium/sass/main.sass b/novaconium/sass/main.sass
index 3c48089..8617fde 100644
--- a/novaconium/sass/main.sass
+++ b/novaconium/sass/main.sass
@@ -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
diff --git a/public/css/main.css b/public/css/main.css
index bf7a57d..cca9366 100644
--- a/public/css/main.css
+++ b/public/css/main.css
@@ -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;