Add waitress + caddy deployment

This commit is contained in:
2026-03-30 22:46:04 +02:00
parent f9d6662467
commit 39fb2edd71
10 changed files with 402 additions and 17 deletions

View File

@@ -6,3 +6,5 @@
*.egg-info
**/__pycache__
**/__mypy_cache__
**/media
*.sqlite

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
instance/
*.sqlite
media/

102
Caddyfile Normal file
View File

@@ -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"
# }
# }

View File

@@ -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()"]
# 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"]

66
docker-compose.yml Normal file
View File

@@ -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

31
docker-entrypoint.sh Normal file
View File

@@ -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 "$@"

123
docs/DEPLOYMENT.md Normal file
View File

@@ -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.

View File

@@ -19,6 +19,7 @@ dependencies = [
"pyparsing>=3.3.2",
"requests>=2.32.5",
"sqlalchemy>=2.0.48",
"waitress>=3.0.2",
]
[project.scripts]

View File

@@ -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__":

11
uv.lock generated
View File

@@ -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"