diff --git a/.gitignore b/.gitignore
index 45899c8b..a8ddc42b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -113,6 +113,5 @@ screenshots/
nginx/certs/
nginx/certbot/
nginx/*.conf
-!nginx/default.dev.conf
*.pem
*.key
diff --git a/Dockerfile b/Dockerfile
index 87811728..03d9dabf 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,67 +1,101 @@
-# Stage 1: build frontend assets (multi-arch: official node image)
+# syntax=docker/dockerfile:1
+#
+# Targets:
+# prod (default, last stage) gunicorn image with assets and static files baked in
+# dev dependencies only; the source is bind-mounted by compose.override.yml
+#
+# Stages are ordered so that editing application code only invalidates the cheap final layers:
+# apt packages, Python dependencies and node modules are all cached separately.
+
+# ---- Frontend assets (multi-arch: official node image) ----
FROM node:24-slim AS assets
WORKDIR /app
COPY package.json package-lock.json ./
-RUN npm ci
+RUN --mount=type=cache,target=/root/.npm \
+ npm ci
COPY gulpfile.js ./
COPY pipeline/source_assets ./pipeline/source_assets
RUN npm run build
-# Stage 2: build the Python environment (multi-arch: official python image)
-FROM python:3.14-slim-trixie AS builder
+
+# ---- Shared Python base: runtime system libraries only ----
+FROM python:3.14-slim-trixie AS python-base
+
+# Keep downloaded packages between builds (the default Docker config for Debian images deletes them)
+RUN rm -f /etc/apt/apt.conf.d/docker-clean \
+ && echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+ --mount=type=cache,target=/var/lib/apt,sharing=locked \
+ apt-get update \
+ && apt-get install -y --no-install-recommends libcairo2
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ UV_PROJECT_ENVIRONMENT=/opt/venv \
+ UV_LINK_MODE=copy \
+ PATH="/opt/venv/bin:$PATH"
+
+
+# ---- Python dependencies (only rebuilt when pyproject.toml / uv.lock change) ----
+FROM python-base AS deps-base
COPY --from=ghcr.io/astral-sh/uv:0.12.21 /uv /uvx /bin/
# pycairo (via z3c.rml) has no wheels and is compiled against cairo
-RUN apt-get update \
- && apt-get install -y --no-install-recommends build-essential pkg-config libcairo2-dev \
- && rm -rf /var/lib/apt/lists/*
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+ --mount=type=cache,target=/var/lib/apt,sharing=locked \
+ apt-get update \
+ && apt-get install -y --no-install-recommends build-essential pkg-config libcairo2-dev
WORKDIR /app
-# Set up py environment
-# DEBUG must never be on in a built image; enable it explicitly via the environment if needed
-ENV DEBUG=false \
- PYTHONDONTWRITEBYTECODE=1 \
- PYTHONUNBUFFERED=1 \
- UV_COMPILE_BYTECODE=1 \
- UV_LINK_MODE=copy
-
-# Copy uv project files first (for better caching)
-COPY pyproject.toml uv.lock ./
-
-# Install the project's dependencies using the lockfile and settings
+FROM deps-base AS deps-prod
+ENV UV_COMPILE_BYTECODE=1
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
-# Then, add the rest of the project source code and install it
-# Installing separately from its dependencies allows optimal layer caching
+FROM deps-base AS deps-dev
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv sync --frozen --no-install-project
+
+
+# ---- Development: dependencies only, source and static assets are mounted at runtime ----
+FROM python-base AS dev
+COPY --from=deps-dev /opt/venv /opt/venv
+WORKDIR /app
+EXPOSE 8000
+CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+
+
+# ---- Static files: depends on the source, so this is the first stage that reruns on a code change ----
+FROM python-base AS static
+COPY --from=deps-prod /opt/venv /opt/venv
+WORKDIR /app
COPY . /app
COPY --from=assets /app/pipeline/built_assets /app/pipeline/built_assets
-RUN --mount=type=cache,target=/root/.cache/uv \
- uv sync --frozen --no-dev
-
# Placeholder values only satisfy settings that are mandatory when DEBUG is off; they are not kept in the image
-RUN EMAIL_HOST=build EMAIL_HOST_USER=build EMAIL_HOST_PASSWORD=build EMAIL_FROM=build@example.com \
- uv run python manage.py collectstatic --noinput
+RUN DEBUG=false EMAIL_HOST=build EMAIL_HOST_USER=build EMAIL_HOST_PASSWORD=build EMAIL_FROM=build@example.com \
+ python manage.py collectstatic --noinput
-FROM python:3.14-slim-trixie
-RUN apt-get update \
- && apt-get install -y --no-install-recommends libcairo2 \
- && rm -rf /var/lib/apt/lists/*
+
+# ---- Production image ----
+FROM python-base AS prod
RUN addgroup --system app \
- && adduser --system --group --home /home/app app \
- && mkdir -p /home/app \
- && chown app:app /home/app
-COPY --from=builder --chown=app:app /app /app
+ && adduser --system --group --home /home/app app
+
+COPY --from=deps-prod /opt/venv /opt/venv
WORKDIR /app
-ENV DEBUG=false \
- PYTHONDONTWRITEBYTECODE=1 \
- PYTHONUNBUFFERED=1
-ENV PATH="/app/.venv/bin:$PATH"
+COPY --chown=app:app . /app
+COPY --from=static --chown=app:app /app/static /app/static
+COPY --from=assets --chown=app:app /app/pipeline/built_assets /app/pipeline/built_assets
+
+# DEBUG must never be on in a built image; enable it explicitly via the environment if needed
+ENV DEBUG=false
USER app
EXPOSE 8000
diff --git a/PyRIGS/urls.py b/PyRIGS/urls.py
index 93b6a513..0577b777 100644
--- a/PyRIGS/urls.py
+++ b/PyRIGS/urls.py
@@ -27,7 +27,3 @@ urlpatterns = [
if settings.DEBUG:
urlpatterns += staticfiles_urlpatterns()
-
- urlpatterns += [
- path("bootstrap/", TemplateView.as_view(template_name="bootstrap.html")),
- ]
diff --git a/README.md b/README.md
index ca91749d..0f14d458 100644
--- a/README.md
+++ b/README.md
@@ -16,14 +16,23 @@ For setup information and other such helpful stuff check the [Wiki](https://gith
- users: Our custom logic for registration and profiles. Semi-modular.
# Running locally
-The compose stack runs the app behind a plain-HTTP nginx (`nginx/default.dev.conf`) on port 80.
+> [!WARNING]
+> `compose.yml` is for **development only** and must not be run in production. It mounts the source into the container, runs Django's development server with `DEBUG` on by default, and uses placeholder captcha keys. Production is deployed from the image built by the `prod` target of the `Dockerfile`.
+
+The compose stack runs Postgres, the Django development server and a gulp watcher that rebuilds the CSS/JS. Changes to Python code and templates reload the app automatically, and changes to `pipeline/source_assets` are rebuilt into `pipeline/built_assets` (refresh the browser to pick them up).
1. Copy `.env.example` to `.env` and fill it in. For local use set at least:
+ ```
+ DEBUG=true
+ DJANGO_ALLOWED_HOSTS=localhost
+ ```
+ The sample data commands refuse to run unless `DEBUG` (or `STAGING`) is true.
2. Start the stack (migrations run automatically on start):
```
docker compose up -d --build
```
-3. Open