Files
chicken_babies_site/Dockerfile
Phillip Tarrant f9f90d408e chore: phase 6 hardening — CSP/HSTS, access log, docker, backup, CI
Ships the cross-cutting hardening set:

- SecurityHeadersMiddleware: per-request nonce-based CSP, HSTS
  (production only), Referrer-Policy, Permissions-Policy,
  X-Content-Type-Options, frame-ancestors 'none', form-action 'self'.
- AccessLogMiddleware: one http_request INFO event per request
  (method/path/status/duration_ms/ip/ua). Skips /healthz, redacts
  /admin/auth/consume/<token> paths, logs 500 + re-raises on
  downstream exceptions.
- Public base.html inline nav-toggle script gets a nonce so it
  passes strict CSP without relaxing to 'unsafe-inline'.
- Dockerfile: non-root app user (uid/gid 10001) + stdlib-only
  HEALTHCHECK against /healthz.
- scripts/backup.sh: sqlite3 .backup + tar data/media with
  14-entry retention; host-side cron install documented.
- .gitea/workflows/build-image.yml: on push to master /
  workflow_dispatch, builds and publishes
  git.sneakygeek.net/ptarrant/chicken_babies_site:latest +
  sha-<short>, with GIT_COMMIT_SHA threaded as a build-arg so
  /healthz keeps reporting the right commit in deployed images.
- 8 new tests (security headers + access log).

Pre-existing dev failures (logo asset rename + RESEND env
pollution) remain unchanged; verified not Phase 6 regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 07:38:23 -05:00

108 lines
4.1 KiB
Docker

# syntax=docker/dockerfile:1.7
# ---------------------------------------------------------------------------
# Chicken Babies R Us — container image
#
# Multi-stage build:
# builder - install build deps, create a venv, resolve Python requirements
# runtime - slim image with only runtime libs + the copied venv + app code
#
# Phase 0 scope: runs as root and has no HEALTHCHECK directive. Phase 6
# (per docs/ROADMAP.md) introduces a non-root user and a HEALTHCHECK.
# ---------------------------------------------------------------------------
ARG PYTHON_IMAGE=python:3.12-slim-bookworm
# ==== Stage 1: builder =====================================================
FROM ${PYTHON_IMAGE} AS builder
# Avoid interactive apt prompts and keep pip quiet + deterministic.
ENV DEBIAN_FRONTEND=noninteractive \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1
# Build dependencies: `build-essential` covers any wheel that needs to
# compile from source; libmagic headers are required by python-magic.
# Keep this list minimal to reduce attack surface of the builder stage.
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
build-essential \
libmagic1 \
libmagic-dev \
&& rm -rf /var/lib/apt/lists/*
# Dedicated virtualenv at a stable path so the runtime stage can copy it
# verbatim. This keeps the runtime image free of build tooling.
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:${PATH}"
WORKDIR /build
# Install Python dependencies. Copying only requirements.txt first lets
# Docker cache the dependency layer when application code changes.
COPY requirements.txt /build/requirements.txt
RUN pip install --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# ==== Stage 2: runtime =====================================================
FROM ${PYTHON_IMAGE} AS runtime
# Runtime-only libs: python-magic needs libmagic1 at import time. Build
# tooling is intentionally NOT installed here.
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
libmagic1 \
&& rm -rf /var/lib/apt/lists/*
# Copy the pre-built virtualenv from the builder stage and put it first
# on PATH so `python` and installed console scripts (uvicorn) resolve.
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:${PATH}"
WORKDIR /app
# Application source. Only the `app/` package is needed at runtime; tests
# and docs stay out of the image.
COPY app /app/app
# Git commit SHA wired in at build time. Declared as an ARG with a safe
# default so local builds without --build-arg still succeed; surfaced to
# runtime via an ENV so the Settings loader can pick it up.
ARG GIT_COMMIT_SHA=unknown
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
# Phase 6 hardening: create a dedicated non-root user with a stable
# UID/GID so host bind mounts (data/, media/) have predictable
# ownership. We pin 10001 because values < 1000 collide with Debian
# system accounts on some host distros.
RUN groupadd -g 10001 app \
&& useradd -m -u 10001 -g app app \
&& chown -R app:app /app /opt/venv
USER app
EXPOSE 8000
# Phase 6 healthcheck: hits /healthz over the loopback with stdlib
# urllib so we don't have to install curl/wget in the runtime image.
# Intervals tuned for a small VM: probe every 30s, give a fresh
# container 10s to finish startup before the first probe counts.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2); sys.exit(0)" || exit 1
# Run Uvicorn directly. --proxy-headers + --forwarded-allow-ips make
# Starlette's ProxyHeadersMiddleware trust X-Forwarded-* only from the
# listed peer IPs (Caddy on the host). No --reload: this is a prod-shape
# image; local hot-reload is a dev concern and runs outside Docker.
CMD ["uvicorn", "app.main:app", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--proxy-headers", \
"--forwarded-allow-ips", "127.0.0.1"]