forked from fabriziosalmi/certmate
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathDockerfile
More file actions
158 lines (141 loc) · 7.68 KB
/
Copy pathDockerfile
File metadata and controls
158 lines (141 loc) · 7.68 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
150
151
152
153
154
155
156
157
158
# Multi-stage build for optimized image size and faster builds.
# Base image pinned by sha256 digest (not just the tag): the tag is a
# moving target on Docker Hub, the digest is content-addressed and
# guarantees byte-identical bytes. Bump deliberately when there's a
# CVE fix or feature reason — not implicitly on every rebuild.
FROM python:3.12-slim-trixie@sha256:2c941e860699f878900b0edc2403613c234d4b32eda3cc9fa7036991a2a63c4a AS builder
# Set working directory for build stage
WORKDIR /build
# Install build dependencies
RUN apt-get update && \
apt-get install -y -o Acquire::Retries=3 gcc && \
rm -rf /var/lib/apt/lists/*
# Copy every requirements*.txt so REQUIREMENTS_FILE and EXTRA_REQUIREMENTS
# can point at any of the optional sets (storage backends, cloud DNS,
# extended providers, …) without rebuilding the COPY layer for each one.
COPY requirements*.txt ./
# Create virtual environment and install dependencies
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Install minimal requirements by default (fastest build)
# Override with --build-arg REQUIREMENTS_FILE=requirements.txt for full install
ARG REQUIREMENTS_FILE=requirements.txt
# Optional extra pip installs layered on top of the main requirements.
# Accepts a SPACE-SEPARATED list so a single image can bundle e.g. the
# Azure DNS plugin AND every remote storage backend at once. Quote the
# value when invoking buildx so the shell preserves the spaces:
#
# --build-arg EXTRA_REQUIREMENTS="requirements-azure.txt requirements-storage-all.txt"
# --build-arg EXTRA_REQUIREMENTS="requirements-aws.txt requirements-gcp.txt"
# --build-arg EXTRA_REQUIREMENTS=requirements-storage-all.txt (single file)
#
# Empty by default → no second install, layer cached.
ARG EXTRA_REQUIREMENTS=
# The venv's pip, pinned to the same version as the runtime stage's.
#
# This used to be a bare `pip install -U pip`, and the effect was that the pin
# defended the copy nobody uses. `ENV PATH` puts /opt/venv/bin first, so `pip`
# in the finished image resolves to THIS one — measured on the published
# v2.25.4 image:
#
# /usr/local/bin/pip 26.1.2 pinned, and the comment below explains why
# /opt/venv/bin/pip 26.2.1 whatever PyPI served that day
# which pip -> /opt/venv/bin/pip
#
# The runtime pin's own comment gives reproducibility as a reason — "two builds
# of the same commit could differ" — and that was true of the unpinned one, not
# the pinned one. Same ARG, so a deliberate bump moves both together.
ARG PIP_VERSION=26.1.2
# shellcheck disable=SC2086 — intentional word-splitting to iterate the list.
# No -U: an exact `==` specifier installs that version regardless of what is
# already there — verified, including downgrading 26.2.1 to 26.1.2 — so the
# flag only muddies the intent (Copilot, #553). setuptools and wheel keep
# theirs; they are build-time only and never reach the runtime stage.
RUN pip install "pip==${PIP_VERSION}" -U setuptools wheel && \
pip install --no-cache-dir -r ${REQUIREMENTS_FILE} && \
if [ -n "${EXTRA_REQUIREMENTS}" ]; then \
for req in ${EXTRA_REQUIREMENTS}; do \
echo "==> Installing extras from ${req}"; \
pip install --no-cache-dir -r "${req}"; \
done; \
fi
# Production stage — same digest pin as the builder stage above.
FROM python:3.12-slim-trixie@sha256:2c941e860699f878900b0edc2403613c234d4b32eda3cc9fa7036991a2a63c4a
# Set working directory
WORKDIR /app
# Install runtime dependencies + tini for proper PID 1 signal handling.
# bash is needed because: (a) the certmate user is created with /bin/bash as
# its login shell on the line below, and (b) operator-provided deploy hooks
# routinely start with `#!/bin/bash` — without bash the kernel cannot resolve
# the shebang and the script returns exit 127 (issue #207).
# apt-get upgrade pulls security patches for glibc, zlib, etc.
#
# The pip upgrade is this stage's, not the builder's. The builder already runs
# `pip install -U pip`, but that only patches the BUILDER's interpreter — the
# runtime stage starts from the same base image with its own bundled pip under
# /usr/local/lib/python3.12/site-packages, which nothing touched. That stale
# copy is what the Trivy scan keeps reporting (CVE-2026-8643, CVE-2026-6357,
# CVE-2026-3219 against pip 25.0.1); the app itself runs from /opt/venv and
# never uses it. See issue #403.
#
# Pinned, for the same reason the base image is pinned by digest a few lines
# up: an unpinned `--upgrade pip` would make the runtime stage's contents
# depend on whatever PyPI happens to serve at build time, so two builds of the
# same commit could differ. Bump this deliberately when a pip CVE lands.
ARG PIP_VERSION=26.1.2
RUN apt-get update && \
apt-get upgrade -y -o Acquire::Retries=3 && \
apt-get install -y -o Acquire::Retries=3 bash curl tini && \
rm -rf /var/lib/apt/lists/* && \
pip install --no-cache-dir "pip==${PIP_VERSION}" && \
useradd --create-home --shell /bin/bash certmate
# Copy virtual environment from builder stage
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy application code
COPY . .
# Create the runtime-writable directories and make them arbitrary-UID ready.
#
# Under rootless podman (issue #380, Rocky Linux) and OpenShift the container
# process is remapped and runs as an ARBITRARY UID that is NOT 1000 and is not
# present in /etc/passwd; that UID is, however, always a member of the root
# group (GID 0). Follow the OpenShift "arbitrary UID" pattern: give these trees
# group 0 and make them group-writable + setgid, so ANY such UID can create and
# rename files here, while the default USER 1000 (plain docker/compose) keeps
# working exactly as before. A named volume created from this image inherits
# these perms, so `--user <anyuid>:0` works out of the box.
#
# Security: this opens only the DIRECTORIES to the root group (mode 2770 — no
# world access). Every secret the app writes at runtime (CA private key,
# audit-signing key, DNS credential files, .secret_key) is created 0600
# owner-only in code, so directory group-write never exposes a key.
RUN mkdir -p /app/certificates /app/data /app/logs /app/backups /app/backups/unified && \
chown -R certmate:certmate /app && \
chgrp -R 0 /app/certificates /app/data /app/logs /app/backups && \
chmod -R g=u /app/certificates /app/data /app/logs /app/backups && \
chmod -R o-rwx /app/certificates /app/data /app/logs /app/backups && \
find /app/certificates /app/data /app/logs /app/backups -type d -exec chmod g+s {} +
# Set environment variables
ENV FLASK_APP=app.py
ENV FLASK_ENV=production
ENV PYTHONPATH=/app
# Configurable listen port (issue #80). Override with -e PORT=9000 or in .env.
ENV PORT=8000
# Gunicorn worker timeout in seconds. ACME DNS-01 challenges can take up to
# 5 minutes on slow providers (Namecheap, Infomaniak). Default: 300s.
ENV GUNICORN_TIMEOUT=300
# Switch to non-root user
USER certmate
# Expose port (documents the default; actual port is controlled by $PORT)
EXPOSE 8000
# Health check uses $PORT so it works when the port is overridden
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:${PORT}/health || exit 1
# Use tini as init process for proper signal handling and zombie reaping
ENTRYPOINT ["tini", "--"]
# Run the application
# Single worker + threads: avoids duplicate APScheduler jobs and session
# sharing issues. CertMate is I/O-bound, not CPU-bound.
# 8 threads: SSE holds 1 thread per browser tab; 4 was too few.
# $PORT defaults to 8000 and can be overridden via environment variable.
CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:${PORT} --workers 1 --threads 8 --timeout ${GUNICORN_TIMEOUT} --access-logfile - --error-logfile - --log-level info app:app"]