Add waitress + caddy deployment
This commit is contained in:
@@ -5,4 +5,6 @@
|
||||
**/instance
|
||||
*.egg-info
|
||||
**/__pycache__
|
||||
**/__mypy_cache__
|
||||
**/__mypy_cache__
|
||||
**/media
|
||||
*.sqlite
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
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
|
||||
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
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",
|
||||
"requests>=2.32.5",
|
||||
"sqlalchemy>=2.0.48",
|
||||
"waitress>=3.0.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -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
11
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user