From 0bab9690c6ec5a8145dcc1b50dfa538886400e3a Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Sun, 30 Aug 2026 16:07:00 +0200 Subject: [PATCH] Add Docker deployment (build- and stack-tested) - backend/Dockerfile: gunicorn, not the dev server. - frontend/Dockerfile: multi-stage, nginx serves the static build. - nginx.conf proxies /api and /auth to the backend so both services share one origin - keeps the session cookie simple first-party, no SameSite=None/CORS complexity in production. - docker-compose.yml: bind-mounts ./data/{instance,uploads} (not named volumes) so the sqlite db and uploaded photos are visible under the project dir; :Z flag for SELinux-enforcing hosts (Fedora/RHEL) - without it gunicorn fails with "unable to open database file". - FRONTEND_PORT env var to pick the exposed port. - .env.example: documents CORS_ORIGIN is moot in the compose setup (same-origin via nginx) and that SESSION_COOKIE_SECURE=true needs a TLS-terminating reverse proxy in front in real deployment. Verified: both images build clean, full stack up via docker compose, login flow starts for real against the actual NC instance through the nginx proxy, sqlite db persists to the bind mount correctly. --- .gitignore | 3 +++ backend/.dockerignore | 7 +++++++ backend/.env.example | 15 +++++++++++++-- backend/Dockerfile | 23 +++++++++++++++++++++++ backend/requirements.txt | 1 + docker-compose.yml | 23 +++++++++++++++++++++++ frontend/.dockerignore | 3 +++ frontend/Dockerfile | 13 +++++++++++++ frontend/nginx.conf | 30 ++++++++++++++++++++++++++++++ 9 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf diff --git a/.gitignore b/.gitignore index d7d3b75..34b1e44 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ backend/**/__pycache__/ node_modules/ frontend/dist/ .DS_Store + +# docker-compose bind mounts (sqlite db, uploaded receipt photos) +/data/ diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..025ea44 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +**/__pycache__/ +*.pyc +instance/ +uploads/ +.env diff --git a/backend/.env.example b/backend/.env.example index dbe5d0a..62fc0ff 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -30,6 +30,13 @@ LLM_MODEL= # --- Flask --- FLASK_SECRET_KEY=dev-change-me DATABASE_PATH=instance/wgbill.sqlite3 +# Only matters when the frontend calls this backend from a different origin +# (e.g. local dev, vite on :5173). The docker-compose.yml setup puts nginx +# in front of both on one origin, so the browser never makes a cross-origin +# call and this is effectively unused there - but Flask-CORS still needs a +# concrete value, not "*", since credentials (the session cookie) are +# involved. +CORS_ORIGIN=http://localhost:5173 # --- Auth --- # Generate with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" @@ -39,6 +46,10 @@ DATABASE_PATH=instance/wgbill.sqlite3 TOKEN_ENCRYPTION_KEY= # Idle session lifetime in days (sliding - refreshed on each use). SESSION_TTL_DAYS=30 -# Cookies need Secure (HTTPS-only) for any real deployment. Only set to -# false for plain-http localhost dev. +# Cookies need Secure (HTTPS-only) for any real deployment - browsers won't +# send/store a Secure cookie over plain HTTP. Only set to false for +# plain-http localhost dev. For docker-compose: put your own TLS-terminating +# reverse proxy (Caddy, nginx-proxy-manager, etc.) in front of the exposed +# frontend port and set this true; without that, leave it false or login +# will silently fail to persist. SESSION_COOKIE_SECURE=true diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8d4a54c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Pillow needs these to build/run; python:3.12-slim's manylinux wheels cover +# most of it, but libjpeg/zlib runtime libs are still needed at import time. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libjpeg62-turbo \ + zlib1g \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p instance uploads + +EXPOSE 5000 + +# instance/ (sqlite db) and uploads/ (receipt photos) should be mounted as +# volumes - see docker-compose.yml - so they survive a container rebuild. +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "60", "run:app"] diff --git a/backend/requirements.txt b/backend/requirements.txt index 3edccfa..b7dfc02 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,4 @@ python-dotenv==1.0.1 requests==2.32.3 Pillow==10.4.0 cryptography==43.0.1 +gunicorn==23.0.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..faddbfd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + backend: + build: ./backend + env_file: ./backend/.env + volumes: + # Bind mounts (not named volumes) so the sqlite db and uploaded + # receipt photos live visibly under ./data, not hidden inside + # Docker's own volume storage. `:Z` relabels them for the container on + # SELinux-enforcing hosts (Fedora/RHEL) - without it the container + # gets "unable to open database file" since the host dir's default + # user_home_t context isn't accessible to it. Harmless no-op on + # non-SELinux hosts. + - ./data/instance:/app/instance:Z + - ./data/uploads:/app/uploads:Z + restart: unless-stopped + + frontend: + build: ./frontend + ports: + - "${FRONTEND_PORT:-8080}:80" + depends_on: + - backend + restart: unless-stopped diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..deed335 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6ce7aed --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +# VITE_API_BASE_URL is left empty (see .env.example) - nginx below proxies +# /api and /auth to the backend on the same origin, so relative paths work. +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e959999 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,30 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Receipt photo uploads can be sizeable. + client_max_body_size 20m; + + location /api/ { + proxy_pass http://backend:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /auth/ { + proxy_pass http://backend:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # SPA - anything else falls back to index.html. + location / { + try_files $uri /index.html; + } +}