Serve the built SPA from the PHP image; drop the web container

The Dockerfile is now multi-stage: a Node stage runs `npm run build`, and the
PHP/Apache stage copies the result into public/. Apache + public/.htaccess route
/api* to the Slim front controller, serve real files, and fall back to
index.html for client-side routes.

docker-compose.yml loses the `web` service, its volume, and the source
bind-mount -- the image is the artifact now (rebuild to pick up changes).
Frontend dev moves to `npm run dev` on the host; APP_URL defaults to :8080 since
the one container serves both halves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 13:37:07 +01:00
co-authored by Claude Sonnet 5
parent 49acc301ec
commit d9db4a3a30
8 changed files with 52 additions and 51 deletions
+5
View File
@@ -8,3 +8,8 @@ storage
.phpunit.result.cache .phpunit.result.cache
docs docs
README.md README.md
# Frontend: the build runs `npm ci` in its own stage, and dist/ is emitted there.
web/node_modules
web/dist
web/dev-dist
+4 -3
View File
@@ -19,9 +19,10 @@ JWT_SECRET=
# How long an issued token stays valid, in seconds (default: 86400 = 24h). # How long an issued token stays valid, in seconds (default: 86400 = 24h).
JWT_TTL=86400 JWT_TTL=86400
# Base URL of the frontend. Verification magic links point here, e.g. # Base URL the app is reached at. Verification magic links point here, e.g.
# <APP_URL>/verify-email?token=... (default: http://localhost:5173). # <APP_URL>/verify-email?token=... The Docker image serves the SPA and the API
APP_URL=http://localhost:5173 # together on http://localhost:8080; a host `npm run dev` serves it on :5173.
APP_URL=http://localhost:8080
# Email delivery. # Email delivery.
# mail — PHP's built-in mail() function (default) # mail — PHP's built-in mail() function (default)
+17
View File
@@ -1,5 +1,19 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# --- Stage 1: build the Vue frontend ---------------------------------------
FROM node:24-alpine AS frontend
WORKDIR /web
# Dependencies in their own layer, cached unless the manifests change. npm ci
# resolves this stage's (musl) platform binaries for rollup/esbuild.
COPY web/package.json web/package-lock.json ./
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 FROM php:8.3-apache
# --- PHP extensions and CLI tools ---------------------------------------------- # --- PHP extensions and CLI tools ----------------------------------------------
@@ -30,6 +44,9 @@ RUN composer dump-autoload --optimize --no-dev \
&& mkdir -p storage \ && mkdir -p storage \
&& chown -R www-data:www-data storage && chown -R www-data:www-data 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 ------------------------ # --- Entrypoint: migrate, then hand off to Apache ------------------------
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh
+7 -27
View File
@@ -8,14 +8,14 @@ services:
- "8080:80" - "8080:80"
environment: environment:
APP_DEBUG: "${APP_DEBUG:-false}" APP_DEBUG: "${APP_DEBUG:-false}"
# Generated files (SQLite DB + JWT signing key) go here, on their own # Generated files (SQLite DB + JWT signing key) go here, on the `storage`
# volume so they don't overlap the bind mount below. # volume below.
STORAGE_PATH: /var/www/storage STORAGE_PATH: /var/www/storage
# Leave blank to auto-generate a secret into the storage volume on first run. # Leave blank to auto-generate a secret into the storage volume on first run.
JWT_SECRET: "${JWT_SECRET:-}" JWT_SECRET: "${JWT_SECRET:-}"
JWT_TTL: "${JWT_TTL:-86400}" JWT_TTL: "${JWT_TTL:-86400}"
# Magic links point at the frontend dev server. # The SPA and the API are both served from this container.
APP_URL: "${APP_URL:-http://localhost:5173}" APP_URL: "${APP_URL:-http://localhost:8080}"
# Deliver to the Mailpit catcher below; read mail at http://localhost:8025. # Deliver to the Mailpit catcher below; read mail at http://localhost:8025.
MAIL_TRANSPORT: "${MAIL_TRANSPORT:-smtp}" MAIL_TRANSPORT: "${MAIL_TRANSPORT:-smtp}"
MAIL_FROM: "${MAIL_FROM:-no-reply@todo.test}" MAIL_FROM: "${MAIL_FROM:-no-reply@todo.test}"
@@ -25,9 +25,9 @@ services:
MAIL_SMTP_PASSWORD: "${MAIL_SMTP_PASSWORD:-}" MAIL_SMTP_PASSWORD: "${MAIL_SMTP_PASSWORD:-}"
MAIL_SMTP_ENCRYPTION: "${MAIL_SMTP_ENCRYPTION:-none}" MAIL_SMTP_ENCRYPTION: "${MAIL_SMTP_ENCRYPTION:-none}"
volumes: volumes:
# Live source: edit on the host, no image rebuild needed. # Only generated state is mounted. The app itself — PHP source and the
- .:/var/www/html # built frontend — is baked into the image; rebuild to pick up changes:
# Persistent storage, kept outside /var/www/html to avoid a nested mount. # docker compose up -d --build
- storage:/var/www/storage - storage:/var/www/storage
depends_on: depends_on:
- mailpit - mailpit
@@ -42,25 +42,5 @@ services:
- "1025:1025" # SMTP (also reachable in-network as mailpit:1025) - "1025:1025" # SMTP (also reachable in-network as mailpit:1025)
restart: unless-stopped restart: unless-stopped
# Optional Vite dev server. Start it with: docker compose --profile frontend up -d
# Without the profile, `docker compose up -d` runs the API alone.
web:
build: ./web
image: php-project-manager-web
profiles: ["frontend"]
ports:
- "5173:5173"
environment:
# /api is proxied to the API container on the compose network.
VITE_PROXY_TARGET: "http://app:80"
volumes:
- ./web:/app
# Keep the image's platform-specific node_modules; don't let the host's shadow them.
- web_node_modules:/app/node_modules
depends_on:
- app
restart: unless-stopped
volumes: volumes:
storage: storage:
web_node_modules:
+1 -1
View File
@@ -5,7 +5,7 @@
Options -Indexes +FollowSymLinks Options -Indexes +FollowSymLinks
AllowOverride All AllowOverride All
Require all granted Require all granted
DirectoryIndex index.php DirectoryIndex index.html index.php
</Directory> </Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log ErrorLog ${APACHE_LOG_DIR}/error.log
+16 -3
View File
@@ -1,4 +1,17 @@
# Route every request that isn't a real file through the front controller. # The built Vue SPA is served from this directory (baked into the Docker image);
# the Slim front controller handles the API. Anything that isn't a real file is
# a client-side route and falls back to index.html.
RewriteEngine On RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L] # API: hand off to the front controller.
RewriteRule ^api(/|$) index.php [QSA,L]
# Real files (hashed assets, favicon, manifest, service worker): serve directly.
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^ - [L]
# SPA fallback when a build is present; otherwise the API front controller
# (e.g. `composer serve` with no frontend built).
RewriteCond %{DOCUMENT_ROOT}/index.html -f
RewriteRule ^ index.html [L]
RewriteRule ^ index.php [L]
-15
View File
@@ -1,15 +0,0 @@
# Development image: runs the Vite dev server. A production build image is a
# later concern.
FROM node:24-alpine
WORKDIR /app
# Install dependencies against this image's platform (musl), kept in a volume
# by docker-compose so the host's node_modules never shadow them.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
+2 -2
View File
@@ -2,8 +2,8 @@ import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
// The API the dev server proxies `/api` to. Defaults to the Dockerised API on // Host-only dev server: proxy `/api` to the Dockerised API published on the
// the host; the compose `web` service overrides it to the internal address. // host. Override with VITE_PROXY_TARGET if the API runs elsewhere.
const proxyTarget = process.env.VITE_PROXY_TARGET ?? 'http://localhost:8080' const proxyTarget = process.env.VITE_PROXY_TARGET ?? 'http://localhost:8080'
// https://vite.dev/config/ // https://vite.dev/config/