Repository navigation
Expand file tree
/
Copy pathDockerfile
More file actions
149 lines (130 loc) · 7.21 KB
/
Copy pathDockerfile
File metadata and controls
149 lines (130 loc) · 7.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# Two usable build targets - see docker-compose.prod.yml and
# docker-compose.yml for which each one uses:
#
# backend (default target) Lean backend-only image, no
# frontend/dist/ baked in. What production uses
# (docker-compose.prod.yml's `web`) - the frontend
# is built and served by its own container instead
# (frontend/Dockerfile, nginx; see
# frontend/nginx.conf for how the two are routed
# behind the same TLS edge). Also what a bare
# `docker build .` and CI's docker-build job
# produce, since it's the default when no
# --target is given.
# backend-with-frontend Same backend, plus frontend/dist/ baked in, so
# voice_transcriber/server.py's SERVE_FRONTEND
# check is true and page-serving routes/the
# /static mount register too. Only
# docker-compose.yml (local Docker dev parity)
# uses this, via `target: backend-with-frontend`.
# ---- Stage: build the React SPA (frontend/) - only feeds the
# backend-with-frontend target below ----
FROM node:20-slim AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# ---- Stage: install production Python deps ----
# Build tools (build-essential/libssl-dev) live only in this stage, not the
# final image - requirements.txt is the production-only dependency set (see
# that file), which mostly has prebuilt wheels, but this stage stays in
# place as a safety net for any transitive dep that doesn't.
FROM python:3.12-slim AS python-deps
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /tmp/requirements.txt
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r /tmp/requirements.txt
# ---- Stage: common base for both backend targets below ----
FROM python:3.12-slim AS app-base
# Baked in at build time via --build-arg (docker-compose.prod.yml,
# release-gate.yml's docker-build job) so a running container can report
# what image it actually is via GET /healthz (voice_transcriber/server.py,
# config.py's APP_VERSION/GIT_SHA). Declared once here, in app-base, so
# both the `backend` and `backend-with-frontend` targets below inherit the
# same values instead of duplicating the ARG/ENV/LABEL lines per target.
ARG APP_VERSION=dev
ARG GIT_SHA=unknown
ENV APP_VERSION=${APP_VERSION}
ENV GIT_SHA=${GIT_SHA}
LABEL org.opencontainers.image.version="${APP_VERSION}" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.source="https://github.com/haswanth13901/Zenoscribe"
# python:3.12-slim's own baked-in OS packages lag Debian's security repo -
# CI's Trivy scan (docker-build job) failed on CVEs in util-linux/bsdutils
# (already-patched HIGH-severity issues) that were sitting in the base
# image untouched, not in anything requirements.txt pulls in.
#
# dist-upgrade, not plain upgrade: apt-get upgrade refuses to touch any
# package whose fix requires installing/removing a dependency, which is
# exactly the case for low-level packages like util-linux/bsdutils - a
# first attempt at this fix used plain `upgrade` and the CVEs were still
# there in CI afterward. dist-upgrade resolves those dependency changes
# instead of skipping the packages that need them.
RUN apt-get update && apt-get dist-upgrade -y && rm -rf /var/lib/apt/lists/*
# The above only patches Debian (.deb) packages - it can't touch the base
# image's own bundled *system* Python install at /usr/local (a completely
# separate thing from our venv at /opt/venv, which already gets a fresh
# pip via the python-deps stage above). With the OS layer clean, Trivy's
# python-pkg scan surfaced two HIGH CVEs sitting in that bundled system
# Python: setuptools 70.3.0 (CVE-2025-47273, path traversal) and pip's own
# vendored msgpack copy (GHSA-6v7p-g79w-8964) - both fixed by upgrading
# pip/setuptools here, before PATH below points at the venv instead.
RUN python -m pip install --no-cache-dir --upgrade pip setuptools
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Non-root: runs as an unprivileged user rather than the container default
# (root). RECORDINGS.mkdir() runs at import time (config.py), so the
# recordings dir and everything else under /app need to be owned by this
# user before CMD ever runs.
RUN groupadd --system app && useradd --system --gid app --home /app app
COPY --from=python-deps /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY . /app
# ---- Stage: backend-with-frontend (dev-parity only, see header comment) ----
FROM app-base AS backend-with-frontend
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
# Created here (not left to config.py's RECORDINGS.mkdir() at import time)
# so the directory exists, owned by `app`, before any volume ever mounts
# over it: Docker initializes a named volume's first mount from whatever
# already exists at that path in the image, ownership included. Skip this
# and a fresh `zenoscribe-recordings`/`zenoscribe-recordings-prod` volume
# would mount in root-owned, and the non-root app user couldn't write to it.
RUN mkdir -p /app/voice_transcriber/recordings && chown -R app:app /app
USER app
EXPOSE 8000
# Shell form (not exec-array form) so $PORT is expanded at container start -
# PaaS hosts (Render/Railway/Heroku-style) inject PORT and require the app
# to bind to it; fixed-port hosts (VPS, Fly.io) get the 8000 default.
#
# --workers 1 is pinned explicitly, not left to uvicorn's default: raising
# it would give each worker process its own SERVER_BOOT_ID (auth.py), so
# users get bounced between workers with random 401s, and each worker would
# run `alembic upgrade head` concurrently against the same database on
# startup (db.init()). Scale this app vertically or run --workers 1 per
# container behind a load balancer instead - see the README's "Production"
# section.
#
# --proxy-headers --forwarded-allow-ips="*" trusts X-Forwarded-* from
# whatever calls this process directly. Safe here because in every shipped
# deployment (docker-compose.prod.yml + frontend/nginx.conf) that's always
# the `nginx` container on the private Compose network - port 8000 is
# never published to the host (see docker-compose.prod.yml's `expose:`) -
# so nothing else can spoof these headers. Without this, every row in
# `failed_logins` (auth forensics) and every rate-limit bucket
# (rate_limit.py) key off nginx's IP instead of the real client's.
CMD uvicorn voice_transcriber.server:app --host 0.0.0.0 --port ${PORT:-8000} \
--workers 1 --proxy-headers --forwarded-allow-ips="*"
# ---- Stage: backend (production, default target - see header comment) ----
FROM app-base AS backend
RUN mkdir -p /app/voice_transcriber/recordings && chown -R app:app /app
USER app
EXPOSE 8000
CMD uvicorn voice_transcriber.server:app --host 0.0.0.0 --port ${PORT:-8000} \
--workers 1 --proxy-headers --forwarded-allow-ips="*"