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 <noreply@anthropic.com>
4.9 KiB
Setup & configuration
For the quick version — just running the app — see the root README. This covers the rest: running without Docker, every configuration option, and the test suite.
Run with Docker
The only requirement is Docker with the Compose plugin.
docker compose up -d
This runs a multi-stage build — a Node stage compiles the Vue frontend, then a
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 http://localhost:8080: 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
shell for client-side routing.
- A Mailpit container (the maintained MailHog
successor — one ~15 MB Go binary, messages kept in memory) also starts. The API
sends all email to it; read it at http://localhost:8025. Set
MAIL_TRANSPORT=mailor=smtp(withMAIL_SMTP_*) to send for real. - The image is the artifact: PHP source and the compiled frontend are copied in
at build time, not bind-mounted. Rebuild to pick up any code change:
docker compose up -d --build. For iterating on the frontend, run the Vite dev server on the host instead (see web/README.md). - The SQLite database and the generated JWT signing key live in the
storagenamed volume, mounted at/var/www/storage, so they survivedocker compose restart/down+up. docker compose down -vremoves the volume and gives you a clean database.- Override settings via the environment or a
.envfile in the repo root (Compose substitutesAPP_DEBUG,JWT_SECRET,JWT_TTL— see docker-compose.yml).
Run without Docker
Requires PHP 8.1+ with the pdo_sqlite and mbstring extensions, plus
Composer. On Fedora:
sudo dnf install php-cli php-pdo php-mbstring composer
Setup
composer install
cp .env.example .env # optional; sane defaults are used without it
composer migrate # creates storage/database.sqlite and its tables
Running
composer serve # http://localhost:8080 (php -S localhost:8080 -t public)
Any web server can serve the API as long as the document root is public/ and
unknown paths fall through to public/index.php. public/.htaccess also serves
a built frontend from public/ (copy web/dist/ there) and only falls back to
index.php for /api and when no index.html is present.
The frontend itself still needs its own toolchain — see web/README.md.
Configuration
All settings are optional environment variables (read from .env or the real
environment). See .env.example.
| Variable | Default | Purpose |
|---|---|---|
APP_DEBUG |
false |
Include exception details in error responses |
DATABASE_PATH |
storage/database.sqlite |
SQLite file location |
JWT_SECRET |
auto-generated into storage/secret.key |
Token signing key |
JWT_TTL |
86400 |
Token lifetime in seconds |
APP_ALLOW_REGISTRATION |
true |
When false, a magic link is only ever sent to an existing address — an unknown one is silently ignored, so no new accounts get created |
MAGIC_LINK_RESEND_SECONDS |
60 |
Minimum gap before a magic link can be resent to the same address (sign-in or email-change). Docker Compose overrides this to 0, so links resend immediately in development |
APP_URL |
http://localhost:8080 |
Base URL used to build magic links (http://localhost:5173 for a host npm run dev) |
WEBAUTHN_RP_ID |
APP_URL's host |
Passkey relying party ID (domain). Must be localhost or a real domain over HTTPS — a LAN IP won't work |
WEBAUTHN_RP_NAME |
Projects |
Passkey relying party display name, shown in the browser/OS prompt |
MAIL_TRANSPORT |
mail |
mail (PHP mail()), smtp, or log (append to a file) |
MAIL_FROM / MAIL_FROM_NAME |
no-reply@todo.test / Projects |
Envelope sender |
MAIL_LOG_PATH |
storage/mail.log |
Where log transport writes |
MAIL_SMTP_HOST / _PORT / _USERNAME / _PASSWORD / _ENCRYPTION |
— / 587 / — / — / tls |
Used only when MAIL_TRANSPORT=smtp |
Standalone, SMTP is opt-in and the API otherwise falls back to PHP's mail().
Under Docker Compose the default is MAIL_TRANSPORT=smtp pointed at the bundled
Mailpit container (mailpit:1025, no auth/TLS); open http://localhost:8025 to
read what was "sent".
Tests
composer install # installs phpunit (require-dev)
vendor/bin/phpunit
Each test run applies every file in migrations/*.sql to a fresh SQLite
database, so it always exercises the current schema from scratch.