Skip to content

Latest commit

 

History

History
442 lines (317 loc) · 15.9 KB

File metadata and controls

442 lines (317 loc) · 15.9 KB

Hosting

This document covers the infrastructure that runs Book Corners in production.

Architecture overview

Book Corners runs on a single Hetzner VPS managed by Dokku, a self-hosted PaaS that provides Heroku-like deployments using Docker containers.

┌─────────────────────────────────────────────────────┐
│  Hetzner VPS (ARM64)                                │
│                                                     │
│  ┌──────────┐  ┌──────────────┐  ┌──────────────┐  │
│  │  Dokku   │  │  PostgreSQL  │  │    nginx     │  │
│  │  (app)   │──│  + PostGIS   │  │  (reverse    │  │
│  │          │  │              │  │   proxy)     │  │
│  └──────────┘  └──────────────┘  └──────┬───────┘  │
│                                         │          │
└─────────────────────────────────────────┼──────────┘
                                          │
                              ┌───────────┴──────────┐
                              │  Cloudflare (proxy)  │
                              │  bookcorners.org     │
                              └──────────────────────┘

Components on the VPS:

  • Dokku — manages the app lifecycle, nginx config, and Docker containers
  • PostgreSQL 17 + PostGIS — via the Dokku Postgres plugin, using the imresamu/postgis image (ARM64-compatible)
  • nginx — reverse proxy managed by Dokku, terminates connections from Cloudflare
  • Let's Encrypt — TLS certificates via the Dokku letsencrypt plugin, auto-renewed

External services:

  • Cloudflare — DNS and CDN proxy for bookcorners.org and www.bookcorners.org
  • GitHub Pages — hosts the API docs at developers.bookcorners.org
  • Grafana Cloud — log aggregation via Loki (free tier), logs shipped by Dokku Vector
  • Sentry — error tracking (free developer plan)
  • UptimeRobot — uptime monitoring (free tier), checks /health/ every 5 minutes
  • Resend — transactional email for admin notifications (new submissions, reports)
  • Mastodon + Bluesky + Instagram — automated social media posting of approved libraries
  • BorgBase — offsite encrypted backup storage for database dumps and media files

VPS setup

Server requirements

  • Ubuntu 22.04 or 24.04
  • SSH access via a non-root deploy user with sudo privileges

Deploy user

A dedicated deploy user handles Dokku operations:

ssh root@vps.bookcorners.org
adduser deploy
usermod -aG sudo deploy

# Copy SSH key
mkdir -p /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys

Dokku installation

ssh deploy@vps.bookcorners.org

# Install Dokku (check https://dokku.com/docs/getting-started/installation/ for latest)
wget -NP . https://dokku.com/install/v0.36.6/bootstrap.sh
sudo DOKKU_TAG=v0.36.6 bash bootstrap.sh

# Set the global domain
sudo dokku domains:set-global bookcorners.org

# Add your SSH key for git push access
cat ~/.ssh/authorized_keys | sudo dokku ssh-keys:add admin

App creation

sudo dokku apps:create book-corners

DNS configuration (Cloudflare)

Three A records, all pointing to the VPS IP:

Record Target Proxy
bookcorners.org <VPS_IP> Proxied (orange cloud)
www.bookcorners.org <VPS_IP> Proxied (orange cloud)
vps.bookcorners.org <VPS_IP> DNS only (grey cloud)

The vps. subdomain bypasses Cloudflare and is used for SSH access and git push to Dokku.

Cloudflare SSL/TLS mode: Full (Strict) — Cloudflare verifies the Let's Encrypt certificate on the origin server.

Database

The Dokku Postgres plugin manages the database. The ARM64-compatible PostGIS image is used because the VPS runs on Hetzner Ampere (ARM64).

# Install the Postgres plugin
sudo dokku plugin:install https://github.com/dokku/dokku-postgres.git postgres

# Create the database with PostGIS support
sudo POSTGRES_IMAGE="imresamu/postgis" POSTGRES_IMAGE_VERSION="17-3.6-bookworm" dokku postgres:create book-corners-db

# Link it to the app (sets DATABASE_URL automatically)
sudo dokku postgres:link book-corners-db book-corners

Verify the link:

sudo dokku config:show book-corners | grep DATABASE_URL

Persistent storage

Dokku containers are ephemeral. Media uploads are stored on a mounted host directory that survives redeployments.

# Create the host directory
sudo mkdir -p /var/lib/dokku/data/storage/book-corners/media
sudo chown -R 32767:32767 /var/lib/dokku/data/storage/book-corners/media

# Mount it into the container
sudo dokku storage:mount book-corners /var/lib/dokku/data/storage/book-corners/media:/app/media

Django's MEDIA_ROOT resolves to /app/media inside the container, which maps to the persistent host path.

SSL / TLS

# Install the letsencrypt plugin
sudo dokku plugin:install https://github.com/dokku/dokku-letsencrypt.git

# Set the notification email
sudo dokku letsencrypt:set book-corners email your-email@example.com

# Enable SSL (app must be running first)
sudo dokku letsencrypt:enable book-corners

