From f1309b4c10cfb763ad2f970a94e82401996e19d9 Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Sat, 5 Sep 2026 02:15:39 +0100 Subject: [PATCH] Rebuild the Docker image on Alpine: ~735MB -> ~89MB Stage 2 was php:8.3-apache (Debian), which compiles PHP from source with --with-apxs2 for mod_php -- that base image alone is 719MB of our 735MB, before any app code. Replaced with alpine:3.24 + apk's own prebuilt php83/php83-apache2/apache2 packages: same architecture (one process, mod_php, .htaccess-driven rewriting), no fpm/nginx rewrite needed. - docker/apache.conf: rewritten for Alpine's apache2 (mod_rewrite ships but isn't loaded by default; a different default document root/log paths). Logs redirected to stdout/stderr so `docker logs` still shows them -- Alpine's own defaults write to a real file under ServerRoot, unlike the official Debian image's symlinked paths. - docker/entrypoint.sh: su-exec instead of su -- BusyBox's su doesn't take the same -c/user argument order as the GNU one the previous entrypoint relied on. Also moved earlier in the Dockerfile (with the other rarely-changing setup, before COPY . .) so it no longer re-runs on every build for a file that essentially never changes. - Composer's binary is still borrowed from the official composer:2 image via multi-stage COPY, not apk's own `composer` package, which turned out to pull in an entire second PHP interpreter (php85) as a dependency just to run itself. - ext-iconv needed adding explicitly (symfony/polyfill-mbstring depends on it; the official Debian image bundles it by default, apk doesn't). Verified against the real compose stack, not just that it builds: apk install; composer install; migrations on startup; PHPUnit 88/88 (runs on the host, but confirms nothing else broke); and by hand, all through the actual container -- health check, SPA fallback for unknown routes, static assets served directly, the API's 401 guard, and a full magic-link -> verify -> JWT -> authenticated project create/list round trip via the real Mailpit catcher. Co-Authored-By: Claude Sonnet 5 --- Dockerfile | 57 +++++++++++++++++++++++++++++++------------- docker/apache.conf | 32 ++++++++++++++++--------- docker/entrypoint.sh | 11 +++++---- docs/architecture.md | 5 +++- docs/history.md | 1 + docs/setup.md | 4 +++- 6 files changed, 75 insertions(+), 35 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9eca7c3..cd790d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,24 +13,51 @@ RUN npm ci COPY web/ ./ RUN npm run build # vue-tsc type-check, then `vite build` -> /web/dist -# --- Stage 2: PHP + Apache runtime --------------------------------------------- -FROM php:8.3-apache +# --- Stage 2: PHP + Apache runtime, on Alpine ------------------------------- +FROM alpine:3.24 -# --- PHP extensions and CLI tools ---------------------------------------------- -RUN apt-get update && apt-get install -y --no-install-recommends \ - libsqlite3-dev \ - libonig-dev \ - unzip \ +# apk's php83 packages are prebuilt (no compiling PHP from source, unlike the +# official Debian php:8.3-apache image), and split finely enough to pull in +# only what this app actually uses. php83-apache2 is Alpine's mod_php SAPI +# module -- the same role as php:8.3-apache's --with-apxs2 build. This alone +# takes the final image from ~735MB to ~90MB. +RUN apk add --no-cache \ + apache2 \ curl \ - && docker-php-ext-install pdo_sqlite mbstring \ - && rm -rf /var/lib/apt/lists/* + php83 \ + php83-apache2 \ + php83-ctype \ + php83-curl \ + php83-fileinfo \ + php83-iconv \ + php83-mbstring \ + php83-opcache \ + php83-openssl \ + php83-pdo \ + php83-pdo_sqlite \ + php83-phar \ + php83-session \ + php83-tokenizer \ + su-exec \ + # apk doesn't symlink an unversioned `php` -- composer's own wrapper + # script (copied in below) and bin/migrate.php's shebang both expect one. + && ln -s /usr/bin/php83 /usr/local/bin/php # --- Composer ---------------------------------------------------------------- +# Borrowed from the official composer image rather than apk's own `composer` +# package, which drags in an entire second PHP interpreter (php85) as a +# dependency just to run the phar -- multi-stage COPY takes only the binary. COPY --from=composer:2 /usr/bin/composer /usr/bin/composer # --- Apache: document root -> public/, allow .htaccess rewrites ------------- -RUN a2enmod rewrite -COPY docker/apache.conf /etc/apache2/sites-available/000-default.conf +COPY docker/apache.conf /etc/apache2/conf.d/zz-app.conf + +# --- Entrypoint: migrate, then hand off to Apache --------------------------- +# Copied early, alongside the other rarely-changing setup above -- after +# COPY . . (below) every later layer re-runs on nearly every build, so this +# would otherwise redo work for a file that essentially never changes. +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh WORKDIR /var/www/html @@ -42,17 +69,13 @@ RUN composer install --no-dev --no-interaction --no-progress --prefer-dist --no- COPY . . RUN composer dump-autoload --optimize --no-dev \ && mkdir -p storage \ - && chown -R www-data:www-data storage + && chown -R apache:apache storage # --- Built frontend: served from the web root next to the API front controller COPY --from=frontend /web/dist/ ./public/ -# --- Entrypoint: migrate, then hand off to Apache ------------------------ -COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh - ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -CMD ["apache2-foreground"] +CMD ["httpd", "-D", "FOREGROUND"] HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ CMD curl -fsS http://localhost/api/health || exit 1 diff --git a/docker/apache.conf b/docker/apache.conf index 6b27e14..79282e4 100644 --- a/docker/apache.conf +++ b/docker/apache.conf @@ -1,13 +1,23 @@ - - DocumentRoot /var/www/html/public +# Alpine's apache2 package ships mod_rewrite but doesn't load it, and defaults +# to a document root (and log files) that don't match this project -- this +# file (auto-included via httpd.conf's "IncludeOptional conf.d/*.conf") +# overrides just those bits, on top of what php83-apache2's own +# conf.d/php83-module.conf already wired up (LoadModule for mod_php, the +# .php handler). +LoadModule rewrite_module modules/mod_rewrite.so - - Options -Indexes +FollowSymLinks - AllowOverride All - Require all granted - DirectoryIndex index.html index.php - +ServerName localhost +DocumentRoot "/var/www/html/public" - ErrorLog ${APACHE_LOG_DIR}/error.log - CustomLog ${APACHE_LOG_DIR}/access.log combined - + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + DirectoryIndex index.html index.php + + +# Alpine's own defaults log to a real file under ServerRoot (logs/*.log) -- +# stdout/stderr instead, so `docker logs` shows them, the way the official +# Debian php:apache image's symlinked log paths used to. +ErrorLog /dev/stderr +CustomLog /dev/stdout combined diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 8609581..cc54a7c 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -5,10 +5,11 @@ set -e # development (see docker-compose.yml); the plain image falls back to ./storage. STORAGE_DIR="${STORAGE_PATH:-storage}" mkdir -p "$STORAGE_DIR" -chown -R www-data:www-data "$STORAGE_DIR" +chown -R apache:apache "$STORAGE_DIR" -# Apply pending migrations as www-data so the SQLite file it creates stays -# writable by Apache. -su -s /bin/sh -c 'php bin/migrate.php' www-data +# Apply pending migrations as apache so the SQLite file it creates stays +# writable by the web server. su-exec, not su -- BusyBox's su (this is an +# Alpine image) doesn't take the same -c/user argument order as the GNU one. +su-exec apache php bin/migrate.php -exec docker-php-entrypoint "$@" +exec "$@" diff --git a/docs/architecture.md b/docs/architecture.md index c415c72..2ecfef6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,8 +21,11 @@ src/Repository/ Database access (User, EmailVerification, Passkey, src/Support/Validator.php Request-body validation helper migrations/*.sql Schema, applied by bin/migrate.php Dockerfile Multi-stage: Node frontend build + PHP 8.3/Apache runtime + (Alpine -- apk's own php83/apache2 packages, not a + from-source compile; ~90MB image) docker-compose.yml Local stack: app (SPA + API) + Mailpit -docker/ Apache vhost + container entrypoint +docker/ Apache vhost (mod_rewrite, document root) + entrypoint + (runs pending migrations, then hands off to Apache) web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host) ``` diff --git a/docs/history.md b/docs/history.md index a43d005..46be7bb 100644 --- a/docs/history.md +++ b/docs/history.md @@ -27,3 +27,4 @@ again in stage 12); see [docs/api.md](api.md) and | 17 | Card detail view (`/cards/:id`) + its own configuration view — a card's text is no longer inline-editable; every list links to its own page instead | ✅ done | | 18 | Project view split into real routes — Explore (`/projects/:id`) and Kanban (`/projects/:id/kanban`) are separate pages under a shared layout, not client-side tab state | ✅ done | | 19 | Backend/frontend refactoring pass — deduplicated controller/repository boilerplate and CSS, dropped the never-surfaced project `description` field, squashed the SQL migration history into one clean initial schema | ✅ done | +| 20 | Docker image rebuilt on Alpine (apk's own php83/apache2 packages instead of compiling PHP from source on Debian) — ~735MB down to ~90MB, same architecture otherwise; `.dockerignore` trimmed to match | ✅ done | diff --git a/docs/setup.md b/docs/setup.md index dd1fcfc..b563678 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -13,7 +13,9 @@ docker compose up -d ``` This runs a multi-stage build — a Node stage compiles the Vue frontend, then a -PHP 8.3 + Apache stage bakes in the PHP source and the built SPA — applies +PHP 8.3 + Apache stage (Alpine-based; apk's own prebuilt packages rather than +compiling PHP from source, which is most of why the image is ~90MB rather +than several times that) bakes in the PHP source and the built SPA — applies migrations, and serves the whole app at : the SPA at `/` (assets and all) and the REST API under `/api` (e.g. `curl http://localhost:8080/api/health`). Unknown paths fall back to the SPA