gitea-saas-core/Dockerfile
Claude 5be23b9390 gitea-saas-core#40: container + ops doc (Dockerfile, compose, OPS.md, .dockerignore)
- Dockerfile: python:3.12-slim, non-root app user, gunicorn wsgi:application,
  HEALTHCHECK on the public /health route.
- Single-writer ledger safety: exactly ONE gunicorn worker (SQLite money
  ledger; BillingCore's lock serialises writes in-process; threads are safe,
  multiple writer processes are not - stated in the Dockerfile and OPS.md).
- docker-compose.yml: named volume saas_data as the durability story;
  SAAS_API_KEY defaults to empty -> fail-closed 503, /health stays public.
- OPS.md: start/stop, online backup (sqlite3 backup API), restore procedure
  (incl. clearing WAL sidecars), key rotation, down-checklist, scaling notes.
- .dockerignore: runtime files only.

Test evidence (local on branch issue-40; no VPS deploy under this ticket):
- python -m pytest -q: 273 passed, 1 skipped.
- docker compose up --build: /health 200, container health=healthy.
- Persistence: client created + credited 2500 -> down/up -> read back intact.
- Restore drill: backup -> drift client B -> restore -> integrity_check ok,
  client A (balance 2500) present, client B absent (404).

Closes #40 once merged + deployed; the VPS run is a separate owner-approved step.
2026-09-05 04:18:12 -05:00

50 lines
2.2 KiB
Docker

# gitea-saas-core container image (issue #40).
#
# Slim Python base, non-root app user, gunicorn serving the repo-root
# wsgi.py entrypoint (``gunicorn wsgi:application``, assembled app from
# issue #38).
#
# Ledger-safety: exactly ONE gunicorn worker. This process owns a SQLite
# money ledger; the DB is WAL-mode with writes serialised by BillingCore's
# single in-process lock (gitea_saas/db.py + billing_core). Multiple
# workers would be multiple writer processes racing on one SQLite file —
# WAL permits one writer at a time but a ledger must not depend on SQLITE_BUSY
# backoff at the file level. Threads are safe: every request in this process
# funnels through that one lock. Scaling to more writer processes requires a
# network database (see OPS.md "Scaling"); it is deliberately out of scope here.
FROM python:3.12-slim
# Runtime deps only. flask matches requirements.txt's "flask>=3.0"; gunicorn
# is the WSGI server and is installed here rather than added to
# requirements.txt, which belongs to earlier issues. pytest stays out of the
# image. The ``stripe`` package is NOT installed: the adapter only imports it
# lazily and CI/dev run against the built-in mock seam.
RUN pip install --no-cache-dir "flask>=3.0" "gunicorn>=23.0"
# Non-root runtime user.
RUN groupadd --system app && useradd --system --gid app --home-dir /app app
WORKDIR /app
COPY . /app
ENV SAAS_PORT=8080 \
SAAS_DB_PATH=/data/saas.db \
PYTHONPATH=/app \
PYTHONUNBUFFERED=1
# /data is where the SQLite ledger lives. In compose it is a named volume —
# that volume IS the durability story (see docker-compose.yml).
RUN mkdir -p /data && chown -R app:app /app /data
USER app
EXPOSE 8080
# /health is public (auth.PUBLIC_PATHS) and answers without an API key, so
# the check works in every deployment state, including the fail-closed
# unconfigured one. urllib only: the slim base has no curl.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5).status == 200 else 1)"
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "4", \
"--access-logfile", "-", "--error-logfile", "-", "wsgi:application"]