The Angular 21 builder generates angular-app-engine-manifest.mjs at build time but does NOT inject the import into server.mjs. Without manually importing and registering the manifest via ɵsetAngularAppEngineManifest, AngularNodeAppEngine fails with 'Angular app engine manifest is not set' and every request returns 404. This matches the exact issue reported in PR #14 on staging where nginx starts successfully but returns 'Not Found' for all paths.
KMountain Flower Radio Station
A three-tier web application for a community radio station: Angular 21 frontend, FastAPI backend, PostgreSQL database.
Renders a hero page, about page, broadcast schedule, donation tiers, upcoming events, and a contact form — styled with a warm mountain/nature aesthetic.
Quick Start
Option A — Dev Container (recommended)
Open the repo in VS Code and click Reopen in Container (or run Dev Containers: Reopen in Container from the command palette). The dev container starts both the backend (port 8000) and frontend (port 4200) automatically.
Open http://localhost:4200.
Option B — Manual (frontend + backend separately)
# Backend (uses SQLite for local dev — no database server needed)
cd backend
pip install -r requirements.txt
export KMTN_DATABASE_URL="sqlite+aiosqlite:///./kmountain.db"
uvicorn app.main:app --reload
# Frontend (new terminal)
npm install && ng serve
Open http://localhost:4200 and ensure the backend is running on http://localhost:8000.
Prerequisites
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20+ | For frontend development |
| npm | 11+ | |
| Python | 3.12+ | For backend development |
| PostgreSQL | 16+ | For production only (dev uses SQLite) |
Architecture
┌──────────────┐
│ Browser │
│ :4200 / :80│
└──────┬───────┘
│
┌────────────┴────────────┐
│ │
┌────▼─────┐ ┌──────▼─────┐
│ Nginx │ /api/* │ FastAPI │
│ (static) │─ proxy ──▶ │ :8000 │
└──────────┘ └──────┬─────┘
│
┌──────▼─────┐
│ PostgreSQL │
│ :5432 │
└────────────┘
Three tiers:
- Frontend — Angular 21 SPA. In production, served by Nginx. In development, served by the Angular dev server on port 4200.
- Backend — Python FastAPI with async SQLAlchemy. Provides CRUD REST endpoints for programs (broadcast schedule), events (upcoming events), and donation tiers. Auto-generates Swagger/OpenAPI docs at
/docs. - Database — PostgreSQL 16. Schema contains three tables:
programs,events,donation_tiers. Initial data is loaded via seed.py. Local development uses SQLite for convenience.
Project Structure
.
├── docker-compose.yml # Parameterized base template
├── .devcontainer/ # VS Code dev container
├── docker-entrypoint.sh # Renders nginx config + config.json from env vars
├── nginx.conf.template # Nginx config template (envsubst)
├── config.json.template # Runtime API config template (envsubst)
├── .devcontainer/ # VS Code dev container
│ ├── Dockerfile # Alpine Python 3.12 + Node.js + Angular CLI
│ └── devcontainer.json # Dev container config
│
├── src/ # Angular 21 frontend
│ ├── app/ # Standalone components
│ │ ├── app.routes.ts # Lazy-loaded route map
│ │ ├── app.config.ts # App providers + APP_INITIALIZER (loads config.json)
│ │ ├── hero/ about/ schedule/ donate/ events/ contact/ navbar/ footer/
│ │ ├── services/ # HTTP services (program, event, tier, app-config)
│ │ └── interfaces/ # TypeScript types (Program, Event, Tier)
│ ├── environments/ # dev/prod config (apiBaseUrl)
│ └── styles/ # SCSS design tokens
│ ├── _variables.scss # Colors, spacing, breakpoints
│ ├── _mixins.scss # Card, button, responsive mixins
│ └── _themes.scss # SCSS → CSS custom properties
│
└── backend/ # FastAPI backend
├── Dockerfile # Python 3.12-slim + gunicorn/uvicorn
├── requirements.txt
├── seed.py # Initial data for all models
├── inject_test_logs.py # Test data injector for visitor stats pipeline
├── geo/ # MaxMind GeoLite2 City database
└── app/
├── main.py # FastAPI app, CORS, router registration
├── config.py # ENV-based settings (KMTN_* prefix)
├── database.py # async engine + session factory
├── models.py # SQLAlchemy ORM (Program, Event, DonationTier)
├── schemas.py # Pydantic schemas (Create/Update/Response)
└── api/
├── programs.py # CRUD /api/programs
├── events.py # CRUD /api/events
├── tiers.py # CRUD /api/tiers
└── stats.py # Visitor stats endpoints (summary, timeseries, geo, parse)
Local Development
Dev Container (recommended)
Open the repo in VS Code → Reopen in Container. The dev container starts both services automatically. See .devcontainer/ for the configuration.
Manual Development
Run both layers in parallel — development uses SQLite (no database server needed):
# 1. Backend (new terminal)
cd backend
pip install -r requirements.txt
export KMTN_DATABASE_URL="sqlite+aiosqlite:///./kmountain.db"
export KMTN_CORS_ORIGINS='["http://localhost:4200"]'
uvicorn app.main:app --reload
# 2. Frontend (another terminal)
npm install
ng serve
The database auto-seeds on first startup. To reset and re-seed at any time:
curl -X POST http://localhost:8000/api/admin/reset
Setting up the bootstrap admin user
The backend supports a hardcoded "bootstrap admin" login via two environment variables. When both are set, anyone with those credentials can authenticate at POST /api/auth/login and receives a JWT token with admin privileges.
# Enable bootstrap admin login
export KMTN_ADMIN_USERNAME="admin"
export KMTN_ADMIN_PASSWORD="my-secret-password"
To log in, send a POST request to /api/auth/login:
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "my-secret-password"}'
This returns a JWT access token you can use to access protected endpoints:
curl http://localhost:8000/api/auth/me \
-H "Authorization: Bearer <access_token>"
If either variable is left empty (the default), login is disabled and returns a 503 error. For production, also set KMTN_JWT_SECRET_KEY to a strong random value.
Configuration
| Variable | Default | Where | Description |
|---|---|---|---|
| Variable | Default | Where | Description |
| --- | --- | --- | --- |
KMTN_DATABASE_URL |
postgresql+asyncpg://... (prod) / sqlite+aiosqlite:///./kmountain.db (dev) |
backend/app/config.py | Database connection string |
KMTN_CORS_ORIGINS |
["http://localhost:4200"] |
backend/app/config.py | CORS allowlist (JSON array) |
KMTN_ENV |
development |
backend/app/config.py | Set to production to disable admin endpoints |
KMTN_ADMIN_USERNAME |
(empty) | backend/app/config.py | Bootstrap admin username. Leave empty to disable login. |
KMTN_ADMIN_PASSWORD |
(empty) | backend/app/config.py | Bootstrap admin password. Both username and password must be set to enable login. |
KMTN_JWT_SECRET_KEY |
change-me-in-production |
backend/app/config.py | Secret key used to sign and verify JWT tokens. If leaked, anyone can forge admin tokens. Change in production. |
API_UPSTREAM |
http://api:8000 |
Dockerfile | Internal API target for Nginx proxy (Docker DNS) |
API_PUBLIC_URL |
"" (empty) |
Dockerfile | Browser-facing API URL. Empty = proxy mode; set to direct URL when ingress breaks proxy chain |
Environment variables are prefixed with KMTN_ in the backend. For production, set KMTN_DATABASE_URL to your managed PostgreSQL connection and update KMTN_CORS_ORIGINS to your actual domain.
Building
# Frontend (produces dist/radio-station/browser/)
ng build --configuration production
For production deployment, serve the built static files with Nginx and run the backend behind it.
Testing
This project uses a dual test framework: pytest for the Python backend and Vitest for the Angular frontend.
Backend (pytest)
cd backend
pip install -r requirements-test.txt
python -m pytest tests/ -v
The test suite covers:
- auth — JWT token generation, validation, and bootstrap admin login
- log_parser — IP resolution (X-Forwarded-For), log line parsing, geo lookups, and file parsing
- storage — database operations and session management
- theme_export — theme generation and serialization
- config — environment variable loading and defaults
Run with coverage:
python -m pytest tests/ --cov=app --cov-report=term-missing --cov-fail-under=10
Frontend (Vitest)
npm install --save-dev vitest @vitest/coverage-v8 jsdom
npx vitest run --reporter=verbose
The frontend test suite uses Vitest with jsdom for DOM-level component tests. Config is in vitest.config.ts.
CI
The project uses Gitea Actions (.gitea/workflows/ci.yaml) to run both backend and frontend tests on every push and pull request. The pipeline includes:
backend-test— pytest with coverage enforcement (>= 10%)frontend-test— Vitest unit testsbackend-lint— ruff linting on the backend codequality-gate— final verification stage
The backend API is self-documented at http://localhost:8000/docs (Swagger UI). The database auto-seeds on first startup — visit that URL to interactively test endpoints.
Admin Dashboard — Visitor Stats
A visitor analytics dashboard built on a log parsing pipeline: Nginx writes access logs to a shared volume that the FastAPI backend reads and processes. No extra services required (no Logstash, Prometheus, or message queues).
How it works
- Nginx writes structured JSON access logs to daily log files on a shared mount:
/logs/access-YYYY-MM-DD.log. Amapdirective extracts the date from the ISO timestamp, so Nginx rotates to a new file at midnight each day. - FastAPI (via log_parser.py) scans
/logs/for daily files that haven't been parsed yet, enriches each entry with IP geolocation (MaxMind GeoLite2 City), and stores aggregates in PostgreSQL. - PostgreSQL tables (
visitor_stats_daily,visitor_stats_geo,log_parse_state) hold the aggregated data and a "last parsed date" marker. - Angular admin dashboard reads from the stats API endpoints to render charts and a world map.
Why daily files
The currently-being-written file is never read — no partial lines, no race conditions, no file locking needed. Stats have a minimum one-day lag.
Stats API endpoints
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/stats/summary |
Total unique visitors for a date range |
GET |
/api/stats/timeseries |
Daily unique visitor counts (chart data) |
GET |
/api/stats/geo |
Geographic breakdown by country (map data) |
GET |
/api/stats/parse |
Trigger log parsing (SSE progress stream) |
All endpoints require admin authentication (JWT token). Date range is optional; defaults to the last 30 days.
GeoLite2 setup
The log parser requires a MaxMind GeoLite2 City database for IP geolocation:
- Download from https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
- Place the
.mmdbfile atbackend/geo/GeoLite2-City.mmdb - The Dockerfile copies it into the container at
/app/geo/GeoLite2-City.mmdb
Injecting test data
To verify the pipeline works (e.g., after deploying or before a demo), use the test log injector script at backend/inject_test_logs.py. It writes realistic traffic from 25 well-known public IPs across multiple past days — data is only aggregated a day in arrears, so all dates are strictly before today.
# Run inside the api container (writes to shared /logs volume)
docker compose exec api python /app/inject_test_logs.py /logs 14
# Or run locally (dev mode)
cd backend
python inject_test_logs.py /logs 14
The second argument controls how many days of data to generate (default: 14). Each day gets a deterministic mix of visitors from the IP pool, so re-running produces the same output (idempotent — files are appended, so check first).
After injecting, trigger parsing via the admin dashboard or:
curl -X GET "http://localhost:8000/api/parse?token=YOUR_JWT"
Shared volume setup
For docker-compose, the logs named volume is mounted in both the frontend (Nginx) and api containers at /logs:
volumes:
logs:/logs
For Kubernetes (minikube), use a hostPath PersistentVolume with ReadWriteMany access mode, claimed by a PersistentVolumeClaim and mounted in both pods.
API Reference
The backend exposes CRUD endpoints for three models:
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/programs |
List all programs (?day=1..7 to filter by day of week) |
GET |
/api/programs/:id |
Single program |
POST |
/api/programs |
Create program |
PUT |
/api/programs/:id |
Update program |
DELETE |
/api/programs/:id |
Delete program |
GET |
/api/events |
List events (?active=true to filter active events) |
GET |
/api/events/:id |
Single event |
POST |
/api/events |
Create event |
PUT |
/api/events/:id |
Update event |
DELETE |
/api/events/:id |
Delete event |
GET |
/api/tiers |
List donation tiers (ordered by display_order) |
GET |
/api/tiers/:id |
Single tier |
POST |
/api/tiers |
Create tier |
PUT |
/api/tiers/:id |
Update tier |
DELETE |
/api/tiers/:id |
Delete tier |
Full interactive API docs at http://localhost:8000/docs.
Deployment
To deploy a customer environment, use the deploy-all.sh script to deploy everyone:
./deploy-all.sh
Or deploy a specific customer using their .env file:
docker compose --env-file customers/.env.<customer_name> -p <customer_name> up -d --build
The docker-compose.yml file is a parameterized template.
# Frontend
docker build -t <registry>/kmtnflower:latest .
docker push <registry>/kmtnflower:latest
# Backend
docker build -t <registry>/kmtnflower-api:latest ./backend
docker push <registry>/kmtnflower-api:latest
Deploy
On the target machine, create a Docker Compose file (see docker-compose.test.yml for a reference) that pulls the images and sets the required environment variables:
services:
db:
image: docker.io/library/postgres:16-alpine
environment:
POSTGRES_DB: kmountain
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
api:
image: <registry>/kmtnflower-api:latest
environment:
KMTN_DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/kmountain
KMTN_CORS_ORIGINS: '["http://yourdomain.com"]'
KMTN_ENV: production
KMTN_ADMIN_USERNAME: admin
KMTN_ADMIN_PASSWORD: change-me
KMTN_JWT_SECRET_KEY: change-me-to-a-random-string
depends_on:
db:
condition: service_healthy
frontend:
image: <registry>/kmtnflower:latest
environment:
API_UPSTREAM: http://api:8000
API_PUBLIC_URL: ""
ports:
- "4200:80"
depends_on:
- api
volumes:
pgdata:
Then start:
docker compose up -d
API_PUBLIC_URL
The frontend container renders a config.json at startup from the API_PUBLIC_URL environment variable. This controls how the Angular app reaches the API:
| Mode | Value | When to use |
|---|---|---|
| Proxy (default) | "" (empty) |
Browser calls /api/* relative paths; Nginx forwards to API_UPSTREAM. Use when no external ingress interferes. |
| Direct | "http://yourhost:8000" |
Browser calls the API container directly. Use when an external ingress layer (e.g., TrueNAS middleware, Kubernetes ingress) breaks the Nginx proxy chain. Requires CORS to be configured on the backend. |
Example: TrueNAS Scale
For a TrueNAS Scale deployment where the built-in ingress layer may redirect traffic:
services:
db:
image: docker.io/library/postgres:16-alpine
environment:
POSTGRES_DB: kmountain
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
api:
image: truenas.local:35000/kmtnflower-api:latest
environment:
KMTN_DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/kmountain
KMTN_CORS_ORIGINS: '["http://truenas.local:34200", "http://truenas.local"]'
KMTN_ENV: production
KMTN_ADMIN_USERNAME: admin
KMTN_ADMIN_PASSWORD: change-me
KMTN_JWT_SECRET_KEY: change-me-to-a-random-string
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
frontend:
image: truenas.local:35000/kmtnflower:latest
environment:
API_UPSTREAM: http://api:8000
API_PUBLIC_URL: "http://truenas.local:8000"
ports:
- "34200:80"
depends_on:
- api
volumes:
pgdata:
Note: The database auto-seeds with initial data on first startup. To re-seed at any time (dev only):
curl -X POST http://localhost:8000/api/admin/reset.
Bare-metal production
For production, you'll need to set up:
- A PostgreSQL database (managed or self-hosted)
- The FastAPI backend (gunicorn/uvicorn)
- Nginx to serve the Angular build and proxy
/api/*to the backend
# 1. Build the frontend
ng build --configuration production
# 2. Configure the backend
export KMTN_DATABASE_URL="postgresql+asyncpg://user:pass@host:5432/kmountain"
export KMTN_CORS_ORIGINS='["https://yourdomain.com"]'
export KMTN_ENV="production"
export KMTN_ADMIN_USERNAME="admin"
export KMTN_ADMIN_PASSWORD="change-me"
export KMTN_JWT_SECRET_KEY="change-me-to-a-random-string"
# 3. Start the backend
cd backend
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000
# 4. Serve the frontend with Nginx (or your preferred web server),
# pointing /api/* requests to http://localhost:8000
The database auto-seeds on first startup.
Production hardening checklist
- Use a managed PostgreSQL database (RDS, Cloud SQL, etc.)
- Update
KMTN_CORS_ORIGINSto your actual domain (notlocalhost) - Set
KMTN_ENV="production"to disable admin endpoints - Set
KMTN_ADMIN_USERNAMEandKMTN_ADMIN_PASSWORDto strong values - Set
KMTN_JWT_SECRET_KEYto a strong random value - Add a reverse proxy (Caddy, Traefik) in front of Nginx for HTTPS
- Set up log rotation
- Configure a process manager (systemd, PM2) for the backend
Development Tools
- Dev container — Open the repo in VS Code → "Reopen in Container" for a pre-configured environment with Angular CLI, Node.js, Python 3, and Git (.devcontainer/).
- VS Code workspace — workspace file in the repo root configures both Angular and Python extensions.
- Prettier — Run
npx prettier --write .to format the codebase. - Prettier config — .prettierrc (100 char width, single quotes, Angular HTML parser).
Tech Stack
| Layer | Technology |
|---|---|
| Frontend | Angular 21 (standalone components, signals-ready) |
| Backend | FastAPI 0.115, Python 3.12 |
| Database | PostgreSQL 16 (async via SQLAlchemy 2.0) |
| Styling | SCSS with design tokens (variables → mixins → CSS custom properties) |
| Routing | Angular Router (lazy loaded) + Nginx SPA fallback |
| HTTP | RxJS observables (frontend → FastAPI) |
| Containers | Dev container (VS Code Remote - Containers) |
| CI/CD | (not yet configured) |
| Testing | Vitest (via Angular CLI) |