# Set up automatic renewal
sudo dokku letsencrypt:cron-job --add

After enabling Let's Encrypt, set Cloudflare SSL/TLS mode to Full (Strict).

Domains

sudo dokku domains:set book-corners bookcorners.org www.bookcorners.org

Environment variables

All production configuration is set via Dokku environment variables. The app reads these from the environment at runtime — the codebase is identical across environments.

sudo dokku config:set --no-restart book-corners \
  DJANGO_SECRET_KEY="<generated-secret-key>" \
  DJANGO_DEBUG="false" \
  DJANGO_ALLOWED_HOSTS="bookcorners.org,www.bookcorners.org" \
  DJANGO_CSRF_TRUSTED_ORIGINS="https://bookcorners.org,https://www.bookcorners.org" \
  DJANGO_SECURE_SSL_REDIRECT="true" \
  DJANGO_SESSION_COOKIE_SECURE="true" \
  DJANGO_CSRF_COOKIE_SECURE="true" \
  DJANGO_SECURE_HSTS_SECONDS="31536000" \
  NOMINATIM_USER_AGENT="bookcorners.org/1.0"

Use --no-restart before the first deploy to avoid restart errors when no container exists yet.

Google OAuth (optional)

sudo dokku config:set --no-restart book-corners \
  GOOGLE_OAUTH_CLIENT_ID="<your-client-id>" \
  GOOGLE_OAUTH_CLIENT_SECRET="<your-client-secret>"

Add the production redirect URI in Google Cloud Console:

  • Authorized origin: https://bookcorners.org
  • Redirect URI: https://bookcorners.org/accounts/google/login/callback/

Apple Sign In (optional)

sudo dokku config:set --no-restart book-corners \
  APPLE_CLIENT_ID="<your-services-id>" \
  APPLE_SECRET_KEY="<contents-of-.p8-file>" \
  APPLE_KEY_ID="<your-key-id>" \
  APPLE_TEAM_ID="<your-team-id>"

Configure in the Apple Developer portal:

  • Domain: www.bookcorners.org
  • Return URL: https://www.bookcorners.org/accounts/apple/login/callback/

Social media posting (optional)

sudo dokku config:set book-corners \
  MASTODON_INSTANCE_URL="https://mastodon.social" \
  MASTODON_ACCESS_TOKEN="<your-access-token>" \
  BLUESKY_HANDLE="bookcorners.bsky.social" \
  BLUESKY_APP_PASSWORD="<your-app-password>" \
  INSTAGRAM_USER_ID="<your-instagram-user-id>" \
  INSTAGRAM_ACCESS_TOKEN="<your-long-lived-token>"

Credentials can be added independently — if only one platform is configured, the others are silently skipped.

Instagram token refresh: The long-lived token expires after ~60 days. A cron job runs refresh_instagram_token every 30 days to keep it valid. After the first refresh, the token is stored in the database and the env var is only used as a fallback.

Updating the Instagram token after re-authorization: If you re-authorize the Meta app (e.g. to add new permissions like instagram_manage_comments), exchange the new short-lived token for a long-lived one via the Graph API, then store it in the database:

sudo dokku run book-corners python manage.py set_instagram_token "NEW_LONG_LIVED_TOKEN"

The command validates the token against the Graph API before storing it. Use --skip-validation for offline scenarios. The DB token always takes precedence over the INSTAGRAM_ACCESS_TOKEN env var.

Email notifications (optional)

sudo dokku config:set book-corners \
  RESEND_API_KEY="<your-resend-api-key>" \
  ADMIN_NOTIFICATION_EMAIL="<admin-email-address>"

Admin notifications are sent when new libraries are submitted. Falls back to console output if not configured.

Sentry error tracking (optional)

sudo dokku config:set book-corners \
  SENTRY_DSN="<your-sentry-dsn>"

Sentry is on the free developer plan. Events are dropped when quota is exhausted, not billed.

Generate a secret key

python3 -c "import secrets; print(secrets.token_urlsafe(50))"

View current configuration

sudo dokku config:show book-corners

Key paths on the VPS

What Path
Dokku app (bare git repo) /home/dokku/book-corners/
Backup/restore scripts (deployed by CI) /home/deploy/backup.sh, /home/deploy/restore.sh
Media files (mounted into container) /var/lib/dokku/data/storage/book-corners/media/
Postgres data (managed by plugin) /var/lib/dokku/services/postgres/book-corners-db/
Backup log /var/log/book-corners-backup.log

Useful commands

# View app logs
sudo dokku logs book-corners --tail

# Check app status
sudo dokku ps:report book-corners

# Run a Django management command
sudo dokku run book-corners python manage.py <command>

# Open a Django shell
sudo dokku run book-corners python manage.py shell

Post-deploy setup (one-time)

After the first successful deploy:

# Create a superuser
sudo dokku run book-corners python manage.py createsuperuser

# Fix the Django Sites framework domain (used by allauth)
sudo dokku run book-corners python manage.py shell -c "
from django.contrib.sites.models import Site
site = Site.objects.get(id=1)
site.domain = 'bookcorners.org'
site.name = 'Book Corners'
site.save()
print(f'Site updated: {site.domain}')
"

