Add waitress + caddy deployment
This commit is contained in:
@@ -6,3 +6,5 @@
|
|||||||
*.egg-info
|
*.egg-info
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
**/__mypy_cache__
|
**/__mypy_cache__
|
||||||
|
**/media
|
||||||
|
*.sqlite
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
instance/
|
instance/
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
media/
|
||||||
102
Caddyfile
Normal file
102
Caddyfile
Normal 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"
|
||||||
|
# }
|
||||||
|
# }
|
||||||
53
Dockerfile
53
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
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
RUN pip install -r requirements.txt
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
COPY . .
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
RUN pip install -e .
|
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
66
docker-compose.yml
Normal 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
31
docker-entrypoint.sh
Normal 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
123
docs/DEPLOYMENT.md
Normal 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.
|
||||||
@@ -19,6 +19,7 @@ dependencies = [
|
|||||||
"pyparsing>=3.3.2",
|
"pyparsing>=3.3.2",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
"sqlalchemy>=2.0.48",
|
"sqlalchemy>=2.0.48",
|
||||||
|
"waitress>=3.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -833,16 +833,25 @@ def db_status() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@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."""
|
"""Start the web server."""
|
||||||
|
if debug:
|
||||||
import livereload # noqa: PLC0415
|
import livereload # noqa: PLC0415
|
||||||
|
|
||||||
app = get_app()
|
app = get_app()
|
||||||
app.debug = True
|
app.debug = debug
|
||||||
# app.run()
|
# app.run()
|
||||||
server = livereload.Server(app.wsgi_app)
|
server = livereload.Server(app.wsgi_app)
|
||||||
server.watch("hxbooks/templates/**")
|
server.watch("hxbooks/templates/**")
|
||||||
server.serve(port=5000, host="0.0.0.0")
|
server.serve(port=port, host=host)
|
||||||
|
else:
|
||||||
|
import waitress # noqa: PLC0415
|
||||||
|
|
||||||
|
app = get_app()
|
||||||
|
waitress.serve(app, port=port, host=host)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
11
uv.lock
generated
11
uv.lock
generated
@@ -226,6 +226,7 @@ dependencies = [
|
|||||||
{ name = "pyparsing" },
|
{ name = "pyparsing" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "waitress" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -253,6 +254,7 @@ requires-dist = [
|
|||||||
{ name = "pyparsing", specifier = ">=3.3.2" },
|
{ name = "pyparsing", specifier = ">=3.3.2" },
|
||||||
{ name = "requests", specifier = ">=2.32.5" },
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.48" },
|
{ name = "sqlalchemy", specifier = ">=2.0.48" },
|
||||||
|
{ name = "waitress", specifier = ">=3.0.2" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "3.1.6"
|
version = "3.1.6"
|
||||||
|
|||||||
Reference in New Issue
Block a user