diff --git a/.dockerignore b/.dockerignore index bac6021..73fa740 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,8 @@ storage .phpunit.result.cache docs 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 diff --git a/.env.example b/.env.example index 75891d0..0081d58 100644 --- a/.env.example +++ b/.env.example @@ -19,9 +19,10 @@ JWT_SECRET= # How long an issued token stays valid, in seconds (default: 86400 = 24h). JWT_TTL=86400 -# Base URL of the frontend. Verification magic links point here, e.g. -# /verify-email?token=... (default: http://localhost:5173). -APP_URL=http://localhost:5173 +# Base URL the app is reached at. Verification magic links point here, e.g. +# /verify-email?token=... The Docker image serves the SPA and the API +# together on http://localhost:8080; a host `npm run dev` serves it on :5173. +APP_URL=http://localhost:8080 # Email delivery. # mail — PHP's built-in mail() function (default) diff --git a/Dockerfile b/Dockerfile index 38be150..9eca7c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,19 @@ # 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 # --- PHP extensions and CLI tools ---------------------------------------------- @@ -30,6 +44,9 @@ RUN composer dump-autoload --optimize --no-dev \ && mkdir -p 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 ------------------------ COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index 6714268..178aaed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,14 +8,14 @@ services: - "8080:80" environment: APP_DEBUG: "${APP_DEBUG:-false}" - # Generated files (SQLite DB + JWT signing key) go here, on their own - # volume so they don't overlap the bind mount below. + # Generated files (SQLite DB + JWT signing key) go here, on the `storage` + # volume below. STORAGE_PATH: /var/www/storage # Leave blank to auto-generate a secret into the storage volume on first run. JWT_SECRET: "${JWT_SECRET:-}" JWT_TTL: "${JWT_TTL:-86400}" - # Magic links point at the frontend dev server. - APP_URL: "${APP_URL:-http://localhost:5173}" + # The SPA and the API are both served from this container. + APP_URL: "${APP_URL:-http://localhost:8080}" # Deliver to the Mailpit catcher below; read mail at http://localhost:8025. MAIL_TRANSPORT: "${MAIL_TRANSPORT:-smtp}" MAIL_FROM: "${MAIL_FROM:-no-reply@todo.test}" @@ -25,9 +25,9 @@ services: MAIL_SMTP_PASSWORD: "${MAIL_SMTP_PASSWORD:-}" MAIL_SMTP_ENCRYPTION: "${MAIL_SMTP_ENCRYPTION:-none}" volumes: - # Live source: edit on the host, no image rebuild needed. - - .:/var/www/html - # Persistent storage, kept outside /var/www/html to avoid a nested mount. + # Only generated state is mounted. The app itself — PHP source and the + # built frontend — is baked into the image; rebuild to pick up changes: + # docker compose up -d --build - storage:/var/www/storage depends_on: - mailpit @@ -42,25 +42,5 @@ services: - "1025:1025" # SMTP (also reachable in-network as mailpit:1025) 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: storage: - web_node_modules: diff --git a/docker/apache.conf b/docker/apache.conf index 96cee74..6b27e14 100644 --- a/docker/apache.conf +++ b/docker/apache.conf @@ -5,7 +5,7 @@ Options -Indexes +FollowSymLinks AllowOverride All Require all granted - DirectoryIndex index.php + DirectoryIndex index.html index.php ErrorLog ${APACHE_LOG_DIR}/error.log diff --git a/public/.htaccess b/public/.htaccess index c48360a..25182ac 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -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 -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] diff --git a/web/Dockerfile b/web/Dockerfile deleted file mode 100644 index 750b234..0000000 --- a/web/Dockerfile +++ /dev/null @@ -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"] diff --git a/web/vite.config.ts b/web/vite.config.ts index 39a1f25..43ed95e 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,8 +2,8 @@ import vue from '@vitejs/plugin-vue' import { defineConfig } from 'vite' import { VitePWA } from 'vite-plugin-pwa' -// The API the dev server proxies `/api` to. Defaults to the Dockerised API on -// the host; the compose `web` service overrides it to the internal address. +// Host-only dev server: proxy `/api` to the Dockerised API published on the +// host. Override with VITE_PROXY_TARGET if the API runs elsewhere. const proxyTarget = process.env.VITE_PROXY_TARGET ?? 'http://localhost:8080' // https://vite.dev/config/