Log aggregation (Grafana Cloud Loki)

Production logs are shipped to Grafana Cloud Loki via Dokku's built-in Vector integration. Logs can be queried from the Grafana Cloud UI or locally with LogCLI.

How it works

Dokku containers → Vector (sidecar) → Grafana Cloud Loki
                                            ↑
                                     LogCLI / Grafana UI

Django uses structlog for structured logging — JSON output in production, pretty console in development. Existing stdlib loggers (Django internals, third-party packages) pass through the same pipeline.

Credentials

Two Grafana Cloud API tokens are required (generate from Grafana Cloud → Security → API Keys):

Token Purpose Used by
Write Push logs to Loki Vector on VPS
Read Query logs from Loki LogCLI on local machine
  • Loki URL: https://logs-prod-012.grafana.net
  • User ID: 1502192

Neither token is stored in the repository. The write token is configured on the VPS via the setup script; the read token goes in your local shell environment.

VPS setup

A setup script is provided at scripts/setup_loki.sh. After deploying, extract it from the app container and run:

ssh deploy@vps.bookcorners.org
sudo dokku run book-corners cat scripts/setup_loki.sh > ~/setup_loki.sh
sed -i 's/\r$//' ~/setup_loki.sh
bash ~/setup_loki.sh <LOKI_WRITE_TOKEN>

The sed step strips carriage returns that Docker's cat may add. The script validates the token with a direct Loki push (expects HTTP 204), clears stale sink config, sets an app-level vector-sink for book-corners, and restarts Vector. The DSN keeps auth[user] and auth[password] quoted to avoid Vector parsing the user ID as an integer.

Verify Vector is running:

sudo dokku logs:vector-logs

Querying logs locally (LogCLI)

Install LogCLI:

brew install grafana/tap/logcli

Configure your shell (add to ~/.zshrc or .envrc):

export LOKI_ADDR=https://logs-prod-012.grafana.net
export LOKI_USERNAME=1502192
export LOKI_PASSWORD=<LOKI_READ_TOKEN>

Example queries:

# Recent app logs
logcli query '{app="book-corners"}' --limit 50

# Errors only (structured JSON logs)
logcli query '{app="book-corners"} | json | level="error"'

# Search for specific text
logcli query '{app="book-corners"} |= "DisallowedHost"' --since 24h

# Tail logs live
logcli query '{app="book-corners"}' --tail

You can also explore logs in the Grafana Cloud UI at Explore → Loki.

Monitoring

  • UptimeRobot monitors https://bookcorners.org/health/ every 5 minutes
  • Sentry error tracking (free developer plan) — reports unhandled exceptions and API 500s
  • Grafana Cloud Loki — centralized log aggregation, queryable via LogCLI or Grafana UI

Cost sheet

Assumptions:

  • One public Hetzner VPS, Dokku-managed services on same host, no staging.
  • PostgreSQL/PostGIS, app, and reverse proxy run on the VPS.
  • Cloudflare for domain registration and DNS.

Baseline recurring costs

Item Required now? Est. monthly Est. yearly Notes
Hetzner VPS (small instance) Yes $5–8 $60–96 App + DB + Dokku on one server
Domain (Cloudflare Registrar) Yes $0.7–1.5 $8–18 Depends on TLD, billed yearly
SSL (Let's Encrypt) Yes $0 $0 Free certs
Dokku Yes $0 $0 Open source
PostgreSQL + PostGIS (self-hosted) Yes $0 $0 Included in VPS cost
BorgBase backup storage Yes (recommended) $0–8* $0–96* Reuse existing plan if possible
Uptime monitoring (UptimeRobot free tier) Recommended $0 $0 Paid tier optional later
Error tracking (Sentry developer plan) Recommended $0 $0 Quota-limited free usage
Transactional email (Resend free tier) Yes $0 $0 3k emails/month free, admin notifications

* If you already have BorgBase capacity, incremental cost can be near zero.

Optional costs (only when needed)

Item Est. monthly Notes
Transactional email provider upgrade $0–20 Only if free tier limits are exceeded or user-facing emails are needed
Paid uptime monitoring upgrade $7–20+ Only if free tier limits become restrictive
Paid error monitoring upgrade $26+ Only if free Sentry quotas are consistently exceeded

Practical budget ranges

  • Lean MVP baseline: ~$6–10/month (VPS + domain amortized, free monitoring, existing backup capacity).
  • Safer baseline with paid backup headroom: ~$10–18/month.
  • With optional paid monitoring/email upgrades: ~$20–45+/month.

Cost control rules

  • Keep Sentry on free plan and do not enable paid add-ons by default.
  • Use free-tier uptime monitoring until false positives or feature limits justify upgrades.
  • Keep Resend on free tier; admin notifications are low volume.
  • Review storage growth monthly (DB dumps + media) to avoid surprise backup costs.

Quota behavior notes

  • Sentry free plan is quota-based: when quota is exhausted, new events are dropped/rejected.
  • Existing accepted events remain available according to plan retention window.