From 7f2057f1391c90f878b8728427a0b0b3adeb466f Mon Sep 17 00:00:00 2001 From: Bonna Date: Sat, 6 Jun 2026 14:25:45 -0500 Subject: [PATCH] Dockerize: self-contained stack + B2 backup sidecar --- .claude/settings.local.json | 11 ++++++++++- .dockerignore | 15 ++++++++++++++ Dockerfile | 39 +++++++++++++++++++++++++++++++++++++ app.py | 4 ++-- backup/Dockerfile | 13 +++++++++++++ backup/backup.sh | 34 ++++++++++++++++++++++++++++++++ docker-compose.yml | 32 ++++++++++++++++++++++++++++++ entrypoint.sh | 19 ++++++++++++++++++ requirements.txt | 9 +++++++++ 9 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 backup/Dockerfile create mode 100644 backup/backup.sh create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 requirements.txt diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6be0890..fa1aa3d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -23,7 +23,16 @@ "Bash(cat >> *)", "Bash(grep -v \"^$\")", "Bash(git init *)", - "Bash(git add *)" + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git branch *)", + "Bash(git config *)", + "Bash(git remote *)", + "Bash(git push *)", + "Bash(pip freeze *)", + "Bash(pip3 freeze *)", + "Bash(/usr/local/bin/docker --version)", + "Bash(/Applications/Docker.app/Contents/Resources/bin/docker --version)" ] } } diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1bf53bd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitignore +__pycache__/ +*.pyc +.venv/ +venv/ +env/ +.env +data/ +*.sqlite +*.sqlite3 +*.db +.DS_Store +node_modules/ +bonnasclaude.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fb4fc00 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Default image for a pip-based Python web app (Flask via gunicorn). +# ADJUST the CMD at the bottom to match the app's entry point. +FROM python:3.12-slim + +WORKDIR /app +ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 + +# sqlite3 CLI is used by the backup snapshot; curl for healthchecks. +RUN apt-get update \ + && apt-get install -y --no-install-recommends sqlite3 curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install -r requirements.txt && pip install gunicorn requests + +COPY . . + +# Persistent data lives on a mounted volume, not in the image. +RUN mkdir -p /data/uploads/suggestions + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8000 +ENTRYPOINT ["/entrypoint.sh"] + +# ---- ADJUST THIS LINE to the app's real entry point ---- +# Flask (app object named `app` in app.py): +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"] +# +# FastAPI (app object named `app` in main.py) — swap the CMD for: +# RUN pip install "uvicorn[standard]" +# CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +# +# Django (project package `mysite`): +# CMD ["gunicorn", "--bind", "0.0.0.0:8000", "mysite.wsgi:application"] +# +# Streamlit: +# CMD ["streamlit", "run", "app.py", "--server.port=8000", "--server.address=0.0.0.0"] diff --git a/app.py b/app.py index 164268b..50a23a4 100644 --- a/app.py +++ b/app.py @@ -45,7 +45,7 @@ def money_filter(value): except (TypeError, ValueError): return "0.00" -TAX_FILE = os.path.join(os.path.dirname(__file__), "data", "taxes.json") +TAX_FILE = os.path.join(os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")), "taxes.json") def load_taxes(): if os.path.exists(TAX_FILE): @@ -160,7 +160,7 @@ def inject_unreviewed_count(): count = sum(1 for t in transactions if not t.get("reviewed")) return dict(unreviewed_count=count) -DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) TRANSACTIONS_FILE = os.path.join(DATA_DIR, "transactions.json") RULES_FILE = os.path.join(DATA_DIR, "rules.json") BUDGET_FILE = os.path.join(DATA_DIR, "budget.json") diff --git a/backup/Dockerfile b/backup/Dockerfile new file mode 100644 index 0000000..df6850e --- /dev/null +++ b/backup/Dockerfile @@ -0,0 +1,13 @@ +# Tiny rclone + sqlite sidecar that backs up /data to Backblaze B2 on a loop. +FROM alpine:3.20 + +RUN apk add --no-cache rclone sqlite tzdata + +COPY backup.sh /backup.sh +RUN chmod +x /backup.sh + +ENV BACKUP_INTERVAL=86400 + +# Run once on boot, then every BACKUP_INTERVAL seconds. A failed run is logged +# but does not kill the loop. +CMD ["sh", "-c", "while true; do /backup.sh || echo '[backup] FAILED'; sleep \"${BACKUP_INTERVAL}\"; done"] diff --git a/backup/backup.sh b/backup/backup.sh new file mode 100644 index 0000000..73e6bbd --- /dev/null +++ b/backup/backup.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Snapshot the app's data to Backblaze B2 via rclone. +# - SQLite DB: a consistent .backup snapshot (safe while the app is running) +# - uploads: mirrored to B2 +# rclone's B2 remote ("b2:") is configured entirely from RCLONE_CONFIG_B2_* +# env vars (set in .env / compose). Runs once; the container loops it. +set -eu + +STAMP=$(date +%Y%m%d-%H%M%S) +DEST="b2:${B2_BUCKET}/moon-household-budget" +KEEP_DAYS="${BACKUP_KEEP_DAYS:-30}" + +echo "[backup ${STAMP}] starting" + +# 1. SQLite snapshot (only if a db file is present) +if [ -f /data/app.db ]; then + sqlite3 /data/app.db ".backup '/tmp/app-${STAMP}.db'" + rclone copyto "/tmp/app-${STAMP}.db" "${DEST}/db/app-${STAMP}.db" + rm -f "/tmp/app-${STAMP}.db" + echo "[backup ${STAMP}] db snapshot uploaded" +else + echo "[backup ${STAMP}] no /data/app.db — skipping db snapshot" +fi + +# 2. Uploaded images (and anything else under /data except the live db) +if [ -d /data/uploads ]; then + rclone sync /data/uploads "${DEST}/uploads" + echo "[backup ${STAMP}] uploads synced" +fi + +# 3. Prune old db snapshots +rclone delete --min-age "${KEEP_DAYS}d" "${DEST}/db" 2>/dev/null || true + +echo "[backup ${STAMP}] done" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d798430 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +# Moon Household Budget — self-contained stack. +# app : the Python web app (SQLite on the ./data volume — no DB container) +# backup : rclone sidecar that snapshots ./data to Backblaze B2 on a loop +# +# Deployed on the Unraid NAS under /mnt/user/appdata/moon-household-budget/. +# Bring up with: docker compose up -d --build +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: moon-household-budget + restart: unless-stopped + ports: + - "8052:8000" # http://192.168.0.5:8052 + env_file: .env + volumes: + - ./data:/data # SQLite db + uploaded images live here + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8000/", "||", "exit", "1"] + interval: 30s + timeout: 5s + retries: 3 + + backup: + build: + context: ./backup + container_name: moon-budget-backup + restart: unless-stopped + env_file: .env + volumes: + - ./data:/data:ro # read-only — backup never writes app data diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..f3ee8ef --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Runs before the app starts. Ensures the data dir exists and runs DB +# migrations if the app uses them. Uncomment the lines that apply. +set -e + +mkdir -p /data/uploads/suggestions + +# --- Database migrations (pick what the app uses, leave the rest commented) --- +# Flask-Migrate / Alembic via Flask: +# flask db upgrade +# Raw Alembic: +# alembic upgrade head +# Django: +# python manage.py migrate --noinput +# python manage.py collectstatic --noinput +# Plain SQLite with a schema file (only first run): +# [ -f /data/app.db ] || sqlite3 /data/app.db < schema.sql + +exec "$@" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..71e07cd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +Flask==3.1.3 +Werkzeug==3.1.8 +Jinja2==3.1.6 +click==8.1.8 +itsdangerous==2.2.0 +MarkupSafe==3.0.3 +pdfplumber==0.11.8 +requests +gunicorn