--- title: 'Project scaffold, configuration and container' type: 'feature' ticket: '1' created: '2026-10-15' status: 'built' baseline_revision: '24de5878ed7f8d297e10aa3cf1e4f772fa5b0fa0' route: 'full' route_source: 'auto' risk: 'medium' review: 'quick' review_source: 'pinned' lenses_ran: ['quick'] review_loop_iteration: 0 context: - '{project-root}/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md' --- ## Intent **Problem:** The repository holds only planning documents. Every later story needs a runnable, secure and configurable Django container to build on. **Approach:** Create the Django 5.2 LTS project with environment-variable configuration that fails fast, a database-only health check, JSON logs with a request id, a strict security-header layer, a multi-stage Docker image with compose file, and a test setup. ## Boundaries & Constraints **Always:** All configuration comes from environment variables, validated at startup; one error message lists every invalid or missing variable and the process exits non-zero. Nothing in the repository names ping-mee, a domain, an OU, a group, an IP or a path. Secrets never appear in logs. One gunicorn worker with threads. The container runs as a non-root user; only `/data` and `/backups` are writable. `/healthz` checks the database only. Python 3.13, Django 5.2 LTS. Spine rules AD-13 and AD-18 apply. Decision: the image is built and run on the Linux host from a checkout of this repository (Docker is not installed on the development machine); no registry or pipeline in this story. **Never:** No AD, OIDC, mail, share or feature-app code (later entries). No python-ldap dependency yet. No inline script or external asset anywhere. No Django admin. No commit of secrets, and no real `.env`. ## I/O & Edge-Case Matrix | Scenario | Input / State | Expected Output / Behavior | Error Handling | |----------|--------------|---------------------------|----------------| | Valid start | All required variables valid | Migrations run, server answers | No error expected | | Invalid config | A required variable missing or malformed | Process exits non-zero, message names every bad variable | No traceback, no secret value printed | | Health ok | GET /healthz, database reachable | 200 `{"status":"ok"}` | No error expected | | Health down | Database unreachable | 503 `{"status":"unavailable"}` | Logged without secrets | | Headers | Any response | CSP, nosniff, frame deny, referrer policy, `X-Request-ID` present | No error expected | | Request log | Any request | One JSON log line with request id, method, path, status | Query strings and bodies not logged | ## Code Map Greenfield: only planning documents exist. New files, using the spine's source tree. - `pyproject.toml`, `uv.lock`, `.python-version` -- dependencies (Django, gunicorn, whitenoise; dev: pytest, pytest-django, ruff) - `manage.py`, `portal/config/` -- `settings.py`, `env.py` (typed parsing, collects all errors), `urls.py`, `wsgi.py`, `logging.py` (JSON formatter), `middleware.py` (request id, security headers), `health.py` - `portal/adapters/__init__.py` -- empty package for later adapters and fakes - `portal/tests/` -- `conftest.py`, config, health, header and logging tests - `Dockerfile`, `compose.yaml`, `.dockerignore`, `docker/entrypoint.sh` -- multi-stage image, non-root, migrate then gunicorn - `.env.example`, `README.md` -- documented variables and run steps - Reuse: spine AD-13, AD-18. Do not change: anything under `_bmad/`, `_bmad-output/`, `.agents/`, `context-resources/`. ## Tasks & Acceptance **Execution:** - [ ] `pyproject.toml`, `.python-version` -- project metadata and pinned dependencies -- repeatable installs - [ ] `portal/config/env.py` -- typed variables (`SECRET_KEY`, `ALLOWED_HOSTS`, `DATA_DIR`, `DEBUG`, `TRUSTED_PROXIES`, `LOG_LEVEL`, `DEFAULT_TIMEZONE`) with defaults only for safe values -- fail fast - [ ] `portal/config/settings.py`, `urls.py`, `wsgi.py` -- SQLite in WAL mode under `DATA_DIR`, secure cookies, CSRF, whitenoise, UTC storage, translation on -- spine baseline - [ ] `portal/config/middleware.py`, `logging.py`, `health.py` -- request id, headers, JSON logs, `/healthz` - [ ] `Dockerfile`, `docker/entrypoint.sh`, `compose.yaml`, `.dockerignore` -- image and run definition - [ ] `portal/tests/` -- unit-test the matrix rows - [ ] `.env.example`, `README.md` -- document every variable and the run steps **Acceptance Criteria:** - Given valid variables, when the app starts, then migrations apply and `/healthz` returns 200. - Given a missing `SECRET_KEY` and a malformed `LOG_LEVEL`, when the app starts, then it exits non-zero naming both. - Given the database file is unreadable, when `/healthz` is called, then it returns 503. - Given any request, when the response is sent, then the CSP forbids inline scripts and external sources and the log line has the request id. - Given the repository, when searched, then it contains no ping-mee name, domain, IP or path. ## Implementation Notes - Implemented by a coding subagent from this plan; the diff was read and `uv run pytest` (12 passed, none skipped) and `uv run ruff check .` were re-run by the orchestrator. - Beyond the plan: `docker/healthcheck.py` (the container probe needs a Host header that matches `ALLOWED_HOSTS`, otherwise it gets a 400); `.gitattributes` (keeps LF in `.sh` and `Dockerfile` on Windows checkouts); extra headers (Permissions-Policy, COOP); HSTS of one year when `DEBUG=false`, which needs the proxy to send `X-Forwarded-Proto`. - `README.md` was rewritten and no longer carries the original project name line. - Ruff excludes `_bmad`, `_bmad-output`, `.agents` and `context-resources`. - The 503 criterion is tested with a mocked unreachable database, not an unreadable file. - Not verified: the Docker build, the read-only root file system with gunicorn's worker temp dir, the `uv` image tag and the container health check. They need `docker compose up --build` on the Linux host. - `compose.yaml` publishes port 8000 on all host interfaces. Bind it to a specific address if the reverse proxy runs on the same host. - Resolved versions in `uv.lock`: Django 5.2.18, gunicorn 26.2.0, whitenoise 6.12.0. ## Plan Change Log ## Review Triage Log Pass 1 (quick lens). Counts: high 1, medium 3, low 3, false 1, maybe-false 0. | # | Finding | Verdict | Route | Evidence | |---|---------|---------|-------|----------| | 1 | `docker/entrypoint.sh` not executable | high | patch | Added as mode 100644 and `COPY docker ./docker` has no chmod, so `ENTRYPOINT` on the Linux host fails with permission denied; "Valid start" would not hold. | | 2 | Security headers missing on responses from outer middleware | medium | patch | `SecurityHeadersMiddleware` is innermost; a bad Host 400 from `CommonMiddleware`, whitenoise static files and `SecurityMiddleware` redirects never pass through it. The AC says any request. | | 3 | Query string and `request` repr in Django's own error log lines, and a second line per 4xx | high | patch | Reproduced: a 404 for `/missing?token=supersecret` writes a `django.request` line containing ``. Breaks "query strings not logged" and "one line per request". | | 4 | `TRUSTED_PROXIES` parsed but unused; `SECURE_PROXY_SSL_HEADER` trusts any client | medium | patch | Nothing reads it until the sign-in entry (client IP rule); docs say it restricts proxies. The fix here is wording; wiring is entry 5. The proxy-header trust has no demonstrated harm yet (low, rejected). | | 5 | `/tmp` tmpfs is a third writable location | low | rejected | Ephemeral and needed for gunicorn workers; the rule's intent (persistent writable paths) holds; changing it adds complexity. | | 6 | Gunicorn error log is plain text on stderr | low | rejected | Server start and error lines only; app logs are JSON. A JSON gunicorn log config is more than a direct correction. | | 7 | "No ping-mee name" not satisfiable because of the plan file | false | rejected | The criterion covers code and image; `.dockerignore` excludes `_bmad-output` and a search outside protected folders found nothing. | | 8 | 503 criterion tested with a mock, not an unreadable file | low | rejected | Disclosed in Implementation Notes; the view returns 503 on any exception from `cursor()`, so a real failure cannot return 200. A real-file test needs database wrapper plumbing. | ## Design Notes CSP is a constant in `middleware.py` (`default-src 'self'`, `script-src 'self'`, `frame-ancestors 'none'`) so story 6 can adjust the style rules for the Fluent components without weakening script rules. Dependencies were checked on the web: Django 5.2 supports Python 3.10 to 3.14 and is LTS to April 2028; gunicorn 26.x and whitenoise 6.12 support it. Python 3.13 is pinned because this machine's default 3.15 is not supported by Django 5.2. ## Verification **Commands:** - `uv run pytest` -- expected: all tests pass - `uv run ruff check .` -- expected: no findings - `uv run python manage.py check --deploy` with a valid test environment -- expected: no errors - `uv run python manage.py check` with missing `SECRET_KEY` -- expected: exit non-zero listing the variable **Manual checks (if no CLI):** - Docker is not installed on this machine. On the Linux host: `docker compose up --build`, then `curl http://localhost:8000/healthz` returns `{"status":"ok"}`, and starting with an invalid variable shows the error and exits.