Site was loading under https but Starlette built logo/static URLs as
http, tripping CSP `img-src 'self'`. Root cause: Dockerfile hardcoded
`--forwarded-allow-ips 127.0.0.1`, but with Caddy on a separate host
(10.10.99.10) reverse-proxying to this VM, the container sees the
request's source IP as the Docker bridge gateway (~172.17.0.1), not
Caddy's real IP — uvicorn discarded X-Forwarded-Proto and fell back
to scheme=http.
Fixes both now and later:
- Dockerfile CMD switched to `sh -c exec uvicorn ... --forwarded-allow-ips
"${FORWARDED_ALLOW_IPS:-127.0.0.1}"`. PID-1 / signal behaviour preserved
via exec. Default stays 127.0.0.1 so host-uvicorn dev is unchanged;
prod sets FORWARDED_ALLOW_IPS=* via .env or compose `command:`.
- docker-compose.prod.yml gets an explicit `command:` override so the
currently-deployed image (with the hardcoded 127.0.0.1 flag) can be
fixed without waiting for a rebuild. Also corrected the port binding
comment + default: Caddy is off-host, so the port must be reachable
over the LAN — restrict with host firewall / OPNsense instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
112 lines
4.5 KiB
Docker
112 lines
4.5 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 8080
|
|
|
|
# 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:8080/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. The trusted-IP value is env-driven so the image
|
|
# can be reused across topologies:
|
|
# - local: defaults to 127.0.0.1 (when running uvicorn on the host)
|
|
# - docker/compose behind Caddy: set FORWARDED_ALLOW_IPS="*" in .env
|
|
# because the container's source IP is the bridge gateway, not
|
|
# 127.0.0.1. Safe because the host only binds 127.0.0.1:8080 so
|
|
# nothing off-host can reach uvicorn directly.
|
|
# `sh -c exec` keeps uvicorn as PID 1 so SIGTERM still triggers a
|
|
# graceful shutdown (exec form was fine before, but we need shell
|
|
# expansion for ${FORWARDED_ALLOW_IPS}).
|
|
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port 8080 --proxy-headers --forwarded-allow-ips \"${FORWARDED_ALLOW_IPS:-127.0.0.1}\""]
|