build: M1 Dockerized development stack with PHP skeleton, config/logging, Tailwind assets, and wrappers

Implements MeshVault M1 (issues #6–11):

- Four-service Docker Compose stack (nginx, PHP-FPM 8.3, PostgreSQL 17, Node assets)
- Slim 4 + PHP-DI routing with escaped PHP views and production/dev error handling
- Immutable validated AppConfig with redacted diagnostics
- JSON stdout Monolog logging with job-scoped context
- Tailwind CSS v4 deterministic locked builds with template scanning
- POSIX and PowerShell dev wrappers (up/down/test/lint/assets/watch)
- Startup config validation fails before FPM serves as unprivileged www-data
- migrate/seed explicitly unavailable until M2 (#12); #10 remains open

Verified via clean-start acceptance on remote host (keith@192.168.2.23).
This commit is contained in:
Keith Solomon committed 2026-10-11 16:45:45 -05:00
1 parent c2747a5762
commit d3e8cf8fae
39 files changed
+5291 -38

No files matched your search

+113 -6
View File
@@ -1,7 +1,114 @@
# Container configuration
# Development containers (M1)
M1 issue #6 adds nginx, PHP-FPM, PostgreSQL and asset build services. There is no
Compose file yet. Keep database data, managed library files and uploaded media
persistent and outside the application source. Mount import sources read-only
where possible. Node is asset tooling in MVP, not a required mesh-processing
runtime. Never bake credentials or user collections into images.
Four-service Docker Compose stack: nginx, unprivileged PHP-FPM, PostgreSQL and
an assets container for Tailwind CSS builds. The application layer (Slim 4,
PHP-DI, Monolog) is wired, but there is no catalog schema, migration runner or
asset pipeline beyond Tailwind. Do not expose this stack to the Internet.
## Prerequisites
- Docker Engine/Desktop with Linux containers and Docker Compose v2+.
- No host PHP, Composer or Node is required.
## First start
From the repository root, copy `.env.example` to `.env` and edit
`MESHVAULT_DB_PASSWORD` to a private, nonempty value. Never commit `.env` or print
expanded Compose configuration in shared logs; use `docker compose config --quiet`.
Create a **new dedicated** `library` directory (`mkdir library`). Do not use an
existing collection. On Linux, give container UID/GID 33 ownership of that new
empty directory, for example `sudo chown 33:33 ./library` and
`sudo chmod 750 ./library`. Alternatively, after building, use the narrowly scoped
one-off command below **only for this new empty directory**:
```sh
docker compose build app
docker compose run --rm --no-deps --user root app chown 33:33 /var/lib/meshvault/library
docker compose config --quiet
docker compose up -d --wait
```
Also ensure `public/build` is writable by the assets container user (`node`, UID
1000). On Linux, run:
```sh
mkdir -p public/build
chown 1000:1000 public/build
```
Docker Desktop bind permissions differ; verify writability using the runtime
identity rather than assuming host chmod works. The stack never automatically
creates or changes ownership of a configured library directory.
## Task wrappers
Use `dev.sh` (POSIX) or `dev.ps1` (PowerShell) from the repository root. They are
independent of your current working directory and propagate exit codes.
```sh
./dev.sh up # build and start services
./dev.sh down # stop services (volume removal is forbidden)
./dev.sh test # run composer test inside the app container
./dev.sh lint # run composer lint inside the app container
./dev.sh assets # build minified CSS inside the assets container
./dev.sh watch # start Tailwind watch inside the assets container
./dev.sh migrate # unavailable until M2 (#12); exits nonzero
./dev.sh seed # unavailable until M2 (#12); exits nonzero
```
`down --volumes` and `down -v` are blocked by the wrapper. Never remove the named
`pgdata` volume or the library bind.
## Asset builds
Assets are built inside the `assets` service using a locked `package-lock.json`.
The generated `public/build/app.css` is served statically by nginx.
```sh
./dev.sh assets
```
Watch mode rebuilds on template changes:
```sh
./dev.sh watch
```
## Verification
Open http://127.0.0.1:8090/ for the landing page. `docker compose exec app id`
should report UID/GID 33. Check a disposable library write without becoming root:
```sh
docker compose exec app php -r '$p = getenv("MESHVAULT_LIBRARY_DIR") . "/write-check"; if (file_put_contents($p, "ok") === false) { exit(1); } unlink($p);'
```
`down` preserves the named `pgdata` volume and library bind. Changing database
credentials in `.env` does not change credentials in an initialized PostgreSQL
volume. Back up data before upgrades.
## Settings and boundaries
`.env.example` documents every current setting: Compose project, HTTP host/port,
DB name/user/password, host library path and absolute container library path.
The container library path must stay outside `/var/www/html` and must not overlap
system paths. The only required non-default value is the database password.
The default project is `meshvault-m1`; HTTP binds localhost:8090. Only nginx has a
published port. PostgreSQL readiness gates app startup. FPM itself runs as
`www-data`, not just its workers. nginx mounts only `public/` and executes only
`/index.php`; source, credentials and library are not web mounts.
Source mounts are explicit and read-only. No host `vendor` or `node_modules` is
mounted; dependencies are installed inside images from lockfiles. No Docker socket
is mounted. Startup configuration validation is provided by the entrypoint script.
Image branches: PHP 8.3 FPM Bookworm (security support to December 2027), PostgreSQL
17 Bookworm (support to November 2029), nginx 1.30 Alpine (stable), Node 24
Bookworm slim (LTS). Tags are versioned but move with patch updates, not digest
locks. PHP 8.3 compatibility is retained.
Remote verification uses only `/home/keith/Projects/MeshVault` after confirming
HOME, explicit project `meshvault-m1`, and an explicit source-only copy manifest.
Access from another machine via `ssh -L 8090:127.0.0.1:8090 keith@192.168.2.23`.
Never attach original collections or change unrelated services.
+7
View File
@@ -0,0 +1,7 @@
FROM node:24-bookworm-slim
WORKDIR /workspace
COPY --chown=node:node package.json package-lock.json ./
RUN chown node:node /workspace
USER node
RUN npm ci --no-audit --no-fund
CMD ["npm", "run", "build"]
+28
View File
@@ -0,0 +1,28 @@
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
server_tokens off;
autoindex off;
location ~ /\. {
return 404;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Only the front controller may execute; never arbitrary uploaded PHP.
location = /index.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param HTTP_PROXY "";
fastcgi_pass app:9000;
}
location ~* \.php(?:/|$) {
return 404;
}
}
+20
View File
@@ -0,0 +1,20 @@
FROM php:8.3-fpm-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libxml2-dev unzip \
&& docker-php-ext-install mbstring dom \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
WORKDIR /var/www/html
# Dependencies are Linux-native and rebuilt from the lock, never host-mounted.
COPY composer.json composer.lock ./
RUN composer install --no-interaction --prefer-dist --no-progress
COPY bin ./bin
COPY docker/php/entrypoint.sh /usr/local/bin/meshvault-entrypoint
COPY docker/php/zz-meshvault.conf /usr/local/etc/php-fpm.d/zz-meshvault.conf
RUN chmod 755 /usr/local/bin/meshvault-entrypoint
RUN mkdir -p /var/www/html/.phpunit.cache && chown www-data:www-data /var/www/html/.phpunit.cache
# The entire FPM process runs as the same identity used for library checks.
USER www-data
ENTRYPOINT ["meshvault-entrypoint"]
CMD ["php-fpm"]
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
# Image and Compose both select www-data; validation and FPM inherit this identity.
if [ "$1" = "php-fpm" ]; then
if [ "$(id -u)" = 0 ]; then
echo 'Refusing to validate or serve FPM as root; use www-data.' >&2
exit 1
fi
php /var/www/html/bin/check-config.php
fi
exec docker-php-entrypoint "$@"
+7
View File
@@ -0,0 +1,7 @@
[www]
; Preserve explicit Compose settings and forward Monolog JSON without prefixes.
clear_env = no
catch_workers_output = yes
decorate_workers_output = no
php_admin_flag[log_errors] = on
php_admin_flag[display_errors] = off