Support subpath
All checks were successful
CI / Backend tests (push) Successful in 30s
CI / Frontend lint (push) Successful in 33s
CI / Frontend build (push) Successful in 25s
CI / Frontend unit tests (push) Successful in 1m6s
CI / Playwright e2e (push) Successful in 1m43s
Docker / build-and-push (push) Successful in 2m30s

This commit is contained in:
Andras Schmelczer 2026-05-31 20:27:41 +01:00
parent 006ae81c3b
commit a48aad974a
6 changed files with 50 additions and 9 deletions

View file

@ -69,6 +69,11 @@ jobs:
with:
context: .
push: true
# The published image is deployed at https://schmelczer.dev/towers/,
# so the SPA is built with that sub-path baked into <base href> and
# the service-worker manifest. Change this if the deploy path changes.
build-args: |
BASE_HREF=/towers/
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ steps.registry.outputs.image }}:buildcache

View file

@ -58,9 +58,28 @@ docker-compose.dev.yml # Ephemeral volume for Playwright runs
- **Frontend**: Angular 21+, **standalone components** (no NgModule), `signal()` / `computed()` / `effect()` / `linkedSignal()`, **zoneless change detection** (`provideZonelessChangeDetection`), `@if`/`@for` control flow, **OnPush** everywhere, esbuild builder (`@angular/build:application`), Reactive Forms, Angular Service Worker (PWA), Angular CDK (drag-drop, A11yModule for focus trap)
- **Backend**: FastAPI, pydantic v2, slowapi (rate limiting), structlog, **sqlite3 with WAL + foreign_keys ON** every connection, uv-managed deps
- **Runtime topology**: single Docker container — FastAPI process serves both `/api/v1/*` JSON endpoints AND the built Angular SPA as static files with SPA fallback. Behind nginx; uvicorn launched with `--proxy-headers --forwarded-allow-ips=*`
- **Runtime topology**: single Docker container — FastAPI process serves both `/api/v1/*` JSON endpoints AND the built Angular SPA as static files with SPA fallback. Behind nginx; uvicorn launched with `--proxy-headers --forwarded-allow-ips=*`. **Deployed under a sub-path** (`https://schmelczer.dev/towers/`) — see "Sub-path deployment" below
- **Storage**: SQLite at `/data/life-towers.db` on a named Docker volume. Tree-replace semantics (PUT replaces user's full tree atomically inside `BEGIN IMMEDIATE`)
## Sub-path deployment
The app is deployed under a path: `https://schmelczer.dev/towers/`. The mechanism, and the one rule that keeps it working:
- **SPA**: built with `ng build --base-href /towers/` (wired via the `BASE_HREF` Docker build arg; default `/`, set to `/towers/` in `.forgejo/workflows/docker.yml`). This stamps `<base href="/towers/">` into `index.html` and prefixes every URL in the service-worker manifest (`ngsw.json`). Asset and script `src`s stay relative, so they resolve against `<base>`.
- **API calls are relative** (`api/v1/...`, no leading slash — see `api.service.ts`) so they resolve against `<base>`: `/towers/api/v1/...` in prod, `/api/v1/...` at the root for dev/e2e. **Never reintroduce a leading slash** — it pins calls to the origin root and breaks the sub-path deploy.
- **Backend is path-agnostic** — it still serves the API at `/api/v1/*` and the SPA at the container root. **nginx strips the prefix** before proxying, so the backend never sees `/towers`. Because of that strip, the backend can't infer its public URL, so `LIFE_TOWERS_PUBLIC_URL` (set in `docker-compose.yml`) is what makes the server-rendered canonical / OG / Twitter tags correct (`main.py:_absolute_meta_urls`).
- **Required nginx** (the trailing slash on `proxy_pass` is what strips `/towers/`):
```nginx
location = /towers { return 308 /towers/; } # add the trailing slash
location /towers/ {
proxy_pass http://127.0.0.1:8000/; # trailing slash strips the prefix
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
- **Dev/e2e stay at the root**: `docker-compose.dev.yml` builds with the default `BASE_HREF=/`, and Playwright hits `http://life-towers:8000/`. The relative API paths work identically there, so e2e exercises the same code.
## Build / dev / test / deploy
```bash

View file

@ -1,10 +1,16 @@
# Stage 1: SPA build
FROM node:22-alpine AS spa-build
WORKDIR /build
# Sub-path the SPA is served under, e.g. "/towers/" for https://schmelczer.dev/towers/.
# Defaults to "/" so local/dev/e2e builds (served at the container root) work
# unchanged. The production image (see .forgejo/workflows/docker.yml) overrides
# this. The trailing slash matters — it becomes <base href> and the service
# worker (ngsw.json) URL prefix.
ARG BASE_HREF=/
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
RUN npm run build -- --base-href="$BASE_HREF"
# Angular's application builder outputs to dist/frontend/browser/
# Stage 2: runtime

View file

@ -5,7 +5,13 @@ services:
# or set it to e.g. `life-towers:local` and uncomment `build: .` for
# local builds.
image: ${LIFE_TOWERS_IMAGE:-life-towers:local}
# build: . # uncomment for local builds (or use docker-compose.dev.yml)
# For a local production build, uncomment the block below. BASE_HREF must
# match the sub-path the app is served under (the published image is built
# with /towers/ by .forgejo/workflows/docker.yml).
# build:
# context: .
# args:
# BASE_HREF: /towers/
pull_policy: ${LIFE_TOWERS_PULL_POLICY:-missing}
ports:
- "${LIFE_TOWERS_PORT:-8000}:8000"
@ -17,6 +23,11 @@ services:
- life-towers-data:/data
environment:
LIFE_TOWERS_ALLOWED_ORIGIN: "${LIFE_TOWERS_ALLOWED_ORIGIN:-}"
# Absolute public URL of the deployment, used to render correct canonical
# / Open Graph / Twitter tags. Required when served under a sub-path,
# because nginx strips the prefix before proxying so the backend can't
# otherwise infer it. Override in .env for a different host/path.
LIFE_TOWERS_PUBLIC_URL: "${LIFE_TOWERS_PUBLIC_URL:-https://schmelczer.dev/towers/}"
restart: unless-stopped
logging:
driver: json-file

View file

@ -8,24 +8,24 @@ export class ApiService {
private readonly http = inject(HttpClient);
health(): Promise<{ status: string }> {
return firstValueFrom(this.http.get<{ status: string }>('/api/v1/health'));
return firstValueFrom(this.http.get<{ status: string }>('api/v1/health'));
}
register(token: string): Promise<{ user_id: string }> {
return firstValueFrom(
this.http.post<{ user_id: string }>('/api/v1/register', { token }),
this.http.post<{ user_id: string }>('api/v1/register', { token }),
);
}
getData(token: string): Promise<TreeDto> {
return firstValueFrom(
this.http.get<TreeDto>('/api/v1/data', { headers: this.authHeaders(token) }),
this.http.get<TreeDto>('api/v1/data', { headers: this.authHeaders(token) }),
);
}
async putData(token: string, tree: TreeDto): Promise<void> {
await firstValueFrom(
this.http.put('/api/v1/data', tree, { headers: this.authHeaders(token) }),
this.http.put('api/v1/data', tree, { headers: this.authHeaders(token) }),
);
}

View file

@ -24,7 +24,7 @@ describe('ApiService', () => {
it('gets data with a bearer token', async () => {
const tree: TreeDto = { pages: [] };
const promise = service.getData('token-1');
const req = http.expectOne('/api/v1/data');
const req = http.expectOne('api/v1/data');
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('Authorization')).toBe('Bearer token-1');
req.flush(tree);
@ -34,7 +34,7 @@ describe('ApiService', () => {
it('puts data with a bearer token', async () => {
const tree: TreeDto = { pages: [] };
const promise = service.putData('token-1', tree);
const req = http.expectOne('/api/v1/data');
const req = http.expectOne('api/v1/data');
expect(req.request.method).toBe('PUT');
expect(req.request.headers.get('Authorization')).toBe('Bearer token-1');
expect(req.request.body).toBe(tree);