Dockerize: self-contained stack + B2 backup sidecar
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.gitignore
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.env
|
||||
data/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
.DS_Store
|
||||
node_modules/
|
||||
bonnasclaude.md
|
||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@@ -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"]
|
||||
4
app.py
4
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")
|
||||
|
||||
13
backup/Dockerfile
Normal file
13
backup/Dockerfile
Normal file
@@ -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"]
|
||||
34
backup/backup.sh
Normal file
34
backup/backup.sh
Normal file
@@ -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"
|
||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -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
|
||||
19
entrypoint.sh
Normal file
19
entrypoint.sh
Normal file
@@ -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 "$@"
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user