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