personal activity index (bluesky, leaflet, substack) pai.desertthunder.dev
rss bluesky

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#

  1. Build binary:

    cargo build --release -p pai
    

    The binary will live at target/release/pai.

  2. 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).

  3. 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
    
  4. 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:4321 only allows that specific origin
  • Same-root-domain: https://desertthunder.dev also allows https://pai.desertthunder.dev, https://api.desertthunder.dev, etc.
  • Dev key: Requests with X-Local-Dev-Key header 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#

  1. Install nginx via your package manager (apt, dnf, brew, etc.).

  2. 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
    
  3. 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#

  1. Install Caddy (https://caddyserver.com/docs/install).
  2. Keep the same pai systemd 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 per source_kind). Ideal for load balancer health probes.
  • GET /api/feed?limit=1 ensures the server can read from SQLite and return real data.
  • GET /api/item/{id} is handy for debugging a specific record.
  • Consider wiring /status into 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#

  1. Cloudflare account with Workers enabled
  2. Wrangler CLI installed npx wrangler works here as well.
  3. Rust toolchain with wasm32-unknown-unknown target
  4. 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 template
  • schema.sql - D1 database schema
  • README.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 minutes
  • 0 */6 * * * - Every 6 hours
  • 0 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:4321 only allows that exact origin
    • Supports same-root-domain: https://desertthunder.dev also allows https://pai.desertthunder.dev, https://api.desertthunder.dev, etc.
  • CORS_DEV_KEY: Optional development key for local testing

    • When set, requests with the X-Local-Dev-Key header matching this value bypass origin checking
    • Useful for testing from different local ports during development

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 filters
  • GET /api/item/{id} - Get single item by ID
  • POST /api/sync - Manually trigger synchronization from all configured sources
  • GET /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.