Personal Activity Index – Deployment Guide#
This guide walks through two common reverse proxy setups for pai serve: nginx and Caddy.
Both sections include native (host binary) instructions and optional Docker paths if you prefer containerized deployments.
Table of Contents#
- Prerequisites
- nginx Deployment
- Caddy Deployment
- Health Checks & Monitoring
- Cloudflare Worker Deployment
Prerequisites#
-
Build binary:
cargo build --release -p paiThe binary will live at
target/release/pai. -
Prepare a configuration + database location. The default locations follow the XDG spec, but you can override them with
-C(config dir) and-d(database path). -
Run a sync at least once so the database has data:
./target/release/pai sync -C /etc/pai -d /var/lib/pai/pai.db -a -
Start the server (example binds to localhost so the proxy terminates TLS):
./target/release/pai serve -d /var/lib/pai/pai.db -a 127.0.0.1:8080
CORS Configuration for Self-Hosted Server#
The HTTP server supports CORS configuration via config.toml. Add a [cors] section:
[cors]
# List of allowed origins for cross-origin requests
allowed_origins = ["https://desertthunder.dev", "http://localhost:4321"]
# Optional development key for local testing
dev_key = "your-secret-dev-key"
CORS features:
- Exact matching:
http://localhost:4321only allows that specific origin - Same-root-domain:
https://desertthunder.devalso allowshttps://pai.desertthunder.dev,https://api.desertthunder.dev, etc. - Dev key: Requests with
X-Local-Dev-Keyheader matching the configured key bypass origin checks
The PAI server handles CORS automatically - no additional proxy configuration needed. See README.md for details.
nginx Deployment#
Host Setup#
-
Install nginx via your package manager (
apt,dnf,brew, etc.). -
Create a systemd service for
pai(optional but recommended):[Unit] Description=Personal Activity Index After=network.target [Service] ExecStart=/usr/local/bin/pai serve -d /var/lib/pai/pai.db -a 127.0.0.1:8080 Restart=on-failure User=pai Group=pai WorkingDirectory=/var/lib/pai [Install] WantedBy=multi-user.target -
Enable and start it:
sudo systemctl daemon-reload sudo systemctl enable --now pai.service
nginx Config#
Create /etc/nginx/conf.d/pai.conf:
server {
listen 80;
server_name pai.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Reload nginx: sudo nginx -s reload.
Optional: nginx via Docker#
Use an nginx image + bind-mount config:
services:
pai:
image: ghcr.io/your-namespace/pai:latest
command: ["serve", "-d", "/data/pai.db", "-a", "0.0.0.0:8080"]
volumes:
- ./data:/data
expose:
- "8080"
nginx:
image: nginx:1.27
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "80:80"
depends_on:
- pai
nginx.conf should proxy to http://pai:8080 instead of localhost.
Caddy Deployment#
Host Setup#
- Install Caddy (https://caddyserver.com/docs/install).
- Keep the same
paisystemd service from above (or run manually).
Caddyfile Example#
Create /etc/caddy/Caddyfile:
pai.example.com {
reverse_proxy 127.0.0.1:8080
encode gzip zstd
header {
Referrer-Policy "no-referrer-when-downgrade"
X-Content-Type-Options "nosniff"
}
}
Caddy automatically provisions TLS certificates with Let’s Encrypt. Reload with sudo systemctl reload caddy.
Optional: Caddy + Docker Compose#
services:
pai:
image: ghcr.io/your-namespace/pai:latest
command: ["serve", "-d", "/data/pai.db", "-a", "0.0.0.0:8080"]
volumes:
- ./data:/data
expose:
- "8080"
caddy:
image: caddy:2
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
ports:
- "80:80"
- "443:443"
depends_on:
- pai
volumes:
caddy_data:
caddy_config:
Use the same Caddyfile contents as above, but point reverse_proxy to pai:8080.
Health Checks & Monitoring#
GET /status– lightweight JSON (status, version, uptime, total items, counts persource_kind). Ideal for load balancer health probes.GET /api/feed?limit=1ensures the server can read from SQLite and return real data.GET /api/item/{id}is handy for debugging a specific record.- Consider wiring
/statusinto nginx/Caddy health checks (/healthz) or your platform’s monitoring agents.
Cloudflare Worker Deployment#
The Personal Activity Index can also be deployed as a Cloudflare Worker with D1 database, providing a serverless alternative to self-hosting.
Prerequisites#
- Cloudflare account with Workers enabled
- Wrangler CLI installed
npx wranglerworks here as well. - Rust toolchain with
wasm32-unknown-unknowntarget - Crate
worker-build
Quick Start#
1. Generate Scaffolding#
Use the pai cf-init command to generate Cloudflare Worker configuration:
# Dry run to preview files
pai cf-init --dry-run -o cloudflare-deployment
# Create scaffolding
pai cf-init -o cloudflare-deployment
cd cloudflare-deployment
This creates:
wrangler.example.toml- Worker configuration templateschema.sql- D1 database schemaREADME.md- Deployment instructions
2. Create D1 Database#
wrangler d1 create personal-activity-db
Copy the database ID from the output and update wrangler.example.toml:
[[d1_databases]]
binding = "DB"
database_name = "personal-activity-db"
database_id = "your-database-id-here" # Replace with returned database_id
Then copy to the active config:
cp wrangler.example.toml wrangler.toml
3. Initialize Database Schema#
wrangler d1 execute personal-activity-db --remote --file=schema.sql
Note that you can omit --remote for local development.
4. Build and Deploy#
# Build the worker
cd ..
cargo install worker-build
worker-build --release worker
5. Patch Generated Code#
The worker-build output requires two patches for compatibility with wrangler:
# 1. Fix import syntax (remove 'source' keyword)
sed -i.bak 's/import source wasmModule/import wasmModule/' worker/build/index.js
# 2. Add default export for ES module format (required for D1 bindings)
echo -e "\nexport default { fetch, scheduled };" >> worker/build/index.js
On macOS, use sed -i '' ... instead of sed -i.bak ....
6. Deploy#
cd cloudflare-deployment
wrangler deploy
Cron Triggers#
The worker includes a scheduled event handler for automatic syncing. Configure the schedule in wrangler.toml:
[triggers]
crons = ["0 * * * *"] # Every hour at minute 0
Common schedules:
*/30 * * * *- Every 30 minutes0 */6 * * *- Every 6 hours0 0 * * *- Daily at midnight
Environment Variables#
Configure sources in wrangler.toml under [vars]:
[vars]
# Substack RSS feed URL
SUBSTACK_URL = "https://patternmatched.substack.com"
# Bluesky handle
BLUESKY_HANDLE = "desertthunder.dev"
# Leaflet publications (comma-separated id:url pairs)
LEAFLET_URLS = "desertthunder:https://desertthunder.leaflet.pub,stormlightlabs:https://stormlightlabs.leaflet.pub"
# BearBlog publications (comma-separated id:url pairs)
BEARBLOG_URLS = "desertthunder:https://desertthunder.bearblog.dev"
# CORS configuration (optional)
CORS_ALLOWED_ORIGINS = "https://desertthunder.dev,http://localhost:4321"
CORS_DEV_KEY = "your-secret-dev-key"
CORS Configuration#
The Worker supports CORS to allow cross-origin requests from your web applications.
Environment Variables#
Add to wrangler.toml under [vars]:
-
CORS_ALLOWED_ORIGINS: Comma-separated list of allowed origins
- Supports exact matching:
http://localhost:4321only allows that exact origin - Supports same-root-domain:
https://desertthunder.devalso allowshttps://pai.desertthunder.dev,https://api.desertthunder.dev, etc.
- Supports exact matching:
-
CORS_DEV_KEY: Optional development key for local testing
- When set, requests with the
X-Local-Dev-Keyheader matching this value bypass origin checking - Useful for testing from different local ports during development
- When set, requests with the
Example Configuration#
[vars]
# Allow requests from your main domain and localhost for development
CORS_ALLOWED_ORIGINS = "https://desertthunder.dev,http://localhost:4321"
# Dev key for local Astro development
CORS_DEV_KEY = "local-dev-secret-123"
Usage from JavaScript#
// Production request from https://desertthunder.dev
fetch('https://pai.desertthunder.dev/api/feed', {
credentials: 'include'
})
// Development request from http://localhost:4321
fetch('http://localhost:8787/api/feed', {
headers: {
'X-Local-Dev-Key': 'local-dev-secret-123'
}
})
Same-Root-Domain Support#
When you configure CORS_ALLOWED_ORIGINS = "https://desertthunder.dev":
- ✓
https://desertthunder.dev(exact match) - ✓
https://pai.desertthunder.dev(subdomain) - ✓
https://api.desertthunder.dev(subdomain) - ✗
https://evil.dev(different root domain)
This allows you to deploy the Worker to pai.desertthunder.dev and access it from your main site at desertthunder.dev without explicitly listing every subdomain.
API Endpoints#
The Worker exposes the following API:
GET /- API documentation (JSON)GET /api/feed?source_kind=bluesky&limit=20- List items with optional filtersGET /api/item/{id}- Get single item by IDPOST /api/sync- Manually trigger synchronization from all configured sourcesGET /status- Health check and version info
Local Development#
Test the worker locally before deploying:
wrangler dev
This starts a local server at http://localhost:8787 with live reload.
Monitoring#
View logs in real-time:
wrangler tail
Or check logs in the Cloudflare Dashboard under Workers & Pages.