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; + } +}