Harden the container: non-root, no build tools, read-only, stdout logging

The app ran as root, and the production image contained gcc, vim, wget
and gnupg. Code execution in the container would have allowed changing
the app code and fetching or compiling further tools.

- Multi-stage build: the wheels (psycopg2) are built in a build stage.
  The runtime image only contains libpq5, postgresql-client and
  wait-for-it (724 MB -> 298 MB).
- The app runs as the system user max (uid/gid 10001). The code is
  owned by root and precompiled, the user has no writable directory.
- Log to stdout only; docker collects it. The log file under /app/log
  was lost on every container recreation anyway.
- docker-compose: read-only filesystem (tmpfs for /tmp), no
  capabilities, no-new-privileges and log rotation (5 x 10 MB).
- Add a .dockerignore and set MAX_ORIGIN in docker-compose.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
finnandClaude Opus 5.5 committed 2026-10-21 18:09:30 +00:00
1 parent 7ac4e6c3a7
commit 931243dd60
6 files changed
+55 -32

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
.git
.devcontainer
.vscode
.claude
docs
log
**/__pycache__
**/*.pyc
-1
View File
@@ -1,2 +1 @@
*.pyc *.pyc
log/
+26 -7
View File
@@ -1,20 +1,39 @@
# Build stage: compile the wheels (psycopg2 needs gcc and libpq-dev)
FROM python:3.15-slim-trixie AS build
RUN apt-get update && \
apt-get install --no-install-recommends -y gcc libc-dev libpq-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# Runtime stage: no compiler and no tools besides what db-setup.sh needs
FROM python:3.15-slim-trixie FROM python:3.15-slim-trixie
RUN apt -y update && apt -y upgrade && \ RUN apt-get update && apt-get upgrade -y && \
apt install --no-install-recommends -y wait-for-it lsb-release wget gnupg2 gcc libpq-dev libc-dev vim postgresql-client && apt clean all apt-get install --no-install-recommends -y libpq5 postgresql-client wait-for-it && \
rm -rf /var/lib/apt/lists/*
COPY --from=build /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
RUN groupadd --system --gid 10001 max && \
useradd --system --uid 10001 --gid max --no-create-home --shell /usr/sbin/nologin max
WORKDIR /app WORKDIR /app
COPY requirements.txt .
RUN pip install -U -r requirements.txt
RUN mkdir log
ENV PYTHONPATH=/app/ ENV PYTHONPATH=/app/
ENV FLASK_APP=max.app ENV FLASK_APP=max.app
# Owned by root, so the app cannot modify its own code
COPY max/ ./max COPY max/ ./max
COPY scripts/prod/ . COPY scripts/prod/ .
# Precompile, since the app cannot write __pycache__ at runtime
RUN python -m compileall -q max
USER max
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]
CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:8000", "max.app:app"] CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:8000", "max.app:app"]
+1 -2
View File
@@ -9,8 +9,7 @@ run-dev:
FLASK_APP=max.app FLASK_DEBUG=1 flask run --host 0.0.0.0 --port 8757 FLASK_APP=max.app FLASK_DEBUG=1 flask run --host 0.0.0.0 --port 8757
run-dev-as-prod: run-dev-as-prod:
mkdir -p log FLASK_APP=max.app MAX_ORIGIN=http://localhost:8757 gunicorn -w 1 -b 0.0.0.0:8757 max.app:app
FLASK_APP=max.app LOG_DIR=log MAX_ORIGIN=http://localhost:8757 gunicorn -w 1 -b 0.0.0.0:8757 max.app:app
run-cleanup: run-cleanup:
scripts/dev/cleanup.sh scripts/dev/cleanup.sh
+12
View File
@@ -11,6 +11,18 @@ services:
- PGSQL_PASSWORD=max - PGSQL_PASSWORD=max
- PGSQL_NAME=max - PGSQL_NAME=max
- MAX_ORIGIN=https://max.finn.st - MAX_ORIGIN=https://max.finn.st
read_only: true
tmpfs:
- /tmp # gunicorn worker heartbeat files
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
networks: networks:
- postgresql - postgresql
- uplink - uplink
+8 -22
View File
@@ -1,37 +1,23 @@
import logging import logging
import os
import sys import sys
from logging.config import dictConfig from logging.config import dictConfig
def init_logging(app): def init_logging(app):
handlers = {
"stdout": {
"class": "logging.StreamHandler",
"stream": sys.stdout,
"formatter": "default",
},
}
active_handlers = ["stdout"]
if not app.debug:
handlers["file"] = {
"class": "logging.handlers.RotatingFileHandler",
"filename": os.path.join(os.environ.get("LOG_DIR", "/app/log"), "max.log"),
"formatter": "default",
"maxBytes": 1024 * 1024,
"backupCount": 5,
}
active_handlers.append("file")
dictConfig( dictConfig(
{ {
"version": 1, "version": 1,
"formatters": { "formatters": {
"default": {"format": "[%(asctime)s] [%(levelname)s]: %(message)s"} "default": {"format": "[%(asctime)s] [%(levelname)s]: %(message)s"}
}, },
"handlers": handlers, "handlers": {
"root": {"level": "INFO", "handlers": active_handlers}, "stdout": {
"gunicorn": {"level": "INFO", "handlers": active_handlers}, "class": "logging.StreamHandler",
"stream": sys.stdout,
"formatter": "default",
},
},
"root": {"level": "INFO", "handlers": ["stdout"]},
} }
) )
logger = logging.getLogger(app.name) logger = logging.getLogger(app.name)