---
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.