This document covers the infrastructure that runs Book Corners in production.
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/postgisimage (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.organdwww.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
- Ubuntu 22.04 or 24.04
- SSH access via a non-root deploy user with sudo privileges
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_keysssh 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 adminsudo dokku apps:create book-cornersThree 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.
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-cornersVerify the link:
sudo dokku config:show book-corners | grep DATABASE_URLDokku 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/mediaDjango's MEDIA_ROOT resolves to /app/media inside the container, which maps to the persistent host path.
# 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 --addAfter enabling Let's Encrypt, set Cloudflare SSL/TLS mode to Full (Strict).
sudo dokku domains:set book-corners bookcorners.org www.bookcorners.orgAll 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.
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/
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/
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.
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.
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.
python3 -c "import secrets; print(secrets.token_urlsafe(50))"sudo dokku config:show book-corners| 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 |
# 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 shellAfter 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}')
"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.
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.
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.
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-logsInstall LogCLI:
brew install grafana/tap/logcliConfigure 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"}' --tailYou can also explore logs in the Grafana Cloud UI at Explore → Loki.
- 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
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.
| 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.
| 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 |
- 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.
- 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.
- 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.