diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..92497af
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,16 @@
+# Keep the build context small and never bake secrets into the image
+.git
+.github
+.env
+.env.*
+!.env.example
+**/node_modules
+**/__pycache__
+**/*.pyc
+frontend/dist
+backend/frontend_dist
+backend/staticfiles
+backend/media
+**/*.sqlite3
+docs
+screenshots
diff --git a/.env.example b/.env.example
index f9bc157..ad58af7 100644
--- a/.env.example
+++ b/.env.example
@@ -20,3 +20,11 @@ CSRF_TRUSTED_ORIGINS=https://your-frontend.example.com
HSTS_SECONDS=3600
HSTS_INCLUDE_SUBDOMAINS=False
HSTS_PRELOAD=False
+
+# Production only: console log level
+LOG_LEVEL=INFO
+
+# Optional: create the first admin on container start (skipped if unset or
+# if the account already exists)
+# DJANGO_SUPERUSER_EMAIL=admin@example.com
+# DJANGO_SUPERUSER_PASSWORD=change-me
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3c77fa3..7fc280d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -88,3 +88,38 @@ jobs:
- name: Build
run: pnpm build
+
+ docker:
+ name: Docker image
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Build
+ run: docker build -t lead-platform:ci .
+
+ # Start the real image (SQLite stands in for Postgres) and check that it
+ # migrates, serves the SPA, static files and the API
+ - name: Smoke test
+ run: |
+ docker run -d --name app -p 8000:8000 \
+ -e SECRET_KEY=ci-only-insecure-secret-key-not-used-anywhere-else \
+ -e DATABASE_URL=sqlite:////tmp/ci.sqlite3 \
+ -e ALLOWED_HOSTS=localhost \
+ -e CORS_ALLOWED_ORIGINS=http://localhost:8000 \
+ -e CSRF_TRUSTED_ORIGINS=http://localhost:8000 \
+ -e SECURE_SSL_REDIRECT=False \
+ -e DJANGO_SUPERUSER_EMAIL=admin@example.com \
+ -e DJANGO_SUPERUSER_PASSWORD=ci-admin-password \
+ lead-platform:ci
+ for i in $(seq 60); do curl -fsS -o /dev/null http://localhost:8000/ && break; sleep 1; done
+ curl -fsS http://localhost:8000/leads/7 | grep -q 'id="root"'
+ curl -fsS -o /dev/null http://localhost:8000/static/admin/css/base.css
+ test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8000/api/leads/)" = 401
+ curl -fsS -X POST http://localhost:8000/api/auth/login/ \
+ -H 'Content-Type: application/json' \
+ -d '{"email":"admin@example.com","password":"ci-admin-password"}' | grep -q '"access"'
+
+ - name: Container logs
+ if: always()
+ run: docker logs app || true
diff --git a/.gitignore b/.gitignore
index 9c5a04f..e6efd2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -159,4 +159,7 @@ docker-compose.override.yml
# ==============================================================================
# Misc
# ==============================================================================
-*.orig
\ No newline at end of file
+*.orig
+
+# Frontend build copied into the backend (Docker / local production test)
+backend/frontend_dist/
diff --git a/CHECKLIST.md b/CHECKLIST.md
index 015a65f..b7369bb 100644
--- a/CHECKLIST.md
+++ b/CHECKLIST.md
@@ -77,7 +77,7 @@
- [x] Consistent Docstring
- [x] Import Cleanup
- [x] Query Optimization
-- [x] 69 Automated Tests Passing
+- [x] 80 Automated Tests Passing
---
@@ -177,8 +177,8 @@
- [x] Frontend Build (`pnpm build`)
- [x] Frontend Lint (`pnpm lint`)
-- [x] Frontend Tests (68/68, `pnpm test`)
-- [x] Backend Tests (69/69)
+- [x] Frontend Tests (69/69, `pnpm test`)
+- [x] Backend Tests (80/80)
- [x] Manual End-to-End Testing
- [x] Swagger Verification
diff --git a/CLAUDE.md b/CLAUDE.md
index 3ed20eb..5bf0801 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,7 +27,9 @@ frontend/
layouts/ page shells
pages/ route-level views
routes/ routing and guards
-docs/ architecture and written assessments
+docs/ architecture, assessments, DEPLOY.md
+Dockerfile one image: builds the frontend, serves API + admin + SPA (gunicorn + WhiteNoise)
+render.yaml Render blueprint (free Docker web service; database on Neon)
screenshots/ UI captures referenced by the README
```
@@ -72,11 +74,21 @@ Swagger is served at `/api/docs/` once the backend is running.
CI (`.github/workflows/ci.yml`) runs on PRs and pushes to `main`: flake8, a missing-migrations
check, a stale-schema check (`backend/schema.yml` must match `manage.py spectacular`), pytest,
a stale-types check (`frontend/src/types/api.ts` must match `openapi-typescript` output), then
-`pnpm lint`, `pnpm test` and `pnpm build`. After changing views or serializers, regenerate both files:
+`pnpm lint`, `pnpm test` and `pnpm build`. A third job builds the Docker image and smoke-tests the
+running container (SPA route, admin static, API 401, admin login). After changing views or serializers, regenerate both files:
`python manage.py spectacular --file schema.yml` (from `backend/`) and
`pnpm exec openapi-typescript ../backend/schema.yml -o src/types/api.ts` (from `frontend/`).
Python lint config is `.flake8` (max line length 120).
+## Deployment
+
+See `docs/DEPLOY.md`. The image builds the frontend with `VITE_API_URL=/api` and copies it
+to `backend/frontend_dist/`; Django serves `index.html` for every path outside `/api/`,
+`/admin/` and `/static/` (`apps/common/views.py#frontend_index`, last route in
+`config/urls.py`), and WhiteNoise serves the assets. `docker-entrypoint.sh` runs `migrate`
+and `ensure_superuser` (idempotent) on every start. `docker compose up --build` runs it
+locally against Postgres.
+
## Conventions
- Frontend source is **tab-indented**; match the surrounding file.
@@ -111,4 +123,4 @@ Python lint config is `.flake8` (max line length 120).
## Known issues
- The backend requires **Python ≥ 3.12** (Django 6.0). Tests need `SECRET_KEY` and
- `DATABASE_URL` set (e.g. `DATABASE_URL=sqlite:///:memory:`); the full suite is 69 tests.
+ `DATABASE_URL` set (e.g. `DATABASE_URL=sqlite:///:memory:`); the full suite is 80 tests.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..5a16e9f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,61 @@
+# syntax=docker/dockerfile:1
+
+# One image serves everything: the Django API, the admin, and the built
+# React app (same origin, so the frontend calls /api directly).
+
+# ------------------------------------------------------------------------------
+# Stage 1: build the frontend
+# ------------------------------------------------------------------------------
+FROM node:22-slim AS frontend
+
+WORKDIR /app/frontend
+
+RUN npm install --global pnpm@10.33.0
+
+COPY frontend/package.json frontend/pnpm-lock.yaml ./
+RUN pnpm install --frozen-lockfile
+
+COPY frontend/ ./
+
+# Baked in at build time: the API is served from the same origin
+ENV VITE_API_URL=/api
+RUN pnpm build
+
+# ------------------------------------------------------------------------------
+# Stage 2: runtime
+# ------------------------------------------------------------------------------
+FROM python:3.12-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
+ DJANGO_ENV=production \
+ PORT=8000
+
+WORKDIR /app
+
+COPY backend/requirements.txt ./
+RUN pip install -r requirements.txt
+
+COPY backend/ ./
+COPY --from=frontend /app/frontend/dist ./frontend_dist
+
+# Pre-compress the frontend (gzip) so WhiteNoise serves smaller files
+RUN python -m whitenoise.compress frontend_dist
+
+# Collect admin/DRF static files. The values below exist only so production
+# settings load during the build; real ones come from the host at runtime.
+RUN SECRET_KEY=build-only \
+ DATABASE_URL=sqlite:///:memory: \
+ ALLOWED_HOSTS=build \
+ CORS_ALLOWED_ORIGINS=https://build \
+ CSRF_TRUSTED_ORIGINS=https://build \
+ python manage.py collectstatic --noinput
+
+RUN useradd --create-home --uid 10001 app
+USER app
+
+EXPOSE 8000
+
+CMD ["./docker-entrypoint.sh"]
diff --git a/README.md b/README.md
index 342fff5..cbfbfcf 100644
--- a/README.md
+++ b/README.md
@@ -106,9 +106,10 @@ A lead may also transition to **LOST** from any intermediate stage where permitt
### Prerequisites
-- Python 3.11+
-- Node.js 20+ and pnpm
+- Python 3.12+ (Django 6.0)
+- Node.js 22 and pnpm 10 (the versions CI uses)
- PostgreSQL (or set `DATABASE_URL` to a hosted instance)
+- Or just Docker: `docker compose up --build` runs the production image locally (see [Deployment](#deployment))
### Environment
@@ -128,6 +129,8 @@ cp .env.example .env
| `CSRF_TRUSTED_ORIGINS` | Production only, required: comma-separated trusted origins for CSRF |
| `HSTS_SECONDS` | Production only: HSTS max-age in seconds (default `3600`); raise in steps once HTTPS is stable |
| `HSTS_INCLUDE_SUBDOMAINS` / `HSTS_PRELOAD` | Production only: default `False`; enable only if every subdomain serves HTTPS |
+| `LOG_LEVEL` | Production only: minimum level written to the console log (default `INFO`) |
+| `DJANGO_SUPERUSER_EMAIL` / `DJANGO_SUPERUSER_PASSWORD` | Optional: create the first admin account on start, once (see `docs/DEPLOY.md`) |
| `DATABASE_URL` | Database connection URL, e.g. `postgres://user:pass@localhost:5432/db` |
| `VITE_API_URL` | Base URL of the API, e.g. `http://127.0.0.1:8000/api` (read by Vite from this same root `.env`) |
@@ -183,6 +186,19 @@ Application screenshots are in [`screenshots/`](screenshots/).
---
+## Deployment
+
+A single Docker image serves the API, the admin and the built frontend from one
+origin. [`docs/DEPLOY.md`](docs/DEPLOY.md) walks through a free demo deployment
+on Render (app) and Neon (Postgres), using the `render.yaml` blueprint, plus the
+full list of production environment variables.
+
+```bash
+docker compose up --build # production image + Postgres at http://localhost:8000
+```
+
+---
+
## Documentation
Additional design notes live in [`docs/`](docs/), including `ARCHITECTURE.md` and a set of assessment, migration, refactor, and standards write-ups.
diff --git a/backend/apps/accounts/management/__init__.py b/backend/apps/accounts/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/apps/accounts/management/commands/__init__.py b/backend/apps/accounts/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/apps/accounts/management/commands/ensure_superuser.py b/backend/apps/accounts/management/commands/ensure_superuser.py
new file mode 100644
index 0000000..c468eb9
--- /dev/null
+++ b/backend/apps/accounts/management/commands/ensure_superuser.py
@@ -0,0 +1,49 @@
+# ==============================================================================
+# Create the first admin account from environment variables
+# ==============================================================================
+
+import os
+from typing import Any
+
+from django.contrib.auth import get_user_model
+from django.core.management.base import BaseCommand, CommandError
+
+REQUIRED = ("DJANGO_SUPERUSER_EMAIL", "DJANGO_SUPERUSER_PASSWORD")
+
+
+class Command(BaseCommand):
+ """Create an admin from DJANGO_SUPERUSER_* variables if it doesn't exist.
+
+ Runs on every container start (hosts without a shell can't run
+ createsuperuser). An existing account is left untouched, so a changed
+ password is never overwritten by the environment.
+ """
+
+ help = "Create the admin account from DJANGO_SUPERUSER_* environment variables, once."
+
+ def handle(self, *args: Any, **options: Any) -> None:
+ email = os.environ.get("DJANGO_SUPERUSER_EMAIL", "").strip()
+ password = os.environ.get("DJANGO_SUPERUSER_PASSWORD", "")
+
+ if not email and not password:
+ self.stdout.write("ensure_superuser: DJANGO_SUPERUSER_EMAIL not set, skipping.")
+ return
+
+ missing = [name for name in REQUIRED if not os.environ.get(name, "").strip()]
+ if missing:
+ raise CommandError(f"ensure_superuser: set {' and '.join(missing)}.")
+
+ User = get_user_model()
+
+ if User.objects.filter(email__iexact=email).exists():
+ self.stdout.write(f"ensure_superuser: {email} already exists, leaving it unchanged.")
+ return
+
+ User.objects.create_superuser(
+ email=email,
+ password=password,
+ first_name=os.environ.get("DJANGO_SUPERUSER_FIRST_NAME", "Admin"),
+ last_name=os.environ.get("DJANGO_SUPERUSER_LAST_NAME", "User"),
+ )
+
+ self.stdout.write(self.style.SUCCESS(f"ensure_superuser: created admin {email}."))
diff --git a/backend/apps/accounts/tests/test_ensure_superuser.py b/backend/apps/accounts/tests/test_ensure_superuser.py
new file mode 100644
index 0000000..2188114
--- /dev/null
+++ b/backend/apps/accounts/tests/test_ensure_superuser.py
@@ -0,0 +1,87 @@
+# ==============================================================================
+# ensure_superuser Command Tests
+# ==============================================================================
+
+import os
+from io import StringIO
+from unittest import mock
+
+from apps.accounts.choices import UserRole
+from django.contrib.auth import get_user_model
+from django.core.management import call_command
+from django.core.management.base import CommandError
+from django.test import TestCase
+
+User = get_user_model()
+
+ENV = {
+ "DJANGO_SUPERUSER_EMAIL": "admin@example.com",
+ "DJANGO_SUPERUSER_PASSWORD": "a-strong-demo-password",
+ "DJANGO_SUPERUSER_FIRST_NAME": "Ada",
+ "DJANGO_SUPERUSER_LAST_NAME": "Admin",
+}
+
+
+def run(env: dict[str, str]) -> str:
+ """Run the command with the given DJANGO_SUPERUSER_* variables added."""
+
+ out = StringIO()
+
+ with mock.patch.dict(os.environ, env):
+ call_command("ensure_superuser", stdout=out)
+
+ return out.getvalue()
+
+
+class EnsureSuperuserTests(TestCase):
+ """Verify the admin bootstrap is safe to run on every start."""
+
+ def setUp(self) -> None:
+ # Start each test without any DJANGO_SUPERUSER_* variables
+ patcher = mock.patch.dict(os.environ)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ for name in ENV:
+ os.environ.pop(name, None)
+
+ def test_skips_when_not_configured(self) -> None:
+ """Ensure nothing happens without the variables."""
+
+ output = run({})
+
+ self.assertIn("skipping", output)
+ self.assertFalse(User.objects.exists())
+
+ def test_creates_admin_once(self) -> None:
+ """Ensure the account is created with the Admin role and a working password."""
+
+ output = run(ENV)
+
+ user = User.objects.get(email="admin@example.com")
+ self.assertIn("created admin", output)
+ self.assertTrue(user.is_superuser)
+ self.assertEqual(user.role, UserRole.ADMIN)
+ self.assertEqual((user.first_name, user.last_name), ("Ada", "Admin"))
+ self.assertTrue(user.check_password("a-strong-demo-password"))
+
+ def test_rerun_leaves_existing_account_unchanged(self) -> None:
+ """Ensure a restart never resets a password changed after creation."""
+
+ run(ENV)
+ user = User.objects.get(email="admin@example.com")
+ user.set_password("changed-later")
+ user.save()
+
+ output = run({**ENV, "DJANGO_SUPERUSER_EMAIL": "ADMIN@example.com"})
+
+ user.refresh_from_db()
+ self.assertIn("already exists", output)
+ self.assertEqual(User.objects.count(), 1)
+ self.assertTrue(user.check_password("changed-later"))
+
+ def test_half_configured_is_an_error(self) -> None:
+ """Ensure an email without a password fails loudly instead of skipping."""
+
+ with self.assertRaisesMessage(CommandError, "DJANGO_SUPERUSER_PASSWORD"):
+ run({"DJANGO_SUPERUSER_EMAIL": "admin@example.com"})
diff --git a/backend/apps/common/tests/test_frontend_view.py b/backend/apps/common/tests/test_frontend_view.py
new file mode 100644
index 0000000..ead02ca
--- /dev/null
+++ b/backend/apps/common/tests/test_frontend_view.py
@@ -0,0 +1,47 @@
+# ==============================================================================
+# Frontend (SPA) Route Tests
+# ==============================================================================
+
+import tempfile
+from pathlib import Path
+
+from django.test import TestCase, override_settings
+
+
+class FrontendRouteTests(TestCase):
+ """Verify client-side routes get index.html without shadowing the API."""
+
+ def setUp(self) -> None:
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ self.dist = Path(tmp.name)
+ (self.dist / "index.html").write_text("
")
+
+ def test_client_routes_get_index_html(self) -> None:
+ """Ensure deep links like /leads/7 survive a reload."""
+
+ with override_settings(FRONTEND_DIST=self.dist):
+ for path in ["/", "/login", "/leads/7", "/leads?page=2"]:
+ response = self.client.get(path)
+
+ self.assertEqual(response.status_code, 200, path)
+ self.assertIn(b"", b"".join(response.streaming_content))
+ self.assertEqual(response["Cache-Control"], "no-cache")
+
+ def test_api_paths_are_not_swallowed(self) -> None:
+ """Ensure unknown API and admin URLs 404 instead of returning the app."""
+
+ with override_settings(FRONTEND_DIST=self.dist):
+ for path in ["/api/does-not-exist/", "/static/missing.css"]:
+ response = self.client.get(path)
+
+ self.assertEqual(response.status_code, 404, path)
+ self.assertFalse(response.streaming, path)
+
+ def test_missing_build_is_a_404(self) -> None:
+ """Ensure development (no build) doesn't crash on non-API paths."""
+
+ with override_settings(FRONTEND_DIST=self.dist / "missing"):
+ response = self.client.get("/leads/7")
+
+ self.assertEqual(response.status_code, 404)
diff --git a/backend/apps/common/tests/test_logging_settings.py b/backend/apps/common/tests/test_logging_settings.py
new file mode 100644
index 0000000..99bc983
--- /dev/null
+++ b/backend/apps/common/tests/test_logging_settings.py
@@ -0,0 +1,75 @@
+# ==============================================================================
+# Production Logging Tests
+# ==============================================================================
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from django.test import SimpleTestCase
+
+BACKEND_DIR = Path(__file__).resolve().parents[3]
+
+EMIT_LOGS = """
+import logging
+import django
+
+django.setup()
+
+try:
+ raise ValueError("boom")
+except ValueError:
+ logging.getLogger("django.request").exception("Internal Server Error: /api/leads/")
+
+logging.getLogger("apps.leads").info("lead created")
+logging.getLogger("apps.leads").debug("noisy detail")
+"""
+
+
+def run_production(**overrides: str) -> subprocess.CompletedProcess[str]:
+ """Emit sample logs from a fresh interpreter using the production settings."""
+
+ env = {
+ **os.environ,
+ "DJANGO_SETTINGS_MODULE": "config.settings",
+ "DJANGO_ENV": "production",
+ "DEBUG": "False",
+ "SECRET_KEY": "test-only-secret-key-for-logging-settings-checks",
+ "DATABASE_URL": "sqlite:///:memory:",
+ "ALLOWED_HOSTS": "api.example.com",
+ "CORS_ALLOWED_ORIGINS": "https://app.example.com",
+ "CSRF_TRUSTED_ORIGINS": "https://app.example.com",
+ **overrides,
+ }
+
+ return subprocess.run(
+ [sys.executable, "-c", EMIT_LOGS],
+ cwd=BACKEND_DIR,
+ env=env,
+ capture_output=True,
+ text=True,
+ )
+
+
+class ProductionLoggingTests(SimpleTestCase):
+ """Verify production writes errors and app logs to the console."""
+
+ def test_request_errors_are_logged_with_traceback(self) -> None:
+ """Ensure a 500's traceback reaches the container log."""
+
+ result = run_production()
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("ERROR django.request: Internal Server Error: /api/leads/", result.stderr)
+ self.assertIn("ValueError: boom", result.stderr)
+
+ def test_app_info_logs_respect_log_level(self) -> None:
+ """Ensure app logs appear at INFO by default and LOG_LEVEL can raise the bar."""
+
+ default = run_production()
+ quiet = run_production(LOG_LEVEL="WARNING")
+
+ self.assertIn("INFO apps.leads: lead created", default.stderr)
+ self.assertNotIn("noisy detail", default.stderr)
+ self.assertNotIn("lead created", quiet.stderr)
diff --git a/backend/apps/common/tests/test_production_settings.py b/backend/apps/common/tests/test_production_settings.py
index 03061a0..00c3043 100644
--- a/backend/apps/common/tests/test_production_settings.py
+++ b/backend/apps/common/tests/test_production_settings.py
@@ -89,3 +89,38 @@ def test_environment_overrides(self) -> None:
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.split(), ["31536000", "True", "True"])
+
+
+class RenderHostnameTests(SimpleTestCase):
+ """Verify Render's public hostname fills in unset host/origin lists."""
+
+ PRINT_LISTS = "print(s.ALLOWED_HOSTS, s.CORS_ALLOWED_ORIGINS, s.CSRF_TRUSTED_ORIGINS, sep='|')"
+
+ def test_fills_unset_lists_from_render_hostname(self) -> None:
+ """Ensure a Render deploy starts with no host/origin variables set."""
+
+ result = load_production_settings(
+ self.PRINT_LISTS,
+ RENDER_EXTERNAL_HOSTNAME="leads-demo.onrender.com",
+ ALLOWED_HOSTS="",
+ CORS_ALLOWED_ORIGINS="",
+ CSRF_TRUSTED_ORIGINS="",
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(
+ result.stdout.strip(),
+ "['leads-demo.onrender.com']|['https://leads-demo.onrender.com']|['https://leads-demo.onrender.com']",
+ )
+
+ def test_explicit_values_win(self) -> None:
+ """Ensure a custom domain configured explicitly isn't overridden."""
+
+ result = load_production_settings(
+ self.PRINT_LISTS,
+ RENDER_EXTERNAL_HOSTNAME="leads-demo.onrender.com",
+ ALLOWED_HOSTS="leads.example.com",
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertTrue(result.stdout.startswith("['leads.example.com']|"))
diff --git a/backend/apps/common/views.py b/backend/apps/common/views.py
index f8ade1f..fb32fb0 100644
--- a/backend/apps/common/views.py
+++ b/backend/apps/common/views.py
@@ -1,3 +1,32 @@
# ==============================================================================
# Views for the Common app
# ==============================================================================
+
+from django.conf import settings
+from django.http import FileResponse, Http404, HttpRequest
+
+# ==============================================================================
+# Frontend (single-page app)
+# ==============================================================================
+
+
+def frontend_index(request: HttpRequest) -> FileResponse:
+ """Serve the built frontend's index.html so client-side routes survive a reload.
+
+ In the Docker image the built frontend lives in FRONTEND_DIST; WhiteNoise
+ serves its asset files, and every other non-API path gets index.html so
+ React Router can render it. In development the directory doesn't exist
+ and the Vite dev server serves the frontend instead.
+ """
+
+ index = settings.FRONTEND_DIST / "index.html"
+
+ if not index.is_file():
+ raise Http404("Frontend build not found.")
+
+ response = FileResponse(index.open("rb"), content_type="text/html")
+
+ # Always revalidate: index.html points at the current hashed asset files
+ response["Cache-Control"] = "no-cache"
+
+ return response
diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py
index 2f48151..c2e2b58 100644
--- a/backend/config/settings/base.py
+++ b/backend/config/settings/base.py
@@ -136,6 +136,9 @@ def get_list(name: str) -> list[str]:
if static_dir.exists():
STATICFILES_DIRS.append(static_dir)
+# Built frontend copied into the Docker image; absent in development
+FRONTEND_DIST = BASE_DIR / "frontend_dist"
+
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
diff --git a/backend/config/settings/production.py b/backend/config/settings/production.py
index ab7679d..c1b2b88 100644
--- a/backend/config/settings/production.py
+++ b/backend/config/settings/production.py
@@ -17,12 +17,62 @@
cast=bool,
)
-SECURE_SSL_REDIRECT = True
+# Off only to try the production image locally over plain HTTP
+SECURE_SSL_REDIRECT = config(
+ "SECURE_SSL_REDIRECT",
+ default=True,
+ cast=bool,
+)
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
+# ==============================================================================
+# Logging
+# ==============================================================================
+
+# Django's defaults only log to the console when DEBUG is on and otherwise
+# email ADMINS (none are set), so production errors, including 500
+# tracebacks, were silently dropped. Write everything to stderr, which
+# container platforms collect.
+LOG_LEVEL = config("LOG_LEVEL", default="INFO").upper()
+
+LOGGING = {
+ "version": 1,
+ "disable_existing_loggers": False,
+ "formatters": {
+ "plain": {
+ "format": "{asctime} {levelname} {name}: {message}",
+ "style": "{",
+ },
+ },
+ "handlers": {
+ "console": {
+ "class": "logging.StreamHandler",
+ "formatter": "plain",
+ },
+ },
+ "root": {
+ "handlers": ["console"],
+ "level": LOG_LEVEL,
+ },
+ "loggers": {
+ # Replace Django's handlers (debug-only console, mail_admins) with the
+ # root console handler; request errors always get through.
+ "django": {
+ "handlers": [],
+ "level": "INFO",
+ "propagate": True,
+ },
+ "django.request": {
+ "handlers": [],
+ "level": "ERROR",
+ "propagate": True,
+ },
+ },
+}
+
# ==============================================================================
# HTTP Strict Transport Security
# ==============================================================================
@@ -53,6 +103,16 @@
CSRF_TRUSTED_ORIGINS = get_list("CSRF_TRUSTED_ORIGINS")
+# Render sets RENDER_EXTERNAL_HOSTNAME to the service's public host. With the
+# frontend served from the same origin, that host is all three lists need, so
+# use it for any list left unset. Explicit values always win.
+RENDER_EXTERNAL_HOSTNAME = config("RENDER_EXTERNAL_HOSTNAME", default="")
+
+if RENDER_EXTERNAL_HOSTNAME:
+ ALLOWED_HOSTS = ALLOWED_HOSTS or [RENDER_EXTERNAL_HOSTNAME]
+ CORS_ALLOWED_ORIGINS = CORS_ALLOWED_ORIGINS or [f"https://{RENDER_EXTERNAL_HOSTNAME}"]
+ CSRF_TRUSTED_ORIGINS = CSRF_TRUSTED_ORIGINS or [f"https://{RENDER_EXTERNAL_HOSTNAME}"]
+
SECURE_PROXY_SSL_HEADER = (
"HTTP_X_FORWARDED_PROTO",
"https",
@@ -74,3 +134,30 @@
2,
"whitenoise.middleware.WhiteNoiseMiddleware",
)
+
+# ==============================================================================
+# Static Files
+# ==============================================================================
+
+# Compressed copies are written by collectstatic at image build time
+STORAGES = {
+ "default": {
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
+ },
+ "staticfiles": {
+ "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
+ },
+}
+
+# Serve the built frontend's files (assets/, favicon.svg) from the site root
+if FRONTEND_DIST.is_dir():
+ WHITENOISE_ROOT = FRONTEND_DIST
+
+
+def _is_hashed_asset(path: str, url: str) -> bool:
+ """Vite's assets/ files have content hashes in their names, so they can be cached forever."""
+
+ return url.startswith("/assets/")
+
+
+WHITENOISE_IMMUTABLE_FILE_TEST = _is_hashed_asset
diff --git a/backend/config/urls.py b/backend/config/urls.py
index cf8c9c7..511f0ce 100644
--- a/backend/config/urls.py
+++ b/backend/config/urls.py
@@ -3,9 +3,11 @@
# ==============================================================================
from django.contrib import admin
-from django.urls import include, path
+from django.urls import include, path, re_path
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
+from apps.common.views import frontend_index
+
urlpatterns = [
path("admin/", admin.site.urls),
path(
@@ -34,4 +36,12 @@
SpectacularRedocView.as_view(url_name="schema"),
name="redoc",
),
+ # --------------------------------------------------------------------------
+ # Frontend: every other path is a client-side route (must stay last)
+ # --------------------------------------------------------------------------
+ re_path(
+ r"^(?!api/|admin/|static/).*$",
+ frontend_index,
+ name="frontend",
+ ),
]
diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh
new file mode 100755
index 0000000..d9d87d8
--- /dev/null
+++ b/backend/docker-entrypoint.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+# Container start: apply migrations, create the first admin if configured,
+# then serve. Hosts without a pre-deploy hook (e.g. Render's free plan) rely
+# on this running migrations on every start; both steps are idempotent.
+set -e
+
+python manage.py migrate --noinput
+python manage.py ensure_superuser
+
+# 2 workers fit comfortably in a 512 MB free instance
+exec gunicorn config.wsgi:application \
+ --bind "0.0.0.0:${PORT}" \
+ --workers "${WEB_CONCURRENCY:-2}" \
+ --timeout 60 \
+ --access-logfile - \
+ --error-logfile -
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..b279c0d
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,38 @@
+# Run the production image locally against Postgres:
+# docker compose up --build -> http://localhost:8000
+# Uses demo credentials; never reuse these values in a real deployment.
+services:
+ db:
+ image: postgres:17-alpine
+ environment:
+ POSTGRES_DB: leads
+ POSTGRES_USER: leads
+ POSTGRES_PASSWORD: leads
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U leads -d leads"]
+ interval: 2s
+ retries: 15
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+
+ web:
+ build: .
+ depends_on:
+ db:
+ condition: service_healthy
+ ports:
+ - "8000:8000"
+ environment:
+ SECRET_KEY: local-compose-only-not-secret-change-me-for-any-real-use
+ DATABASE_URL: postgres://leads:leads@db:5432/leads
+ ALLOWED_HOSTS: localhost,127.0.0.1
+ CORS_ALLOWED_ORIGINS: http://localhost:8000
+ CSRF_TRUSTED_ORIGINS: http://localhost:8000
+ # Plain HTTP locally; the real host terminates TLS
+ SECURE_SSL_REDIRECT: "False"
+ HSTS_SECONDS: "0"
+ DJANGO_SUPERUSER_EMAIL: admin@example.com
+ DJANGO_SUPERUSER_PASSWORD: admin-demo-password
+
+volumes:
+ pgdata:
diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md
new file mode 100644
index 0000000..5d2fc1c
--- /dev/null
+++ b/docs/DEPLOY.md
@@ -0,0 +1,117 @@
+# Deployment (free demo hosting)
+
+One Docker image serves the whole app: the Django API, the Django admin, and the
+built React frontend (same origin, so no CORS setup). It runs on **Render's free
+web service**, with the database on **Neon's free Postgres**. Neither needs a
+credit card, and neither expires, so the demo can stay up for months.
+
+| Piece | Where | Free-tier limits that matter |
+| --- | --- | --- |
+| App (Docker) | [Render](https://render.com/docs/free) web service | Sleeps after 15 min without traffic; the next visit takes about a minute to wake it. 750 instance-hours/month, enough for one service running all month. |
+| Database | [Neon](https://neon.com/docs/introduction/plans) Postgres | 0.5 GB storage, 100 compute-hours/month. Scales to zero when idle and wakes in about a second. |
+
+Render also offers free Postgres, but it is **deleted 30 days after creation**,
+which is why the database lives on Neon.
+
+---
+
+## 1. Create the database (Neon)
+
+1. Sign up at [neon.com](https://neon.com) (GitHub login works; no card).
+2. Create a project: pick **Postgres 17** and the region closest to your users.
+ The blueprint puts the app in **Singapore**, so choose *AWS Asia Pacific
+ (Singapore)* to keep the two close.
+3. Open **Connect**, turn **Connection pooling off**, and copy the connection
+ string. It looks like:
+
+ ```
+ postgresql://USER:PASSWORD@ep-xxxx.ap-southeast-1.aws.neon.tech/neondb?sslmode=require
+ ```
+
+ Use the direct (non-pooled) string: Django keeps its own connections open
+ and doesn't work well behind the pooler's transaction mode.
+
+## 2. Deploy the app (Render)
+
+1. Sign up at [render.com](https://render.com) with GitHub (no card) and give
+ Render access to this repository.
+2. **New → Blueprint**, select the repository. Render reads `render.yaml` and
+ asks for the values marked `sync: false`:
+
+ | Variable | Value |
+ | --- | --- |
+ | `DATABASE_URL` | The Neon connection string from step 1 |
+ | `DJANGO_SUPERUSER_EMAIL` | The email you'll log in with |
+ | `DJANGO_SUPERUSER_PASSWORD` | A strong password (it is not length-checked, so choose a long one) |
+
+ `SECRET_KEY` is generated by Render. The host and origin settings default to
+ the service's `*.onrender.com` address.
+3. Click **Apply**. The first build takes a few minutes. On start the container
+ runs migrations, creates the admin account once, then starts the server.
+4. Open the service URL (shown at the top of the service page) and log in.
+
+## 3. After the first deploy
+
+- **Team members**: log in at `/admin/` with the admin account and add users.
+ New users get the *Member* role; set *Admin* where needed. There is no
+ self sign-up.
+- **Changing the admin password**: do it in `/admin/`. Restarts never reset it;
+ `DJANGO_SUPERUSER_*` is only used to create the account the first time.
+- **Updates**: every push to `main` redeploys automatically. Branch protection
+ only lets CI-passing PRs merge into `main`.
+- **Logs**: Render's *Logs* tab shows requests and any errors with tracebacks
+ (`LOG_LEVEL` controls verbosity, default `INFO`).
+
+## Ending the demo
+
+Delete the service in Render and the project in Neon. Nothing is billed and no
+card is on file, so there is nothing else to cancel.
+
+---
+
+## Running the production image locally
+
+```bash
+docker compose up --build # then open http://localhost:8000
+```
+
+This builds the same image and runs it against a local Postgres, with demo
+credentials from `docker-compose.yml` (admin: `admin@example.com` /
+`admin-demo-password`). It serves plain HTTP, so HTTPS redirects and HSTS are
+switched off there. The React app works fully; the Django admin login does not
+work over plain HTTP, because its cookies are marked HTTPS-only.
+
+## Environment variables
+
+Required in production (startup fails with a message naming the missing one):
+
+| Variable | Notes |
+| --- | --- |
+| `SECRET_KEY` | Long random value |
+| `DATABASE_URL` | Postgres connection URL |
+| `ALLOWED_HOSTS`, `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS` | Comma-separated. On Render they default to the service's own host; set them for a custom domain. |
+
+Optional:
+
+| Variable | Default | Notes |
+| --- | --- | --- |
+| `DJANGO_SUPERUSER_EMAIL` / `_PASSWORD` | unset | Creates the first admin once. `_FIRST_NAME` / `_LAST_NAME` are optional. |
+| `LOG_LEVEL` | `INFO` | Console log level |
+| `HSTS_SECONDS` | `3600` | See the README before raising it |
+| `WEB_CONCURRENCY` | `2` | Gunicorn workers; 2 fits a 512 MB instance |
+| `SECURE_SSL_REDIRECT` | `True` | Only turn off to try the image over plain HTTP |
+
+## How the image is built
+
+`Dockerfile` has two stages:
+
+1. **Node**: installs the frontend with pnpm and builds it with
+ `VITE_API_URL=/api`, since the API is served from the same origin.
+2. **Python**: installs the backend, copies in the built frontend and
+ pre-compresses it, runs `collectstatic`, and runs as a non-root user.
+ `docker-entrypoint.sh` applies migrations, runs `ensure_superuser`, then
+ starts gunicorn on `$PORT`.
+
+Django serves `index.html` for every path outside `/api/`, `/admin/` and
+`/static/`, so deep links like `/leads/7` survive a reload. WhiteNoise serves
+the hashed files under `/assets/` gzipped, with a one-year immutable cache.
diff --git a/frontend/src/pages/dashboard/DashboardPage.test.tsx b/frontend/src/pages/dashboard/DashboardPage.test.tsx
new file mode 100644
index 0000000..ab31314
--- /dev/null
+++ b/frontend/src/pages/dashboard/DashboardPage.test.tsx
@@ -0,0 +1,17 @@
+import { screen } from "@testing-library/react"
+import { describe, expect, it } from "vitest"
+
+import { renderRoute } from "../../test/render"
+import DashboardPage from "./DashboardPage"
+
+describe("DashboardPage", () => {
+ it("links to the API docs of the configured backend, not localhost", () => {
+ renderRoute(
, { path: "/dashboard", url: "/dashboard" })
+
+ // Vitest pins VITE_API_URL to http://api.test/api
+ expect(screen.getByRole("link", { name: "Open Swagger" })).toHaveAttribute(
+ "href",
+ "http://api.test/api/docs/",
+ )
+ })
+})
diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx
index 78f386a..084f39f 100644
--- a/frontend/src/pages/dashboard/DashboardPage.tsx
+++ b/frontend/src/pages/dashboard/DashboardPage.tsx
@@ -44,7 +44,7 @@ export default function DashboardPage() {