From 39fb2edd71765bcf2ce8d33c60d8f25cb3da5e9a Mon Sep 17 00:00:00 2001 From: Francisco Penedo Alvarez Date: Mon, 30 Mar 2026 22:46:04 +0200 Subject: [PATCH] Add waitress + caddy deployment --- .dockerignore | 4 +- .gitignore | 3 +- Caddyfile | 102 +++++++++++++++++++++++++++++++++++ Dockerfile | 53 ++++++++++++++++--- docker-compose.yml | 66 +++++++++++++++++++++++ docker-entrypoint.sh | 31 +++++++++++ docs/DEPLOYMENT.md | 123 +++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + src/hxbooks/cli.py | 25 ++++++--- uv.lock | 11 ++++ 10 files changed, 402 insertions(+), 17 deletions(-) create mode 100644 Caddyfile create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 docs/DEPLOYMENT.md diff --git a/.dockerignore b/.dockerignore index 93e05c6..adb754e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,6 @@ **/instance *.egg-info **/__pycache__ -**/__mypy_cache__ \ No newline at end of file +**/__mypy_cache__ +**/media +*.sqlite \ No newline at end of file diff --git a/.gitignore b/.gitignore index 363580b..33ad483 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ instance/ -*.sqlite \ No newline at end of file +*.sqlite +media/ \ No newline at end of file diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..22a4bfc --- /dev/null +++ b/Caddyfile @@ -0,0 +1,102 @@ +# Caddyfile for HXBooks +# Replace 'localhost' with your domain for production with automatic HTTPS +localhost { + # Serve static files directly (CSS, JS, images, etc.) + handle /static/* { + root * /var/www + file_server + + # Cache static assets for 7 days (good balance of performance vs update flexibility) + header { + Cache-Control "public, max-age=604800" + # ETag support is enabled by default in file_server + } + } + + # Serve book cover images directly + handle /media/covers/* { + root * /var/www + file_server + + # Cache cover images for 30 days (they may be updated occasionally) + header { + Cache-Control "public, max-age=2592000" + } + } + + # Proxy all other requests to the Flask app + reverse_proxy app:5000 { + # Health check endpoint + health_uri / + health_interval 30s + health_timeout 10s + + # Forward real IP to app + header_up X-Real-IP {remote} + header_up X-Forwarded-For {remote} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Host {host} + } + + # Optional: Enable compression for better performance + encode gzip + + # Security headers + header { + # Remove server identification + -Server + + # Security headers + X-Content-Type-Options nosniff + X-Frame-Options DENY + X-XSS-Protection "1; mode=block" + Referrer-Policy strict-origin-when-cross-origin + } + + # Logging + log { + output file /var/log/caddy/access.log + format json + } +} + +# Production example - uncomment and modify for your domain +# your-domain.com { +# handle /static/* { +# root * /var/www +# file_server +# +# # Cache static assets for 7 days +# header { +# Cache-Control "public, max-age=604800" +# } +# } +# +# handle /media/covers/* { +# root * /var/www +# file_server +# +# # Cache cover images for 30 days +# header { +# Cache-Control "public, max-age=2592000" +# } +# } +# +# reverse_proxy app:5000 { +# header_up X-Real-IP {remote} +# header_up X-Forwarded-For {remote} +# header_up X-Forwarded-Proto {scheme} +# header_up X-Forwarded-Host {host} +# } +# +# encode gzip +# +# header { +# -Server +# X-Content-Type-Options nosniff +# X-Frame-Options DENY +# X-XSS-Protection "1; mode=block" +# Referrer-Policy strict-origin-when-cross-origin +# Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" +# } +# } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b917474..0982d6b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,50 @@ -FROM python:3.11-alpine +# Multi-stage build example for HXBooks +FROM ghcr.io/astral-sh/uv:python3.14-alpine AS builder +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy + +# Omit development dependencies +ENV UV_NO_DEV=1 + +# Disable Python downloads, because we want to use the system interpreter +# across both images. If using a managed Python version, it needs to be +# copied from the build image into the final image; see `standalone.Dockerfile` +# for an example. +ENV UV_PYTHON_DOWNLOADS=0 WORKDIR /app -COPY requirements.txt . -RUN pip install -r requirements.txt -COPY . . -RUN pip install -e . +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked -EXPOSE 8080 +# Then, use a final image without uv +FROM python:3.14-alpine +# It is important to use the image that matches the builder, as the path to the +# Python executable must be the same, e.g., using `python:3.11-slim-bookworm` +# will fail. -CMD ["gunicorn", "--bind", "0.0.0.0:8080", "hxbooks:create_app()"] \ No newline at end of file +# Copy the application from the builder +COPY --from=builder /app /app + +# Copy and setup entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Create shared directories for volumes +RUN mkdir -p /shared/static + +# Place executables in the environment at the front of the path +ENV PATH="/app/.venv/bin:$PATH" + +# Set Flask app for migrations +ENV FLASK_APP="src.hxbooks.app:create_app()" + +# Use `/app` as the working directory +WORKDIR /app + +# Set entrypoint and default command +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["hxbooks", "serve", "--host", "0.0.0.0", "--port", "5000"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..23b806e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +services: + app: + build: . + restart: unless-stopped + volumes: + # Mount media directory for book covers + - media:/app/media + # Mount instance directory for config and database persistence + - instance:/app/instance + # Mount shared directory for static files that Caddy can access + - static:/shared/static + expose: + - "5000" + environment: + - FLASK_ENV=production + networks: + - hxbooks + # Health check to ensure app is ready + healthcheck: + test: + [ + "CMD-SHELL", + "wget --no-verbose --tries=1 --spider http://127.0.0.1:5000/ || exit 1", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + # Caddyfile configuration + - ./Caddyfile:/etc/caddy/Caddyfile:ro + # Media files served directly by Caddy + - media:/var/www/media:ro + # Static files served directly by Caddy (populated by app container) + - static:/var/www/static:ro + # Caddy data for TLS certificates + - caddy_data:/data + - caddy_config:/config + networks: + - hxbooks + depends_on: + app: + condition: service_healthy + +networks: + hxbooks: + driver: bridge + +volumes: + media: + driver: local + instance: + driver: local + static: + driver: local + caddy_data: + driver: local + caddy_config: + driver: local diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..98df65c --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -e + +# Copy static files to shared volume if they don't exist +# This allows Caddy to serve static files directly +if [ ! -f /shared/static/.static-files-ready ]; then + echo "Copying static files to shared volume..." + cp -r /app/src/hxbooks/static/* /shared/static/ + touch /shared/static/.static-files-ready + echo "Static files copied successfully" +else + echo "Static files already present in shared volume" +fi + +# Initialize database if it doesn't exist or run migrations if it does +echo "Checking database status..." +if [ ! -f /app/instance/hxbooks.sqlite ]; then + echo "Database not found. Initializing database..." + hxbooks db init + echo "Database initialized successfully" +else + echo "Database exists. Running migrations..." + # Run Flask-Migrate migrations + flask db upgrade 2>/dev/null || { + echo "No migrations to run or migration failed. Database ready." + } +fi + +# Start the application +echo "Starting HXBooks application..." +exec "$@" \ No newline at end of file diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..21dc40c --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,123 @@ +# HXBooks Docker Deployment + +This directory contains Docker Compose configuration for deploying HXBooks with Caddy as a reverse proxy. + +## Architecture + +- **App Container**: Runs the Flask application on port 5000 +- **Caddy Container**: Reverse proxy with automatic HTTPS, serves static files directly +- **Database Initialization**: Automatic on first startup (creates SQLite database and runs migrations) +- **Volumes**: + - `media`: Book cover images (shared between app and Caddy) + - `static`: CSS/JS files (copied from app to volume on startup, served by Caddy) + - `instance`: Application configuration and SQLite database + - `caddy_data`: TLS certificates + - `caddy_config`: Caddy configuration cache + +## Quick Start + +1. **Development (localhost)**: + ```bash + docker-compose up -d + ``` + Access the app at http://localhost + +2. **Production with your domain**: + - Edit `Caddyfile` and replace `localhost` with your domain + - Run: `docker-compose up -d` + - Caddy will automatically obtain TLS certificates + +## Configuration + +### Environment Variables + +Set these in your `docker-compose.yml` or create a `.env` file: + +```bash +# Flask configuration +FLASK_ENV=production +SECRET_KEY=your-secret-key-here + +# Optional: Google Books API key +GOOGLE_BOOKS_API_KEY=your-api-key +``` + +### Custom Configuration + +Create `instance/config.py` for local overrides: + +```python +SECRET_KEY = "your-production-secret-key" +GOOGLE_BOOKS_API_KEY = "your-api-key" +# Add other Flask config as needed +``` + +## Commands + +```bash +# Start services +docker-compose up -d + +# View logs +docker-compose logs -f app +docker-compose logs -f caddy + +# Stop services +docker-compose down + +# Rebuild and restart +docker-compose up --build -d + +# Remove everything including volumes (⚠️ destroys data) +docker-compose down -v +``` + +## File Serving Strategy + +- **Static files** (CSS/JS): Copied from app container to shared volume on startup, served directly by Caddy +- **Media files** (book covers): Stored in shared volume, served directly by Caddy +- **Application routes**: Proxied to Flask app + +This setup provides optimal performance by having Caddy serve static assets while the Flask app handles dynamic content. + +## Database Initialization + +The application handles database setup automatically on first startup: + +1. **Fresh Install**: Creates SQLite database and initializes tables using `hxbooks db init` +2. **Existing Database**: Runs Flask-Migrate migrations with `flask db upgrade` +3. **Database Location**: `/app/instance/hxbooks.sqlite` (persisted in `instance` volume) + +**Manual Database Operations** (if needed): +```bash +# Connect to running container +docker-compose exec app sh + +# Initialize database manually +hxbooks db init + +# Create new migration +flask db migrate -m "description" + +# Apply migrations +flask db upgrade +``` + +## Production Notes + +1. **Database**: Currently uses SQLite with volume persistence. For high-traffic sites, consider PostgreSQL. + +2. **Backups**: The important data is in the `instance` and `media` volumes: + ```bash + # Backup + docker run --rm -v hxbooks_instance:/source -v $(pwd):/backup alpine tar czf /backup/instance-backup.tar.gz -C /source . + docker run --rm -v hxbooks_media:/source -v $(pwd):/backup alpine tar czf /backup/media-backup.tar.gz -C /source . + ``` + +3. **Updates**: To update the application: + ```bash + docker-compose pull + docker-compose up --build -d + ``` + +4. **Monitoring**: Consider adding health check endpoints and monitoring services to the stack. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d9b67c2..82d41de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "pyparsing>=3.3.2", "requests>=2.32.5", "sqlalchemy>=2.0.48", + "waitress>=3.0.2", ] [project.scripts] diff --git a/src/hxbooks/cli.py b/src/hxbooks/cli.py index a1fc1f7..09ca815 100644 --- a/src/hxbooks/cli.py +++ b/src/hxbooks/cli.py @@ -833,16 +833,25 @@ def db_status() -> None: @cli.command() -def serve() -> None: +@click.option("--port", default=5000, help="Port to run the web server on") +@click.option("--host", default="0.0.0.0", help="Host to run the web server on") +@click.option("--debug", is_flag=True, help="Run in debug mode with auto-reload") +def serve(port: int, host: str, debug: bool) -> None: """Start the web server.""" - import livereload # noqa: PLC0415 + if debug: + import livereload # noqa: PLC0415 - app = get_app() - app.debug = True - # app.run() - server = livereload.Server(app.wsgi_app) - server.watch("hxbooks/templates/**") - server.serve(port=5000, host="0.0.0.0") + app = get_app() + app.debug = debug + # app.run() + server = livereload.Server(app.wsgi_app) + server.watch("hxbooks/templates/**") + server.serve(port=port, host=host) + else: + import waitress # noqa: PLC0415 + + app = get_app() + waitress.serve(app, port=port, host=host) if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index f1a7b9a..71c3a1f 100644 --- a/uv.lock +++ b/uv.lock @@ -226,6 +226,7 @@ dependencies = [ { name = "pyparsing" }, { name = "requests" }, { name = "sqlalchemy" }, + { name = "waitress" }, ] [package.dev-dependencies] @@ -253,6 +254,7 @@ requires-dist = [ { name = "pyparsing", specifier = ">=3.3.2" }, { name = "requests", specifier = ">=2.32.5" }, { name = "sqlalchemy", specifier = ">=2.0.48" }, + { name = "waitress", specifier = ">=3.0.2" }, ] [package.metadata.requires-dev] @@ -755,6 +757,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, ] +[[package]] +name = "waitress" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, +] + [[package]] name = "werkzeug" version = "3.1.6"