diff --git a/.env.example b/.env.example
index 0dc9d14..cb4f6e9 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,7 @@ POSTGRES_PASSWORD=change-me-for-local-development
# Future Django application configuration. Supply real production values through Cloud Run
# secrets and configuration, never through a tracked file.
+APP_PORT=8000
DJANGO_SECRET_KEY=replace-with-a-local-development-only-value
DJANGO_DEBUG=true
ALLOWED_HOSTS=localhost,127.0.0.1
diff --git a/Dockerfile b/Dockerfile
index 8b7daa2..48a627b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,6 +13,7 @@ FROM python:3.14.7-slim-bookworm AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
+ PYTHONPATH=/app/src \
PATH=/usr/local/bin:$PATH
WORKDIR /app
@@ -24,5 +25,7 @@ USER app
EXPOSE 8000
-# No CMD or HEALTHCHECK is intentionally defined. Those require the future Django
-# project's application-owned ASGI/WSGI entrypoint and health route.
+HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+ CMD python -c "import os; from urllib.request import urlopen; urlopen(f'http://127.0.0.1:{os.getenv(\"PORT\", \"8000\")}/healthz', timeout=2).read()"
+
+CMD ["sh", "-c", "exec gunicorn --bind 0.0.0.0:${PORT:-8000} skillstreak.wsgi:application"]
diff --git a/README.md b/README.md
index b5f459a..4569a27 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,9 @@
SkillStreak is planned as a public, multi-account Django web application with
PostgreSQL. This repository currently contains its development, quality, Docker,
-and delivery foundation only; no Django project, application routes, database
-schema, product code, or production deployment exists yet.
+delivery foundation, plus a minimal Django source framework and liveness route.
+No database schema, product apps, product routes, or production deployment
+exists yet.
## Repository map
@@ -11,11 +12,12 @@ schema, product code, or production deployment exists yet.
|---|---|---|
| `infrastructure_plan.md` | Approved infrastructure decisions | Current source of truth |
| `requirements.in`, `requirements.txt`, `pyproject.toml` | Python dependencies, resolved lockfile, and tool configuration | Ready |
-| `compose.yml`, `Dockerfile`, `.dockerignore` | Local PostgreSQL and a hardened future application-image base | Ready; no Django entrypoint yet |
+| `compose.yml`, `Dockerfile`, `.dockerignore` | Local Django/PostgreSQL stack and hardened runtime image | Ready |
| `scripts/` | Infrastructure validation and disposable Docker smoke tests | Ready |
-| `tests/infrastructure/` | Infrastructure-harness tests | Ready |
+| `tests/application/`, `tests/infrastructure/` | Django framework and infrastructure tests | Ready |
+| `docs/specs/`, `docs/plans/` | Reviewed specifications and implementation plans | Bootstrap work documented |
| `.github/workflows/` | Pull-request checks and guarded Cloud Run release workflow | Ready |
-| `src/`, Django project, API, frontend | Future application implementation | Not created yet |
+| `src/`, `manage.py` | Django project framework and liveness endpoint | Ready; product apps not created |
| `.agents/skills/` | Repository-specific agent guidance | Available |
## Getting Started
@@ -35,7 +37,30 @@ schema, product code, or production deployment exists yet.
docker run --rm --volume "$PWD:/workspace" --workdir /workspace python:3.14.7-slim-bookworm \
sh -c 'python -m pip install "pip-tools>=7.5,<8" && python -m piptools compile --strip-extras --output-file requirements.txt requirements.in'
```
-4. Validate the static infrastructure configuration:
+4. Start the local Django and PostgreSQL services:
+
+ ```sh
+ docker compose up --build
+ ```
+
+ The application listens on `http://localhost:8000` by default. Set
+ `APP_PORT` in `.env` to choose another host port. Verify its liveness endpoint
+ from another terminal:
+
+ ```sh
+ curl --fail http://localhost:8000/healthz
+ ```
+
+ Run Django commands inside the Docker-only development service:
+
+ ```sh
+ docker compose exec web python manage.py check
+ docker compose exec web pytest
+ docker compose exec web ruff format --check .
+ docker compose exec web ruff check .
+ ```
+
+5. Validate the static infrastructure configuration:
```sh
./scripts/check-infrastructure.sh
@@ -43,34 +68,40 @@ schema, product code, or production deployment exists yet.
./scripts/smoke-postgres.sh
```
- The PostgreSQL smoke test removes its disposable Compose volume when it finishes.
-5. To keep the local PostgreSQL service running for future Django work, run
- `docker compose up -d db`; stop it with `docker compose down`. Remove local
- database data deliberately with `docker compose down --volumes`.
+ The PostgreSQL smoke test stops the Compose stack and removes its named
+ PostgreSQL volume when it finishes. Run it only when local database data is
+ disposable.
+6. Stop the local stack with `docker compose down`. Remove local database data
+ deliberately with `docker compose down --volumes`.
-The Docker image intentionally has no application command or health endpoint
-until the application phase creates a Django ASGI/WSGI entrypoint and `/healthz`.
-Similarly, full Django checks, coverage enforcement, migrations, and Playwright
-workflows activate after `manage.py` and application tests exist.
+The production image runs Gunicorn and reports a liveness-only `/healthz`
+endpoint; it does not query PostgreSQL. Cloud Run must supply
+`DJANGO_SETTINGS_MODULE=skillstreak.settings.production`, `DATABASE_URL`,
+`DJANGO_SECRET_KEY`, and `ALLOWED_HOSTS` through managed configuration and
+secrets. Full Django checks and coverage enforcement now run in pull requests;
+migrations and browser workflows remain future product work.
## Quality and CI
`requirements.txt` is the committed pip-tools lockfile. Pull requests verify the
lockfile, Ruff formatting and linting, the infrastructure harness, Gitleaks,
-CodeQL, and an image build with a critical-vulnerability scan. Django checks,
-coverage (80% branch and line threshold), and browser tests are intentionally
-conditional on the future Django application bootstrap.
+CodeQL, and an image build with a critical-vulnerability scan. Django system
+checks and the 80% branch-and-line coverage gate now run in pull requests.
+Browser tests remain future product work.
Release tags (`v*`) target Google Cloud Run through Artifact Registry. Before a
release can run, create the Google Cloud project resources and GitHub production
environment listed in `infrastructure_plan.md`: workload identity provider,
service account, project/region/repository/service/migration-job variables, and
-Cloud Run-managed application secrets. The release workflow fails before cloud
-authentication while `manage.py` is absent.
+Cloud Run-managed application secrets. The release workflow now runs Django
+checks and tests before cloud authentication; it cannot deploy until the named
+Google Cloud and GitHub production configuration exists.
## Troubleshooting
- **Docker connection refused or permission denied:** start Docker Desktop, then rerun the command.
-- **Port conflicts:** this foundation does not publish PostgreSQL to the host. A future application port will be configurable when its entrypoint exists.
+- **Port conflicts:** set `APP_PORT` in `.env` to choose a different web port.
+ PostgreSQL is not published to the host.
- **Lockfile differs in CI:** regenerate it with the exact container command above and commit both dependency files.
-- **Cloud Run release is blocked:** create the Django project first, then configure Google Cloud Workload Identity Federation and the named GitHub production variables/secrets.
+- **Cloud Run release is blocked:** configure Google Cloud Workload Identity
+ Federation and the named GitHub production variables/secrets.
diff --git a/compose.yml b/compose.yml
index 2295792..1205b44 100644
--- a/compose.yml
+++ b/compose.yml
@@ -1,6 +1,24 @@
name: skillstreak
services:
+ web:
+ build: .
+ command: python manage.py runserver 0.0.0.0:8000
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
+ DATABASE_URL: ${DATABASE_URL:-postgresql://skillstreak:change-me-for-local-development@db:5432/skillstreak}
+ DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
+ DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-replace-with-a-local-development-only-value}
+ DJANGO_SETTINGS_MODULE: skillstreak.settings.local
+ networks: [application]
+ ports:
+ - "${APP_PORT:-8000}:8000"
+ volumes:
+ - .:/app
+
db:
image: postgres:17.7-bookworm
environment:
@@ -18,8 +36,7 @@ services:
- postgres-data:/var/lib/postgresql/data
networks:
- application:
- internal: true
+ application: {}
volumes:
postgres-data:
diff --git a/docs/plans/django-source-framework-implementation.md b/docs/plans/django-source-framework-implementation.md
new file mode 100644
index 0000000..4d77dec
--- /dev/null
+++ b/docs/plans/django-source-framework-implementation.md
@@ -0,0 +1,246 @@
+# Implementation Plan: SkillStreak Django Source Framework
+
+## Status
+
+Implemented — derived from the accepted
+[`skillstreak-django-source-framework` specification](../specs/skillstreak-django-source-framework.md).
+
+## Completion Record
+
+The framework was delivered in the following pushed commits:
+
+- `8f6dd10` — Django package, local settings, and liveness endpoint.
+- `ad013e8` — secure production settings and their tests.
+- `4db8b34` — Gunicorn runtime command, health check, and lockfile.
+- `8d91b2f` — Docker Compose web workflow, documentation, and external-port
+ regression coverage.
+
+Final verification passed with six tests and 96.91% coverage, Ruff formatting
+and lint checks, Django system checks, a host request to `/healthz`, and all
+three required infrastructure smoke commands.
+
+## Overview
+
+Bootstrap the Docker-first Django project under `src/skillstreak`, with local
+and production settings, a liveness-only `/healthz` endpoint, and a Cloud
+Run-suitable Gunicorn image command. No product app, data model, migration,
+authentication flow, or readiness endpoint is included.
+
+## Architecture Decisions
+
+- Use Django's conventional project package contents (`settings`, `urls`,
+ `asgi`, and `wsgi`) under `src/skillstreak`; a project package may contain
+ resources that are not tied to an application. Source:
+ .
+- Keep `base`, `local`, and `production` settings separate. Production settings
+ will be selected explicitly through `DJANGO_SETTINGS_MODULE` and verified
+ with `check --deploy`, which Django recommends against the production
+ settings file. Source:
+ .
+- Make `/healthz` liveness-only and use it for the container health check. It
+ must not query PostgreSQL.
+- Add Gunicorn as the production WSGI server. Django's development `runserver`
+ is not intended for production. Source:
+ .
+- Make Compose wait for the existing PostgreSQL health check before starting
+ the development web service, using `depends_on.condition: service_healthy`.
+ Source: .
+
+## Dependency Graph
+
+```text
+pytest configuration + failing health test
+ |
+ v
+project package + local settings
+ |
+ v
+URL route + health view + ASGI/WSGI
+ |
+ +-------------------+
+ v v
+production settings Docker/Gunicorn image
+ | |
+ +---------+---------+
+ v
+ Compose web service
+```
+
+## Task List
+
+### Task 1: Establish the failing health-check contract
+
+**Description:** Configure pytest-django to load local settings from `src` and
+add a test asserting that `/healthz` returns HTTP 200 with a minimal response.
+Run it before project code exists to demonstrate the red state.
+
+**Acceptance criteria:**
+
+- [x] Pytest discovers Django tests from `src` using local settings.
+- [x] The health-check test fails because the project implementation is absent.
+
+**Verification:**
+
+- [x] Build the current runtime image with
+ `docker build --target runtime --tag skillstreak-framework-test .`, then run
+ `docker run --rm --volume "$PWD:/app" --workdir /app skillstreak-framework-test pytest tests/application/test_health.py`.
+ It fails for the expected missing-project behavior. Compose cannot run this
+ test yet because the `web` service is created in Task 6.
+
+**Dependencies:** None.
+
+**Files likely touched:** `pyproject.toml`, `tests/application/test_health.py`.
+
+### Task 2: Add the minimal project and local-settings foundation
+
+**Description:** Create `manage.py`, the `skillstreak` package, and `base` /
+`local` settings. Local settings use environment-based PostgreSQL configuration
+and development-safe defaults; no migrations or domain apps are created.
+
+**Acceptance criteria:**
+
+- [x] `manage.py check` loads `skillstreak.settings.local`.
+- [x] The local database configuration targets the Compose `db` host.
+- [x] Configuration does not contain a real secret.
+
+**Verification:**
+
+- [x] `docker run --rm --volume "$PWD:/app" --workdir /app skillstreak-framework-test python manage.py diffsettings`
+ loads the local settings successfully. Full `manage.py check` follows when
+ the URL configuration exists in Task 3.
+- [x] Ruff formatting and linting pass for the new files.
+
+**Dependencies:** Task 1.
+
+**Files likely touched:** `manage.py`, `src/skillstreak/__init__.py`,
+`src/skillstreak/settings/__init__.py`, `src/skillstreak/settings/base.py`,
+`src/skillstreak/settings/local.py`.
+
+### Task 3: Implement the liveness route and application entrypoints
+
+**Description:** Add the URL configuration, health view, ASGI, and WSGI
+entrypoints. Make the Task 1 test green with the smallest HTTP response that
+does not disclose configuration or query the database.
+
+**Acceptance criteria:**
+
+- [x] `GET /healthz` returns HTTP 200.
+- [x] The response does not require a database connection.
+- [x] Both ASGI and WSGI point to local settings by default.
+
+**Verification:**
+
+- [x] `docker run --rm --volume "$PWD:/app" --workdir /app skillstreak-framework-test pytest tests/application/test_health.py`
+ passes.
+- [x] `docker run --rm --volume "$PWD:/app" --workdir /app skillstreak-framework-test ruff format --check .`
+ and `docker run --rm --volume "$PWD:/app" --workdir /app skillstreak-framework-test ruff check .`
+ pass.
+
+**Dependencies:** Task 2.
+
+**Files likely touched:** `src/skillstreak/health.py`,
+`src/skillstreak/urls.py`, `src/skillstreak/asgi.py`,
+`src/skillstreak/wsgi.py`.
+
+### Checkpoint: Application foundation
+
+- [x] Health test passes after first failing.
+- [x] `python manage.py check` passes in the web container.
+- [x] Working changes are committed as an atomic application-foundation slice.
+
+### Task 4: Add and test production settings
+
+**Description:** Add `production.py` and focused tests for required
+configuration. Require a production secret key, allowed hosts, and database
+URL; configure HTTPS, proxy, secure-cookie, and HSTS protections appropriate
+to a TLS-terminating Cloud Run deployment.
+
+**Acceptance criteria:**
+
+- [x] Production settings fail clearly when required values are absent.
+- [x] With required values supplied, production settings load and
+ `DEBUG=False`.
+- [x] `check --deploy` runs against production settings in a controlled test
+ environment.
+
+**Verification:**
+
+- [x] `docker run --rm --volume "$PWD:/app" --workdir /app skillstreak-framework-test pytest tests/application/test_production_settings.py`
+ passes.
+- [x] A `docker run` command with
+ `DJANGO_SETTINGS_MODULE=skillstreak.settings.production` executes
+ `python manage.py check --deploy` using non-secret test values.
+
+**Dependencies:** Task 2.
+
+**Files likely touched:** `src/skillstreak/settings/production.py`,
+`tests/application/test_production_settings.py`.
+
+### Task 5: Make the image production-runnable
+
+**Description:** Add the approved Gunicorn dependency, regenerate the
+pip-tools lockfile, and give the runtime image a non-root Gunicorn command and
+liveness health check.
+
+**Acceptance criteria:**
+
+- [x] `requirements.in` and `requirements.txt` include Gunicorn.
+- [x] The image starts Gunicorn on the Cloud Run `PORT` (falling back to 8000).
+- [x] Docker's health check calls `/healthz` without requiring `curl`.
+
+**Verification:**
+
+- [x] Lockfile regeneration matches CI's pip-tools validation.
+- [x] `./scripts/smoke-image.sh` builds the image and verifies its liveness
+ endpoint.
+
+**Dependencies:** Tasks 3 and 4.
+
+**Files likely touched:** `requirements.in`, `requirements.txt`, `Dockerfile`,
+`scripts/smoke-image.sh`.
+
+### Task 6: Add the Docker Compose web workflow and update guidance
+
+**Description:** Add the `web` Compose service with a source bind mount,
+development command, configurable application port, environment forwarding,
+and a database-health dependency. Update templates and onboarding commands.
+
+**Acceptance criteria:**
+
+- [x] `docker compose up --build` starts database then web service.
+- [x] `curl --fail http://localhost:${APP_PORT:-8000}/healthz` succeeds.
+- [x] The README and environment template describe the Docker-only workflow.
+
+**Verification:**
+
+- [x] `docker compose config --quiet` passes.
+- [x] `./scripts/check-infrastructure.sh`, `./scripts/smoke-image.sh`, and
+ `./scripts/smoke-postgres.sh` pass.
+
+**Dependencies:** Tasks 3 and 5.
+
+**Files likely touched:** `compose.yml`, `.env.example`, `README.md`.
+
+### Checkpoint: Complete framework
+
+- [x] Django tests and the 80% coverage gate pass.
+- [x] Ruff formatting and lint checks pass.
+- [x] The web container serves `/healthz` and the image health check passes.
+- [x] Infrastructure smoke checks pass.
+- [x] Each completed slice is committed separately.
+
+## Risks and Mitigations
+
+| Risk | Impact | Mitigation |
+| --- | --- | --- |
+| Docker runtime cannot import code from `src` | High | Add the import path explicitly in `manage.py` and test inside the image. |
+| Database startup race | Medium | Gate `web` on the existing `db` health check. |
+| Deployment settings pass locally but fail on Cloud Run | High | Require settings inputs, run `check --deploy`, and bind Gunicorn to `PORT`. |
+| Health endpoint leaks deployment details | Medium | Use a constant minimal response and no database query. |
+
+## Scope Exclusions
+
+- No Django apps, models, migrations, admin customization, templates, product
+ routes, authentication flows, or browser tests.
+- No Google Cloud provisioning, image publication, deployment, or remote
+ migrations.
diff --git a/docs/specs/skillstreak-django-source-framework.md b/docs/specs/skillstreak-django-source-framework.md
new file mode 100644
index 0000000..a5ea723
--- /dev/null
+++ b/docs/specs/skillstreak-django-source-framework.md
@@ -0,0 +1,152 @@
+# Spec: SkillStreak Django Source Framework
+
+## Status
+
+Accepted — settings structure, Docker-first development, project name, minimal
+source layout, and liveness-only health-check scope are agreed.
+
+## Objective
+
+Create the minimal, Docker-first Django source framework for SkillStreak. It
+must establish the project package, safely separate local and production
+settings, connect to the existing PostgreSQL Compose service, and expose a
+dependency-free `/healthz` endpoint.
+
+It deliberately excludes product apps, models, migrations, authentication
+flows, pages, APIs, and business logic.
+
+## Context and Decisions
+
+The repository already provides an approved infrastructure foundation: Python
+3.14, Django 5.2 LTS, PostgreSQL, Docker Compose for local development, and a
+future Google Cloud Run deployment. It contains no application source.
+
+The following decisions are accepted for this bootstrap:
+
+- The Django project package is named `skillstreak`.
+- Local development is Docker-only; host-Python commands are not a supported
+ developer workflow.
+- Settings use a `base` / `local` / `production` module split.
+- The source layout remains minimal until a product feature defines its first
+ app boundary.
+- A `/healthz` endpoint is included as application plumbing rather than a
+ product feature.
+
+Separate settings modules keep Cloud Run security requirements distinct from
+local convenience defaults. A single conditional settings module would start
+smaller but becomes harder to audit as deployment settings expand.
+
+## Tech Stack
+
+- Python 3.14 and Django 5.2, using the existing locked dependencies.
+- PostgreSQL through the existing Compose `db` service.
+- Docker Compose as the only supported local developer workflow.
+- Django project package: `skillstreak`.
+
+## Commands
+
+After implementation, the supported local application commands will be:
+
+```sh
+docker compose up --build
+docker compose exec web python manage.py check
+docker compose exec web pytest
+docker compose exec web ruff format --check .
+docker compose exec web ruff check .
+curl --fail http://localhost:8000/healthz
+```
+
+The existing infrastructure validation remains required:
+
+```sh
+./scripts/check-infrastructure.sh
+./scripts/smoke-image.sh
+./scripts/smoke-postgres.sh
+```
+
+## Project Structure
+
+```text
+manage.py
+src/
+ skillstreak/
+ __init__.py
+ asgi.py
+ wsgi.py
+ urls.py
+ health.py
+ settings/
+ __init__.py
+ base.py
+ local.py
+ production.py
+```
+
+`health.py` remains project plumbing, not a product app. It must not establish
+an app-domain convention before a product specification exists.
+
+## Settings Design
+
+- `base.py`: shared Django configuration, installed Django components,
+ middleware, templates, and static-file configuration.
+- `local.py`: `DEBUG=True`, values supplied through the tracked-safe
+ `.env.example` / untracked `.env` convention, and the local Compose
+ PostgreSQL connection.
+- `production.py`: `DEBUG=False`, production-only HTTPS and secure-cookie
+ settings, plus required secret-key, allowed-host, and database configuration.
+- `manage.py`, ASGI, and WSGI default to local settings. Cloud Run explicitly
+ selects production settings.
+
+## Code Style
+
+Framework code keeps configuration explicit and puts environment access at the
+settings boundary rather than scattering it through views:
+
+```python
+# settings/production.py
+from .base import *
+
+DEBUG = False
+SECRET_KEY = required_environment_value("DJANGO_SECRET_KEY")
+ALLOWED_HOSTS = comma_separated_environment_value("ALLOWED_HOSTS")
+```
+
+Use Python and Django naming conventions, Ruff's existing 100-character line
+limit, and direct type annotations for new non-trivial functions.
+
+## Testing Strategy
+
+- Add focused Django tests for `/healthz` and settings selection.
+- Preserve the infrastructure harness unchanged except where it must recognize
+ the new application framework.
+- Run Django checks and the existing pytest coverage gate once `manage.py`
+ exists.
+- Defer browser testing until the first user-facing feature.
+
+## Boundaries
+
+- **Always:** use Docker for local commands; load secrets and deployment
+ configuration from environment variables; keep `/healthz` free of sensitive
+ information.
+- **Ask first:** add dependencies; add migrations or a database schema; change
+ CI or infrastructure-plan decisions; create a product app.
+- **Never:** commit `.env` values; add domain behavior; weaken production
+ security settings; create a separate frontend or API service.
+
+## Success Criteria
+
+- `docker compose up --build` starts Django and PostgreSQL.
+- `/healthz` returns HTTP 200 from the running application.
+- Django starts with local settings and connects to Compose PostgreSQL.
+- Production settings reject missing required production configuration and
+ enable secure deployment defaults.
+- The image has a Django command and health check suitable for the planned
+ Cloud Run delivery.
+- Existing infrastructure checks and the new Django test suite pass.
+
+## Health-Check Scope
+
+`/healthz` is liveness-only: it returns HTTP 200 without querying PostgreSQL.
+This avoids transient database availability producing container-health churn.
+A database-readiness endpoint may be added later when deployment monitoring
+needs it.
diff --git a/manage.py b/manage.py
new file mode 100644
index 0000000..0e9031b
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,20 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+
+import os
+import sys
+from pathlib import Path
+
+
+def main() -> None:
+ """Run Django administrative commands with the source directory importable."""
+ sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "skillstreak.settings.local")
+
+ from django.core.management import execute_from_command_line
+
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pyproject.toml b/pyproject.toml
index 94774b4..502ea72 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,6 +9,8 @@ select = ["E", "F", "I", "UP", "B"]
[tool.pytest.ini_options]
addopts = "-ra --strict-config --strict-markers"
testpaths = ["tests"]
+DJANGO_SETTINGS_MODULE = "skillstreak.settings.local"
+pythonpath = ["src"]
[tool.coverage.run]
branch = true
diff --git a/requirements.in b/requirements.in
index 80eae50..1853be4 100644
--- a/requirements.in
+++ b/requirements.in
@@ -1,5 +1,6 @@
# Application and development tooling. `requirements.txt` is the resolved lockfile.
Django>=5.2.17,<5.3
+gunicorn>=26.2,<27
psycopg[binary]>=3.2,<3.4
pip-tools>=7.5,<8
pytest>=9,<10
diff --git a/requirements.txt b/requirements.txt
index f7a83fd..4206a47 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,6 +16,8 @@ django==5.2.17
# via -r requirements.in
greenlet==3.5.6
# via playwright
+gunicorn==26.2.0
+ # via -r requirements.in
iniconfig==2.3.0
# via pytest
packaging==26.3
diff --git a/scripts/smoke-image.sh b/scripts/smoke-image.sh
index a595ee0..835c696 100755
--- a/scripts/smoke-image.sh
+++ b/scripts/smoke-image.sh
@@ -2,5 +2,25 @@
set -eu
image=skillstreak-infrastructure-smoke
+container_id=
+
+cleanup() {
+ if [ -n "$container_id" ]; then
+ docker rm --force "$container_id" >/dev/null 2>&1 || true
+ fi
+}
+trap cleanup EXIT INT TERM
+
docker build --target runtime --tag "$image" .
test "$(docker image inspect --format '{{.Config.User}}' "$image")" = "app"
+container_id=$(docker run --detach "$image")
+
+attempt=0
+until [ "$(docker inspect --format '{{.State.Health.Status}}' "$container_id")" = "healthy" ]; do
+ attempt=$((attempt + 1))
+ if [ "$attempt" -ge 15 ]; then
+ echo "Application image did not become healthy within 30 seconds." >&2
+ exit 1
+ fi
+ sleep 2
+done
diff --git a/src/skillstreak/__init__.py b/src/skillstreak/__init__.py
new file mode 100644
index 0000000..64f2deb
--- /dev/null
+++ b/src/skillstreak/__init__.py
@@ -0,0 +1 @@
+"""SkillStreak Django project package."""
diff --git a/src/skillstreak/asgi.py b/src/skillstreak/asgi.py
new file mode 100644
index 0000000..6bb107e
--- /dev/null
+++ b/src/skillstreak/asgi.py
@@ -0,0 +1,9 @@
+"""ASGI configuration for SkillStreak."""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "skillstreak.settings.local")
+
+application = get_asgi_application()
diff --git a/src/skillstreak/health.py b/src/skillstreak/health.py
new file mode 100644
index 0000000..9c8f086
--- /dev/null
+++ b/src/skillstreak/health.py
@@ -0,0 +1,9 @@
+"""Liveness endpoint for container orchestration."""
+
+from django.http import JsonResponse
+from django.http.request import HttpRequest
+
+
+def health_check(_: HttpRequest) -> JsonResponse:
+ """Return a constant liveness response without querying dependencies."""
+ return JsonResponse({"status": "ok"})
diff --git a/src/skillstreak/settings/__init__.py b/src/skillstreak/settings/__init__.py
new file mode 100644
index 0000000..bf565ed
--- /dev/null
+++ b/src/skillstreak/settings/__init__.py
@@ -0,0 +1 @@
+"""Settings modules for local and production environments."""
diff --git a/src/skillstreak/settings/base.py b/src/skillstreak/settings/base.py
new file mode 100644
index 0000000..089fcff
--- /dev/null
+++ b/src/skillstreak/settings/base.py
@@ -0,0 +1,83 @@
+"""Shared Django settings for every SkillStreak environment."""
+
+import os
+from pathlib import Path
+from urllib.parse import unquote, urlparse
+
+from django.core.exceptions import ImproperlyConfigured
+
+BASE_DIR = Path(__file__).resolve().parents[3]
+
+INSTALLED_APPS = [
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+]
+
+MIDDLEWARE = [
+ "django.middleware.security.SecurityMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
+]
+
+ROOT_URLCONF = "skillstreak.urls"
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [BASE_DIR / "templates"],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = "skillstreak.wsgi.application"
+ASGI_APPLICATION = "skillstreak.asgi.application"
+
+LANGUAGE_CODE = "en-us"
+TIME_ZONE = "UTC"
+USE_I18N = True
+USE_TZ = True
+
+STATIC_URL = "static/"
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+
+
+def database_configuration(database_url: str) -> dict[str, dict[str, str | int]]:
+ """Convert a PostgreSQL URL into Django's database configuration."""
+ parsed_url = urlparse(database_url)
+ if parsed_url.scheme not in {"postgres", "postgresql"} or not parsed_url.path:
+ message = "DATABASE_URL must be a PostgreSQL connection URL."
+ raise ImproperlyConfigured(message)
+
+ return {
+ "default": {
+ "ENGINE": "django.db.backends.postgresql",
+ "NAME": unquote(parsed_url.path.lstrip("/")),
+ "USER": unquote(parsed_url.username or ""),
+ "PASSWORD": unquote(parsed_url.password or ""),
+ "HOST": parsed_url.hostname or "",
+ "PORT": parsed_url.port or "",
+ },
+ }
+
+
+def required_environment_value(name: str) -> str:
+ """Return a required deployment setting without exposing its value."""
+ value = os.getenv(name)
+ if value:
+ return value
+
+ raise ImproperlyConfigured(f"{name} must be set.")
diff --git a/src/skillstreak/settings/local.py b/src/skillstreak/settings/local.py
new file mode 100644
index 0000000..f3c080d
--- /dev/null
+++ b/src/skillstreak/settings/local.py
@@ -0,0 +1,21 @@
+"""Development settings for the Docker Compose environment."""
+
+import os
+
+from .base import * # noqa: F403
+from .base import database_configuration
+
+DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
+SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "django-insecure-local-development-only")
+ALLOWED_HOSTS = [
+ host.strip()
+ for host in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
+ if host.strip()
+]
+
+DATABASES = database_configuration(
+ os.getenv(
+ "DATABASE_URL",
+ "postgresql://skillstreak:change-me-for-local-development@db:5432/skillstreak",
+ )
+)
diff --git a/src/skillstreak/settings/production.py b/src/skillstreak/settings/production.py
new file mode 100644
index 0000000..7c6c907
--- /dev/null
+++ b/src/skillstreak/settings/production.py
@@ -0,0 +1,20 @@
+"""Secure deployment settings for Cloud Run."""
+
+from .base import * # noqa: F403
+from .base import database_configuration, required_environment_value
+
+DEBUG = False
+SECRET_KEY = required_environment_value("DJANGO_SECRET_KEY")
+ALLOWED_HOSTS = [
+ host.strip() for host in required_environment_value("ALLOWED_HOSTS").split(",") if host.strip()
+]
+DATABASES = database_configuration(required_environment_value("DATABASE_URL"))
+
+SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
+SECURE_SSL_REDIRECT = True
+SESSION_COOKIE_SECURE = True
+CSRF_COOKIE_SECURE = True
+SECURE_HSTS_SECONDS = 31_536_000
+SECURE_HSTS_INCLUDE_SUBDOMAINS = True
+SECURE_CONTENT_TYPE_NOSNIFF = True
+X_FRAME_OPTIONS = "DENY"
diff --git a/src/skillstreak/urls.py b/src/skillstreak/urls.py
new file mode 100644
index 0000000..04fb22b
--- /dev/null
+++ b/src/skillstreak/urls.py
@@ -0,0 +1,9 @@
+"""Top-level URL configuration for project-level endpoints."""
+
+from django.urls import path
+
+from .health import health_check
+
+urlpatterns = [
+ path("healthz", health_check, name="health-check"),
+]
diff --git a/src/skillstreak/wsgi.py b/src/skillstreak/wsgi.py
new file mode 100644
index 0000000..6da0d5e
--- /dev/null
+++ b/src/skillstreak/wsgi.py
@@ -0,0 +1,9 @@
+"""WSGI configuration for SkillStreak."""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "skillstreak.settings.local")
+
+application = get_wsgi_application()
diff --git a/tests/application/test_health.py b/tests/application/test_health.py
new file mode 100644
index 0000000..a30a655
--- /dev/null
+++ b/tests/application/test_health.py
@@ -0,0 +1,5 @@
+def test_health_check_returns_ok(client) -> None:
+ response = client.get("/healthz")
+
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
diff --git a/tests/application/test_production_settings.py b/tests/application/test_production_settings.py
new file mode 100644
index 0000000..5d21919
--- /dev/null
+++ b/tests/application/test_production_settings.py
@@ -0,0 +1,24 @@
+import importlib
+
+import pytest
+from django.core.exceptions import ImproperlyConfigured
+
+
+def test_production_settings_require_a_secret_key(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("DJANGO_SECRET_KEY", raising=False)
+
+ with pytest.raises(ImproperlyConfigured, match="DJANGO_SECRET_KEY"):
+ importlib.import_module("skillstreak.settings.production")
+
+
+def test_production_settings_load_secure_values(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("DJANGO_SECRET_KEY", "test-only-secret-key")
+ monkeypatch.setenv("ALLOWED_HOSTS", "example.com,api.example.com")
+ monkeypatch.setenv("DATABASE_URL", "postgresql://user:password@db:5432/skillstreak")
+
+ production = importlib.import_module("skillstreak.settings.production")
+
+ assert production.DEBUG is False
+ assert production.ALLOWED_HOSTS == ["example.com", "api.example.com"]
+ assert production.SESSION_COOKIE_SECURE is True
+ assert production.DATABASES["default"]["HOST"] == "db"
diff --git a/tests/infrastructure/test_repository_configuration.py b/tests/infrastructure/test_repository_configuration.py
index fe45dc2..6e6a560 100644
--- a/tests/infrastructure/test_repository_configuration.py
+++ b/tests/infrastructure/test_repository_configuration.py
@@ -25,3 +25,11 @@ def test_required_infrastructure_files_are_present() -> None:
)
assert all((ROOT / path).is_file() for path in required_files)
+
+
+def test_compose_exposes_the_web_service_without_exposing_postgresql() -> None:
+ configuration = (ROOT / "compose.yml").read_text()
+
+ assert ' - "${APP_PORT:-8000}:8000"' in configuration
+ assert "internal: true" not in configuration
+ assert ' - "5432:5432"' not in configuration