This guide covers everything you need to know about self-hosting the F1 E-Ink Calendar service. If you just want to use the service, visit the public instance at f1.inkycloud.click.
- Quick Start
- Deployment Options
- Project Structure
- Data Files & Updates
- Yearly Maintenance
- Configuration Reference
- Database Management
- Development Setup
- Performance & Caching
- Track Images
Deploy in 5 minutes with one-click deployment:
-
Connect Repository in Coolify Dashboard
- Repository:
https://github.com/Rhiz3K/InkyCloud-F1 - Branch:
main
- Repository:
-
Set Environment Variables
APP_HOST=0.0.0.0 APP_PORT=8000 DEBUG=false
-
Click Deploy → Done! 🎉
For detailed guide with custom domains, SSL, scaling, and monitoring, see COOLIFY.md.
# Clone the repository
git clone https://github.com/Rhiz3K/InkyCloud-F1.git
cd InkyCloud-F1
# Copy environment file
cp .env.example .env
# Build and run with Docker
docker build -t f1-eink-cal .
docker run -p 8000:8000 --env-file .env f1-eink-cal# Start the service
docker-compose up -d
# View logs
docker-compose logs -f
# Stop the service
docker-compose down# Install dependencies
pip install -e .
# Set up local environment
cp .env.local.example .env
# Edit .env if needed - it uses relative paths for local development
# Run the server
python -m app.main
# Or with uvicorn
uvicorn app.main:app --reloadNote: Default paths in config.py are optimized for Docker containers (/app/data/*). The .env.local.example file provides relative paths for local development. Copy it to .env and modify as needed.
For more deployment options (Heroku, Railway, Render, DigitalOcean, systemd), see DEPLOYMENT.md.
| Option | Complexity | Cost | Best For |
|---|---|---|---|
| Coolify | ⭐ Easy | €5-10/mo | Self-hosters wanting Heroku-like experience |
| Docker | ⭐⭐ Medium | Varies | Existing Docker infrastructure |
| Cloud Platforms | ⭐ Easy | $7-25/mo | Quick deployment without infrastructure |
| Manual | ⭐⭐⭐ Advanced | €3-5/mo | Full control, minimal cost |
InkyCloud-F1/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI application (endpoints, lifespan)
│ ├── config.py # Configuration management
│ ├── models.py # Pydantic models
│ ├── assets/
│ │ ├── fonts/ # Custom fonts (TitilliumWeb, RacingSansOne)
│ │ ├── images/ # Driver photos, team logos
│ │ ├── tracks/ # Circuit track images
│ │ ├── seasons/ # Static season calendars (2025.json, 2026.json)
│ │ └── circuits_data.json # Circuit info + historical results
│ ├── services/
│ │ ├── renderer.py # 1-bit BMP rendering engine
│ │ ├── f1_service.py # F1 data service (static + API fallback)
│ │ ├── teams_service.py # Teams & drivers data
│ │ ├── standings_service.py # Championship standings
│ │ ├── database.py # SQLite operations
│ │ ├── scheduler.py # APScheduler background jobs
│ │ ├── backup.py # S3 database backup
│ │ ├── analytics.py # Umami analytics
│ │ └── i18n.py # Translation service
│ └── templates/ # Jinja2 HTML templates
├── scripts/
│ ├── update_seasons.py # Download season calendars from API
│ ├── update_historical.py # Update historical race results
│ └── preprocess_*.py # Asset preprocessing utilities
├── translations/
│ ├── en.json # English translations
│ └── cs.json # Czech translations
├── tests/ # Pytest test suite
├── .github/workflows/
│ ├── ci.yml # CI pipeline (lint, test, build)
│ └── update-f1-data.yml # Weekly auto-update action
├── Dockerfile
├── docker-compose.yml
├── pyproject.toml
├── .env.example
└── README.md
| Component | Purpose |
|---|---|
app/main.py |
FastAPI endpoints with async/await pattern |
app/services/renderer.py |
Pixel-perfect 1-bit BMP rendering engine (1600+ lines) |
app/services/f1_service.py |
F1 data fetching with timezone conversion |
app/services/teams_service.py |
Teams & drivers data service |
app/services/database.py |
SQLite for statistics and cache |
app/services/scheduler.py |
APScheduler background jobs |
app/services/backup.py |
S3-compatible database backup |
app/services/analytics.py |
Fire-and-forget Umami tracking |
app/services/i18n.py |
Translation loader with caching |
The application uses static JSON files for F1 data instead of making API calls at runtime. This eliminates rate limiting issues and enables offline operation.
| File | Description | Update Frequency |
|---|---|---|
app/assets/seasons/2025.json |
2025 race calendar | Once per year (or when FIA changes) |
app/assets/seasons/2026.json |
2026 race calendar | Once per year |
app/assets/circuits_data.json |
Circuit info + historical results | After each GP |
A GitHub Action runs every Monday at 06:00 UTC (after Sunday GP) to automatically update historical race results. Changes are committed directly to the repository.
You can also trigger updates manually from the GitHub Actions tab:
- historical - Update race results after each GP
- seasons - Update season calendars (use when FIA announces changes)
- all - Update both
# Update historical results (after each Grand Prix)
python scripts/update_historical.py
# Update specific circuit only
python scripts/update_historical.py --circuit albert_park
# Update season calendars (when FIA changes schedule)
python scripts/update_seasons.py
# Update specific years
python scripts/update_seasons.py --years 2025,2026-
Update season calendar when FIA announces the official schedule:
python scripts/update_seasons.py --years 2026
-
Add new circuits if any are introduced:
- Add circuit data to
app/assets/circuits_data.json - Add track image to
app/assets/tracks/{circuitId}.png
- Add circuit data to
-
Update dependencies for security:
pip install -U -e ".[dev]" -
Test rendering with the new season data:
pytest tests/ curl "http://localhost:8000/calendar.bmp?year=2026&round=1" -o test.bmp
The GitHub Action automatically updates historical results every Monday. If you need to trigger manually:
# From GitHub Actions tab
# Or locally:
python scripts/update_historical.py- If FIA changes schedule: Update season calendar
- If circuits are added/removed: Update circuits data
- Monitor error logs: Check GlitchTip/Sentry for issues
- Review analytics: Check Umami for usage patterns
-
Create next year's calendar file (placeholder until FIA announces):
python scripts/update_seasons.py --years 2027
-
Review and update dependencies
-
Archive old data if needed (optional - data is useful for historical display)
Create a .env file based on .env.example:
# Application Configuration
APP_HOST=0.0.0.0
APP_PORT=8000
DEBUG=false
# Sentry/GlitchTip Configuration
SENTRY_DSN=your-sentry-dsn-here
SENTRY_ENVIRONMENT=production
SENTRY_TRACES_SAMPLE_RATE=0.1
# Umami Analytics Configuration
UMAMI_WEBSITE_ID=your-website-id
UMAMI_API_URL=https://analytics.example.com/api/send
UMAMI_ENABLED=true
# API Configuration
JOLPICA_API_URL=https://api.jolpi.ca/ergast/f1/current/next.json
REQUEST_TIMEOUT=10
# Default Language
DEFAULT_LANG=en
# Default Timezone (IANA format)
DEFAULT_TIMEZONE=Europe/Prague
# Database and Storage Configuration
# Use absolute paths for containers (default: /app/data)
# For local development, you can use relative paths (e.g., ./data/f1.db)
DATABASE_PATH=/app/data/f1.db
IMAGES_PATH=/app/data/images
# Scheduler Configuration
SCHEDULER_ENABLED=true| Variable | Default | Description |
|---|---|---|
APP_HOST / APP_PORT / PORT |
0.0.0.0:8000 |
Bind address and port |
DEBUG |
false |
Enable verbose logging |
PYTHONUNBUFFERED / PYTHONDONTWRITEBYTECODE |
- | Container-friendly Python flags |
SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_TRACES_SAMPLE_RATE |
- | GlitchTip/Sentry monitoring |
UMAMI_WEBSITE_ID, UMAMI_API_URL, UMAMI_ENABLED |
- | Umami analytics tracking |
JOLPICA_API_URL, REQUEST_TIMEOUT |
- | Upstream F1 data endpoint |
DEFAULT_LANG |
en |
Default calendar language |
DEFAULT_TIMEZONE |
Europe/Prague |
IANA timezone for schedule |
DATABASE_PATH |
/app/data/f1.db |
SQLite database location (absolute path for containers) |
IMAGES_PATH |
/app/data/images |
Generated preview images (absolute path for containers) |
SCHEDULER_ENABLED |
true |
Background data refresh |
BACKUP_ENABLED |
false |
Enable S3 database backup |
BACKUP_CRON |
0 3 * * * |
Backup schedule (cron expression) |
BACKUP_RETENTION_DAYS |
30 |
Days to keep old backups (0 = keep all) |
S3_ENDPOINT_URL |
- | S3-compatible endpoint URL |
S3_ACCESS_KEY_ID |
- | S3 access key |
S3_SECRET_ACCESS_KEY |
- | S3 secret key |
S3_BUCKET_NAME |
- | S3 bucket for backups |
S3_REGION |
auto |
S3 region (use "auto" for Cloudflare R2) |
The application supports automatic backups of the SQLite database to any S3-compatible storage provider (Cloudflare R2, AWS S3, MinIO, Backblaze B2, etc.).
Cloudflare R2 offers generous free tier (10GB storage, 1M requests/month) and no egress fees.
-
Create an R2 bucket in your Cloudflare dashboard:
- Go to R2 → Create bucket
- Name it (e.g.,
f1-eink-backups) - Note your account ID from the bucket URL
-
Create R2 API token:
- Go to R2 → Manage R2 API Tokens → Create API token
- Select "Object Read & Write" permission
- Scope to your backup bucket
- Save the Access Key ID and Secret Access Key
-
Configure environment variables:
BACKUP_ENABLED=true BACKUP_CRON=0 3 * * * BACKUP_RETENTION_DAYS=30 S3_ENDPOINT_URL=https://<account_id>.r2.cloudflarestorage.com S3_ACCESS_KEY_ID=<your-access-key-id> S3_SECRET_ACCESS_KEY=<your-secret-access-key> # skipcq: SCT-A000 (documentation placeholder) S3_BUCKET_NAME=f1-eink-backups S3_REGION=auto
BACKUP_ENABLED=true
BACKUP_CRON=0 3 * * *
BACKUP_RETENTION_DAYS=30
S3_ENDPOINT_URL=https://s3.us-east-1.amazonaws.com
S3_ACCESS_KEY_ID=<your-access-key-id>
S3_SECRET_ACCESS_KEY=<your-secret-access-key> # skipcq: SCT-A000 (documentation placeholder)
S3_BUCKET_NAME=f1-eink-backups
S3_REGION=us-east-1BACKUP_ENABLED=true
BACKUP_CRON=0 3 * * *
BACKUP_RETENTION_DAYS=30
S3_ENDPOINT_URL=http://minio:9000
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_BUCKET_NAME=f1-backups
S3_REGION=us-east-1The BACKUP_CRON variable uses standard cron syntax:
| Expression | Description |
|---|---|
0 3 * * * |
Daily at 3:00 AM UTC (default) |
0 */6 * * * |
Every 6 hours |
0 3 * * 0 |
Weekly on Sundays at 3:00 AM |
0 3 1 * * |
Monthly on the 1st at 3:00 AM |
Backups are stored with the naming pattern: f1_backup_YYYY-MM-DD_HH-MM-SS.db
Example: f1_backup_2025-03-15_03-00-00.db
- Download the backup file from your S3 bucket
- Stop the container
- Replace the database file (default:
/app/data/f1.db) - Start the container
# Example with Docker
docker cp f1_backup_2025-03-15.db container_name:/app/data/f1.db
docker restart container_nameThe container includes a backup CLI tool for testing and manual operations:
# Show backup configuration (without sensitive data)
docker exec <container> backup info
# Test S3 connection and permissions
docker exec <container> backup test
# Perform backup immediately
docker exec <container> backup now# Show configuration
docker compose exec f1-eink-cal backup info
# Test connection
docker compose exec f1-eink-cal backup test
# Manual backup
docker compose exec f1-eink-cal backup now| Command | Description |
|---|---|
backup info |
Shows endpoint, bucket, region, schedule, retention (no secrets) |
backup test |
Tests credentials, bucket access, write permissions, shows latency and existing backups |
backup now |
Performs immediate backup + retention cleanup, shows upload progress |
backup info:
S3 Backup Configuration
========================================
Enabled: True
Endpoint: https://xxx.r2.cloudflarestorage.com
Bucket: f1-eink-backups
Region: auto
Schedule: 0 3 * * *
Retention: 30 days
Credentials: configured
backup test:
Testing S3 connection...
[OK] Credentials valid
[OK] Bucket accessible
[OK] Write permission confirmed
Connection latency: 45.2 ms
Bucket statistics:
Existing backups: 12
Total size: 1.2 MB
Oldest backup: 2025-11-25_03-00-00
Newest backup: 2025-12-24_03-00-00
Connection test PASSED
backup now:
Starting manual backup...
[OK] Database copied: 156.0 KB
[OK] Uploaded: f1_backup_2025-12-25_14-30-00.db
[OK] Cleanup: 2 old backup(s) deleted
Backup completed successfully.
The application includes a reset-db command for managing the SQLite database in Docker containers.
# Show database info and record counts (no changes)
docker exec <container> reset-db info
# Reset statistics only (api_calls, request_stats)
docker exec <container> reset-db stats
# Reset cache only (cache_meta, generated_images, BMP files)
docker exec <container> reset-db cache
# Delete entire database (will be recreated on next request)
docker exec <container> reset-db all# Show database info
docker compose exec f1-eink-cal reset-db info
# Reset statistics
docker compose exec f1-eink-cal reset-db stats| Command | Tables Affected | Also Deletes |
|---|---|---|
info |
None (read-only) | Nothing |
stats |
api_calls, request_stats |
Nothing |
cache |
cache_meta, generated_images |
BMP files in IMAGES_PATH |
all |
Entire database file | BMP files in IMAGES_PATH |
- All destructive commands require confirmation (
[y/N]prompt) - The database is automatically recreated on the next request after deletion
- Use
statsto clear analytics data while preserving cached images - Use
cacheto force regeneration of all BMP images
pip install -e ".[dev]"ruff check .
ruff format .pytestThe flag preprocessing script requires optional dependencies:
pip install -e .[dev]
python scripts/preprocess_flags.pyThe application uses a multi-tier caching strategy optimized for E-Ink displays that typically refresh every few hours.
Run the benchmark script to measure performance on your hardware:
python scripts/benchmark_renderer.pyTypical results on a 4-core VPS:
| Method | Avg Time | Throughput | Use Case |
|---|---|---|---|
| In-memory cache | ~0.0003 ms | ~3,000,000 req/s | Repeated requests within same process |
| Pre-generated file | ~0.04 ms | ~25,000 req/s | Popular language/timezone combinations |
| On-the-fly render | ~50 ms | ~20 req/s | Specific race or uncommon timezone |
| HTTP endpoint | ~55 ms | ~18 req/s | Full request cycle including overhead |
Memory usage: Each rendered BMP is ~47 KB (800×480 1-bit).
Request → In-Memory Cache → Pre-generated File → On-the-fly Render
↓ ↓ ↓
(instant) (~0.04ms) (~50ms)
- In-memory LRU cache - Stores recently served BMPs in memory
- Pre-generated files - Popular variants saved to disk by scheduler
- On-the-fly rendering - Fallback for specific races or rare timezones
The scheduler runs hourly and intelligently pre-generates BMP files based on actual usage patterns:
Always generated (defaults):
- All
(lang, display, weather)combinations for the next race in the default timezone - Example:
calendar_en.bmp,calendar_en_bwr.bmp,calendar_en_bwry_weather_current.bmp
Dynamically generated (based on popularity):
- Up to 20 additional timezone combinations based on the most popular
(language, timezone)pairs from the last 24 hours - Each selected timezone gets the full display/weather matrix for the next race
- Example:
calendar_en_America_New_York.bmp,calendar_cs_Europe_London_bwry_weather_race.bmp
Selection criteria:
- Queries
api_callstable for combinations with >10 requests in last 24h - Excludes default timezone (already covered by base files)
- Limits to 20 variants to control disk usage
Pre-generated files follow this pattern:
calendar_{lang}.bmp # Default timezone
calendar_{lang}_bwr.bmp # Default timezone, B/W/R display
calendar_{lang}_bwry.bmp # Default timezone, B/W/R/Y display
calendar_{lang}_spectra6.bmp # Default timezone, Spectra 6 display
calendar_{lang}_weather_current.bmp # Default timezone, current weather
calendar_{lang}_weather_race.bmp # Default timezone, race-day weather
calendar_{lang}_{tz_safe}.bmp # Specific timezone
calendar_{lang}_{tz_safe}_bwr.bmp # Specific timezone, B/W/R display
calendar_{lang}_{tz_safe}_bwry.bmp # Specific timezone, B/W/R/Y display
calendar_{lang}_{tz_safe}_spectra6.bmp # Specific timezone, Spectra 6 display
calendar_{lang}_{tz_safe}_weather_current.bmp # Specific timezone, current weather
calendar_{lang}_{tz_safe}_weather_race.bmp # Specific timezone, race-day weather
calendar_{lang}_{tz_safe}_bwr_weather_race.bmp # Specific timezone, B/W/R + race-day weather
calendar_{lang}_{tz_safe}_bwry_weather_current.bmp # Specific timezone, B/W/R/Y + current weather
configure_calendar_{lang}.png # Full-size configure preview (1bit)
configure_calendar_{lang}_bwr.png # Full-size configure preview (B/W/R)
configure_calendar_{lang}_bwry.png # Full-size configure preview (B/W/R/Y)
configure_calendar_{lang}_spectra6.png # Full-size configure preview (Spectra 6)
configure_teams_{lang}.png # Full-size teams configure preview (1bit)
configure_teams_{lang}_bwr.png # Full-size teams configure preview (B/W/R)
configure_teams_{lang}_bwry.png # Full-size teams configure preview (B/W/R/Y)
configure_teams_{lang}_spectra6.png # Full-size teams configure preview (Spectra 6)
Where {tz_safe} replaces / with _ in timezone names:
America/New_York→America_New_YorkEurope/London→Europe_London
The /calendar.bmp endpoint checks for pre-generated files before rendering:
-
Next race requests (no
year/roundparams):- Uses pre-generated BMPs only for the default timezone
- Checks the exact default-timezone variant for the requested
lang/display/weather_type - Renders on-the-fly for non-default timezones, even if timezone-specific BMPs were generated by the scheduler
- Renders on-the-fly if no pre-generated file exists
-
Specific race requests (
yearandroundparams):- Always renders on-the-fly (historical data not pre-generated)
# Basic benchmark (excludes HTTP test)
python scripts/benchmark_renderer.py
# Include HTTP endpoint test
python scripts/benchmark_renderer.py --http
# Custom number of iterations
python scripts/benchmark_renderer.py --runs 200
# Export results to JSON
python scripts/benchmark_renderer.py --json
# Verbose output with individual run times
python scripts/benchmark_renderer.py -vThe renderer automatically loads circuit track images from app/assets/tracks/. Images are matched by circuitId from the Jolpica API.
Name your track images using the circuitId:
{circuitId}.png
| circuitId | Circuit | Location |
|---|---|---|
albert_park |
Albert Park Grand Prix Circuit | Melbourne, Australia |
americas |
Circuit of the Americas | Austin, USA |
bahrain |
Bahrain International Circuit | Sakhir, Bahrain |
baku |
Baku City Circuit | Baku, Azerbaijan |
buddh |
Buddh International Circuit | Uttar Pradesh, India |
catalunya |
Circuit de Barcelona-Catalunya | Barcelona, Spain |
fuji |
Fuji Speedway | Oyama, Japan |
hockenheimring |
Hockenheimring | Hockenheim, Germany |
hungaroring |
Hungaroring | Budapest, Hungary |
imola |
Autodromo Enzo e Dino Ferrari | Imola, Italy |
indianapolis |
Indianapolis Motor Speedway | Indianapolis, USA |
interlagos |
Autódromo José Carlos Pace | São Paulo, Brazil |
istanbul |
Istanbul Park | Istanbul, Turkey |
jeddah |
Jeddah Corniche Circuit | Jeddah, Saudi Arabia |
losail |
Losail International Circuit | Lusail, Qatar |
madring |
Madring | Madrid, Spain |
magny_cours |
Circuit de Nevers Magny-Cours | Magny Cours, France |
marina_bay |
Marina Bay Street Circuit | Marina Bay, Singapore |
miami |
Miami International Autodrome | Miami, USA |
monaco |
Circuit de Monaco | Monte Carlo, Monaco |
monza |
Autodromo Nazionale di Monza | Monza, Italy |
mugello |
Autodromo Internazionale del Mugello | Mugello, Italy |
nurburgring |
Nürburgring | Nürburg, Germany |
portimao |
Autódromo Internacional do Algarve | Portimão, Portugal |
red_bull_ring |
Red Bull Ring | Spielberg, Austria |
ricard |
Circuit Paul Ricard | Le Castellet, France |
rodriguez |
Autódromo Hermanos Rodríguez | Mexico City, Mexico |
sepang |
Sepang International Circuit | Kuala Lumpur, Malaysia |
shanghai |
Shanghai International Circuit | Shanghai, China |
silverstone |
Silverstone Circuit | Silverstone, UK |
sochi |
Sochi Autodrom | Sochi, Russia |
spa |
Circuit de Spa-Francorchamps | Spa, Belgium |
suzuka |
Suzuka Circuit | Suzuka, Japan |
valencia |
Valencia Street Circuit | Valencia, Spain |
vegas |
Las Vegas Strip Street Circuit | Las Vegas, USA |
villeneuve |
Circuit Gilles Villeneuve | Montreal, Canada |
yas_marina |
Yas Marina Circuit | Abu Dhabi, UAE |
yeongam |
Korean International Circuit | Yeongam County, Korea |
zandvoort |
Circuit Park Zandvoort | Zandvoort, Netherlands |
Note: If no matching track image is found, the renderer uses a stylized placeholder.
- Python 3.14.3: Modern Python with type hints
- FastAPI: High-performance web framework
- Pillow: Image generation and manipulation
- HTTPX: Async HTTP client for API calls
- Sentry-SDK: Error tracking and monitoring
- pytz: Timezone handling
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Coolify Guide: COOLIFY.md
- Deployment Guide: DEPLOYMENT.md
- Contributing: CONTRIBUTING.md