FastAPI backend for Strava activity tracking with OAuth, cached statistics, and encrypted token storage.
- OAuth 2.0 integration with automatic token refresh
- Full historic activity storage for detailed aggregation
- Hourly cached statistics (YTD, recent activities, monthly aggregates)
- Encrypted tokens at rest (Fernet AES-128)
- CSRF-protected OAuth flow (128-bit HMAC)
- Race-condition-free database operations
- CORS configured for frontend integration
- Docker & Docker Compose: Version 24.0+ and 2.20+ respectively
- Domain with SSL: Required for production OAuth (Strava requires HTTPS callbacks)
- Strava API Application: Register at https://www.strava.com/settings/api
- Go to https://www.strava.com/settings/api
- Create a new application with these settings:
- Application Name: Your app name (e.g., "My Activity Tracker")
- Category: Choose appropriate category
- Website: Your frontend URL (e.g.,
https://yourdomain.com) - Authorization Callback Domain: Your API domain (e.g.,
api.yourdomain.com)- For local development:
localhost
- For local development:
- Save the application and note your:
- Client ID (visible on the page)
- Client Secret (click "Show" to reveal)
git clone https://github.com/vuhnger/backend.git
cd backend
cp .env.example .envEdit .env with your settings:
# Environment Mode
ENVIRONMENT=production # Use 'development' for local testing
# Database Configuration
# IMPORTANT: Generate a secure password (see command below)
POSTGRES_USER=backend_user
POSTGRES_PASSWORD=<GENERATE_SECURE_PASSWORD>
POSTGRES_DB=backend_db
# Security Keys (REQUIRED in production)
# These protect OAuth state, API access, and stored tokens
INTERNAL_API_KEY=<GENERATE_KEY> # Protects /refresh-data endpoint
STATE_SECRET=<GENERATE_KEY> # Signs OAuth state tokens (CSRF protection)
ENCRYPTION_KEY=<GENERATE_KEY> # Encrypts stored OAuth tokens (AES-128)
# Strava OAuth Credentials (from Step 1)
STRAVA_CLIENT_ID=<your_client_id_from_strava>
STRAVA_CLIENT_SECRET=<your_client_secret_from_strava>
STRAVA_REDIRECT_URI=https://api.yourdomain.com/strava/callback
# For local dev: http://localhost:5001/strava/callback
# Frontend CORS (optional)
FRONTEND_URL=https://yourdomain.com
# For local dev: http://localhost:5173Generate secure keys by running this command 4 times (once for each secret):
python3 -c "import secrets; print(secrets.token_urlsafe(32))"Use the output to replace:
<GENERATE_SECURE_PASSWORD>→ Database password<GENERATE_KEY>→ INTERNAL_API_KEY<GENERATE_KEY>→ STATE_SECRET<GENERATE_KEY>→ ENCRYPTION_KEY
Important: Each key must be unique. Never reuse keys across variables or environments.
If deploying to production, set up your server first:
-
DNS Setup: Point your API subdomain to your server
# Example DNS A record api.yourdomain.com → <server-ip> (your server IP)
-
SSL Certificate: Install SSL on your server (required for OAuth)
Option A: Using Caddy (Recommended)
# Caddy automatically handles SSL with Let's Encrypt # Configure Caddyfile: api.yourdomain.com { reverse_proxy localhost:5001 }
Option B: Using Nginx + Certbot
sudo apt update sudo apt install nginx certbot python3-certbot-nginx sudo certbot --nginx -d api.yourdomain.com
-
Firewall: Ensure ports 80 and 443 are open
sudo ufw allow 80/tcp sudo ufw allow 443/tcp
ssh user@your-server
cd ~
git clone https://github.com/vuhnger/backend.git
cd backend
cp .env.example .env
nano .env # Edit with your production valuesThis step creates the PostgreSQL database with your password for the first time.
# Start all services
docker compose up -d --build
# Wait ~10 seconds for database to initialize
# Verify database is running
docker compose exec db pg_isready -U backend_user
# Expected output: "db:5432 - accepting connections"
# Check API logs for errors
docker compose logs strava-api
# Look for: "Application startup complete"
# Verify services are up
docker compose ps
# All services should show "Up"Common startup issues:
- If database fails to start, check
docker compose logs db - If API can't connect, verify
POSTGRES_PASSWORDmatches in.env - If port conflicts, check nothing else is using port 5001
This exchanges your Strava credentials for access tokens.
-
Navigate to authorization URL:
- Production:
https://api.yourdomain.com/strava/authorize - Local dev:
http://localhost:5001/strava/authorize
- Production:
-
Authorize the application on Strava's page
-
Verify success:
- You'll be redirected to your frontend with
?strava=success - Check API logs:
docker compose logs strava-api --tail 20 - You should see: "All Strava data cached successfully"
- You'll be redirected to your frontend with
-
Test the API:
curl https://api.yourdomain.com/strava/health # Expected: {"status":"ok","service":"strava","database":"connected"} curl https://api.yourdomain.com/strava/stats/ytd # Expected: JSON with your year-to-date stats
The API caches your Strava data to reduce API calls. Set up a cron job to refresh it hourly.
# Open crontab editor
crontab -e
# Add this line (runs every hour at :00)
0 * * * * docker exec backend-strava-api-1 python3 -m apps.strava.tasks >> /var/log/strava.log 2>&1What this does:
- Fetches fresh data from Strava API every hour
- Updates cached YTD stats, recent activities, and monthly aggregates
- Logs output to
/var/log/strava.logfor debugging
Alternative: Manually refresh anytime:
curl -X POST https://api.yourdomain.com/strava/refresh-data \
-H "X-API-Key: your-INTERNAL_API_KEY"Error: password authentication failed for user "backend_user"
Cause: The database volume was created with a different password than what's currently in your .env file. This commonly happens when:
- You changed
POSTGRES_PASSWORDafter first run - You copied
.envfrom another environment - The database volume persisted from a previous installation
Fix (
# Stop all services
docker compose down
# Remove the database volume (THIS DELETES ALL DATA)
docker volume rm backend_postgres_data
# Update .env with NEW password
nano .env # Set POSTGRES_PASSWORD to a new secure value
# Recreate with new password
docker compose up -d --build
# Wait for startup, then re-authorize OAuth
# Visit: https://api.yourdomain.com/strava/authorizePreserve Data Fix (if you have important data):
# Option 1: Keep using the old password
# Just revert POSTGRES_PASSWORD in .env to the original value
# Option 2: Change password inside running container
docker compose exec db psql -U backend_user -d postgres -c \
"ALTER USER backend_user WITH PASSWORD 'your-new-password';"
# Then update .env to match and restart
docker compose restart strava-apiSymptoms: docker compose ps shows container as "Exit 1" or constantly restarting
Debug steps:
# Check API logs for Python errors
docker compose logs strava-api --tail 100
# Check database logs
docker compose logs db --tail 50
# Verify database connection
docker compose exec db psql -U backend_user -d backend_db -c "SELECT 1"
# Check environment variables are loaded
docker compose exec strava-api env | grep STRAVA
# Verify database tables exist
docker compose exec db psql -U backend_user -d backend_db -c "\dt"Common causes:
- Missing environment variables → Check all required vars in
.env - Database not ready → Wait 10-15 seconds after
docker compose up - Port conflict → Check if port 5001 is already in use
- Invalid encryption key → Must be valid base64 from
secrets.token_urlsafe(32)
Error: "Invalid or expired state parameter" or redirect fails
Fixes:
-
Verify redirect URI matches exactly:
# In .env STRAVA_REDIRECT_URI=https://api.yourdomain.com/strava/callback # Must match Strava app settings (including https://) # Go to: https://www.strava.com/settings/api # Authorization Callback Domain: api.yourdomain.com
-
Check STATE_SECRET is set:
docker compose exec strava-api env | grep STATE_SECRET # Should output a long random string
-
Clear browser cookies and try authorization again
-
Check logs for detailed error:
docker compose logs strava-api --tail 20
Error: {"detail": "YTD stats not cached yet. Try /strava/refresh-data"}
Cause: OAuth completed but initial data fetch failed
Fix:
# Manually trigger data fetch
curl -X POST https://api.yourdomain.com/strava/refresh-data \
-H "X-API-Key: your-INTERNAL_API_KEY"
# Check if it worked
curl https://api.yourdomain.com/strava/stats/ytd
# If still failing, check logs
docker compose logs strava-api --tail 50Error: OAuth redirect fails or shows "Not Secure" warning
Fix:
# Verify SSL certificate is installed
sudo certbot certificates
# Check Caddy/Nginx is proxying correctly
curl -I https://api.yourdomain.com/strava/health
# Should return: HTTP/2 200
# Verify Strava app uses HTTPS callback
# In Strava app settings:
# ✓ https://api.yourdomain.com/strava/callback
# ✗ http://api.yourdomain.com/strava/callbackReset everything (
docker compose down -v # Removes all volumes
docker compose up -d --build
# Re-authorize OAuthList volumes:
docker volume ls | grep backendInspect volume:
docker volume inspect backend_postgres_dataThis API implements an hourly cache layer and full activity synchronization between your frontend and Strava's API.
Frontend Request → API Cache/Database → Strava API (if sync needed)
↓
Database (Full History)
-
Initial OAuth: When you authorize, the API performs a full sync:
- Fetches and stores all historic activities in
strava_activitiestable - Calculates year-to-date totals (runs + rides)
- Caches last 30 activities
- Caches monthly aggregates (12 months)
- Fetches and stores all historic activities in
-
Subsequent Requests: Frontend gets instant responses from the database. Use
/strava/activitiesfor custom aggregations. -
Automatic Refresh: Cron job updates cache and syncs new activities every hour.
-
Manual Refresh: Use
/strava/refresh-dataendpoint anytime.
- Database storage: Full history in
strava_activities, aggregated stats instrava_stats - Refresh frequency: Hourly (via cron) or manual
- Token refresh: Automatic when tokens expire (handled transparently)
- Strava Rate Limits: 100 requests per 15 minutes, 1000 per day
- Response Speed: Database queries are 10-100x faster than API calls
- Detailed Aggregates: Storing individual activities allows for custom metrics (like "Longest Run") without re-fetching from Strava.
All endpoints available at https://api.yourdomain.com
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/strava/health |
GET | None | Health check endpoint |
/strava/authorize |
GET | None | Start OAuth flow (redirects to Strava) |
/strava/callback |
GET | None | OAuth callback (Strava redirects here) |
/strava/stats/ytd |
GET | None | Year-to-date run + ride totals |
/strava/stats/longest-run |
GET | None | Longest run for a year (default: current) |
/strava/stats/longest-ride |
GET | None | Longest ride for a year (default: current) |
/strava/stats/totals |
GET | None | All-time totals grouped by type |
/strava/stats/yearly |
GET | None | Yearly totals grouped by type |
/strava/stats/activities |
GET | None | Recent 30 activities (from cache) |
/strava/activities |
GET | None | All activities with filtering and pagination |
/strava/stats/monthly |
GET | None | Monthly aggregates (last 12 months) |
/strava/refresh-data |
POST | API Key | Manually trigger data refresh and full sync |
Interactive API Docs: https://api.yourdomain.com/docs (Swagger UI)
All stats endpoints return:
{
"type": "ytd|recent_activities|monthly",
"data": { /* stats data */ },
"fetched_at": "2025-12-25T12:00:00+00:00"
}Units: Distance (meters), Time (seconds), Elevation (meters)
YTD Stats (/strava/stats/ytd):
{
"type": "ytd",
"data": {
"run": {
"count": 42,
"distance": 312500.0,
"moving_time": 54000,
"elevation_gain": 1250.0
},
"ride": {
"count": 15,
"distance": 450000.0,
"moving_time": 72000,
"elevation_gain": 2100.0
}
},
"fetched_at": "2025-12-25T12:00:00+00:00"
}Recent Activities (/strava/stats/activities):
{
"type": "recent_activities",
"data": [
{
"id": 12345678,
"name": "Morning Run",
"type": "Run",
"distance": 5000.0,
"moving_time": 1800,
"elevation_gain": 50.0,
"start_date": "2025-12-25T08:00:00Z"
}
],
"fetched_at": "2025-12-25T12:00:00+00:00"
}Monthly Stats (/strava/stats/monthly):
{
"type": "monthly",
"data": {
"2025-12": {
"count": 12,
"distance": 85000.0,
"moving_time": 14400,
"elevation_gain": 420.0
},
"2025-11": { /* ... */ }
},
"fetched_at": "2025-12-25T12:00:00+00:00"
}// Fetch YTD stats
const response = await fetch('https://api.yourdomain.com/strava/stats/ytd');
const { data, fetched_at } = await response.json();
console.log(`Runs: ${data.run.count}`);
console.log(`Distance: ${(data.run.distance / 1000).toFixed(2)} km`);
console.log(`Time: ${(data.run.moving_time / 3600).toFixed(1)} hours`);interface StravaStats {
count: number;
distance: number; // meters
moving_time: number; // seconds
elevation_gain: number;
}
interface YTDResponse {
type: 'ytd';
data: { run: StravaStats; ride: StravaStats };
fetched_at: string;
}function useStravaStats<T>(endpoint: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`https://api.yourdomain.com${endpoint}`)
.then(res => res.json())
.then(setData)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [endpoint]);
return { data, loading, error };
}
// Usage
function YTDStats() {
const { data, loading, error } = useStravaStats<YTDResponse>('/strava/stats/ytd');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h2>This Year</h2>
<p>Runs: {data.data.run.count}</p>
<p>Distance: {(data.data.run.distance / 1000).toFixed(2)} km</p>
</div>
);
}The API accepts requests from:
https://vuhnger.devhttps://vuhnger.github.iohttp://localhost:5173(development)
Add more origins in apps/strava/main.py:
origins = [
"https://vuhnger.dev",
"https://your-new-domain.com",
]Before deploying to production, ensure you've completed all steps:
- Server provisioned with Docker 24.0+ and Docker Compose 2.20+
- Domain configured with DNS pointing to your server IP
- SSL certificate installed (Caddy or Certbot)
- Firewall rules set (ports 80, 443 open)
- Strava app created with correct callback domain
- Environment variables set in
.envwith production values:- All 4 security keys generated uniquely
-
ENVIRONMENT=production -
POSTGRES_PASSWORDis cryptographically secure -
STRAVA_REDIRECT_URIuses HTTPS - Strava credentials copied correctly
# On your production server
cd ~
git clone https://github.com/vuhnger/backend.git
cd backend
# Configure environment
cp .env.example .env
nano .env # Fill in all production values
# Start services
docker compose up -d --build
# Verify everything is running
docker compose ps
docker compose logs strava-api --tail 50
# Test health endpoint
curl https://api.yourdomain.com/strava/health
# Expected: {"status":"ok","service":"strava","database":"connected"}- Complete OAuth flow by visiting
/strava/authorize - Verify data caching - check
/strava/stats/ytdreturns data - Setup cron job for hourly refreshes (Step 7)
- Configure monitoring (optional, see below)
- Test frontend integration with your actual frontend app
- Setup database backups (see Database Backup section)
Basic Monitoring:
# Check service health
curl https://api.yourdomain.com/strava/health
# Monitor logs in real-time
docker compose logs -f strava-api
# Check resource usage
docker stats
# Database connections
docker compose exec db psql -U backend_user -d backend_db -c \
"SELECT count(*) FROM pg_stat_activity WHERE datname='backend_db';"Log Rotation (prevent disk space issues):
# Create logrotate config
sudo nano /etc/logrotate.d/strava-backend
# Add:
/var/log/strava.log {
daily
rotate 7
compress
missingok
notifempty
}Uptime Monitoring:
- Use services like UptimeRobot, Pingdom, or StatusCake
- Monitor:
https://api.yourdomain.com/strava/health - Alert if status code ≠ 200 or response doesn't contain
"status":"ok"
# On your server
cd ~/backend
git pull origin main
docker compose up -d --build
# Verify deployment
docker compose ps
docker compose logs --tail 50 strava-api
curl https://api.yourdomain.com/strava/health
# Check data is still accessible
curl https://api.yourdomain.com/strava/stats/ytd# View recent commits
git log --oneline -10
# Reset to previous working commit
git reset --hard <commit-hash>
# Rebuild and restart
docker compose up -d --build
# Verify rollback worked
curl https://api.yourdomain.com/strava/healthFor critical updates without downtime:
# Pull latest code
git pull origin main
# Build new image without stopping services
docker compose build
# Rolling restart (stops old, starts new)
docker compose up -d --no-deps --build strava-api
# Verify
curl https://api.yourdomain.com/strava/healthBackup (run regularly, e.g., daily cron job):
# Create timestamped backup
docker compose exec db pg_dump -U backend_user backend_db \
> backup-$(date +%Y%m%d-%H%M%S).sql
# Compress to save space
gzip backup-*.sqlRestore (in case of data loss):
# Stop API to prevent writes during restore
docker compose stop strava-api
# Restore from backup
cat backup-20251225-120000.sql | \
docker compose exec -T db psql -U backend_user -d backend_db
# Restart API
docker compose start strava-api
# Re-authorize OAuth if tokens were affected
# Visit: https://api.yourdomain.com/strava/authorizeAutomated Daily Backups (recommended):
# Create backup script
cat > ~/backup-strava.sh << 'EOF'
#!/bin/bash
cd ~/backend
mkdir -p ~/backups
docker compose exec db pg_dump -U backend_user backend_db \
| gzip > ~/backups/strava-$(date +%Y%m%d).sql.gz
# Keep only last 30 days
find ~/backups -name "strava-*.sql.gz" -mtime +30 -delete
EOF
chmod +x ~/backup-strava.sh
# Add to crontab (runs daily at 3 AM)
crontab -e
# Add: 0 3 * * * ~/backup-strava.sh┌─────────────┐
│ Caddy │ HTTPS/SSL
│ :443 │
└──────┬──────┘
│
▼
┌─────────────┐ ┌──────────────┐
│ strava-api │────▶│ PostgreSQL │
│ :5001 │ │ :5432 │
└─────────────┘ └──────────────┘
│
▼
┌─────────────┐
│ Strava API │
│ (OAuth) │
└─────────────┘
- OAuth tokens encrypted at rest (Fernet AES-128)
- CSRF protection (128-bit HMAC-signed state tokens)
- API key auth for admin endpoints (constant-time comparison)
- Explicit database rollback on failures
- Race-condition-free atomic upserts
- CORS restricted to allowed origins
- Secrets required in production mode
| Variable | Required | Default | Description |
|---|---|---|---|
ENVIRONMENT |
No | development |
Runtime mode: production or development. Production enforces security key requirements. |
| Variable | Required | Example | Description |
|---|---|---|---|
POSTGRES_USER |
Yes | backend_user |
PostgreSQL username. Used by both API and database containers. |
POSTGRES_PASSWORD |
Yes | <generated> |
PostgreSQL password. CRITICAL: Must be set before first docker compose up. Generate with secrets.token_urlsafe(32). Changing this after database creation requires volume deletion (see Troubleshooting). |
POSTGRES_DB |
Yes | backend_db |
PostgreSQL database name. Created automatically on first startup. |
Internal Connection String (automatically constructed):
postgresql://backend_user:<POSTGRES_PASSWORD>@db:5432/backend_db
All security keys must be cryptographically secure random strings. Generate each with:
python3 -c "import secrets; print(secrets.token_urlsafe(32))"| Variable | Required | Length | Description |
|---|---|---|---|
INTERNAL_API_KEY |
Yes* | 32+ bytes | Protects admin endpoints (e.g., /strava/refresh-data). Include in requests as X-API-Key header. Uses constant-time comparison to prevent timing attacks. |
STATE_SECRET |
Yes* | 32+ bytes | HMAC signing key for OAuth state tokens. Prevents CSRF attacks by signing per-request state parameters with 128-bit HMAC. State tokens expire after 10 minutes. |
ENCRYPTION_KEY |
Yes* | 32+ bytes | Fernet (AES-128-CBC) encryption key for OAuth tokens at rest. Tokens are encrypted before database storage and decrypted on read. Never rotate this key without migrating existing tokens or you'll lose access. |
* Required when ENVIRONMENT=production. Optional in development (but recommended).
Security Notes:
- Each key must be unique (never reuse keys)
- Store keys securely (use environment variables, never commit to git)
- Rotation requires data migration (tokens encrypted with old keys become unreadable)
- Keys use URL-safe base64 encoding (A-Z, a-z, 0-9, -, _)
| Variable | Required | Example | Description |
|---|---|---|---|
STRAVA_CLIENT_ID |
Yes | 161983 |
Strava application client ID. Get from https://www.strava.com/settings/api after creating your app. |
STRAVA_CLIENT_SECRET |
Yes | a1b2c3d4... |
Strava application client secret. Click "Show" on Strava settings page to reveal. Keep secret - never expose in frontend code. |
STRAVA_REDIRECT_URI |
Yes | https://api.yourdomain.com/strava/callback |
OAuth callback URL. Must exactly match "Authorization Callback Domain" in Strava app settings. For local dev: http://localhost:5001/strava/callback. Must use HTTPS in production (Strava requirement). |
OAuth Flow:
- User visits
/strava/authorize - Redirected to Strava with
client_idandredirect_uri - User authorizes app
- Strava redirects back to
STRAVA_REDIRECT_URIwith authorization code - API exchanges code for access/refresh tokens using
client_secret - Tokens encrypted with
ENCRYPTION_KEYand stored in database
| Variable | Required | Default | Description |
|---|---|---|---|
FRONTEND_URL |
No | https://vuhnger.dev |
Frontend origin for CORS. After OAuth, users are redirected to {FRONTEND_URL}/?strava=success. Also added to CORS allowed origins. For local dev: http://localhost:5173. Multiple origins must be configured in code (see apps/strava/main.py:34). |
Development (ENVIRONMENT=development):
ENVIRONMENT=development
POSTGRES_PASSWORD=devpass123 # Simple password OK for local
# Security keys optional (but recommended for testing OAuth)
STRAVA_REDIRECT_URI=http://localhost:5001/strava/callback
FRONTEND_URL=http://localhost:5173Production (ENVIRONMENT=production):
ENVIRONMENT=production
POSTGRES_PASSWORD=<32-byte-random-string>
INTERNAL_API_KEY=<32-byte-random-string> # REQUIRED
STATE_SECRET=<32-byte-random-string> # REQUIRED
ENCRYPTION_KEY=<32-byte-random-string> # REQUIRED
STRAVA_REDIRECT_URI=https://api.yourdomain.com/strava/callback # MUST be HTTPS
FRONTEND_URL=https://yourdomain.com# View logs
docker compose logs -f strava-api
# Manual data refresh
docker compose exec strava-api python3 -m apps.strava.tasks
# Database shell
docker compose exec db psql -U backend_user -d backend_db
# Restart API
docker compose restart strava-api
# Clean rebuild
docker compose down
docker compose up -d --build
# Check resource usage
docker stats# Install dependencies
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Setup local PostgreSQL
createdb backend_db
psql backend_db < schema.sql
# Run locally
uvicorn apps.strava.main:app --reload --port 5001pytest
flake8 apps/ --max-line-length=120
mypy apps/ --ignore-missing-importsMIT