This document covers how code gets from a developer's machine to production, including the CI/CD pipeline, manual deployment, rollback procedures, and backups.
Every push to master that passes CI is automatically deployed to production via GitHub Actions. The pipeline:
- Runs the full test suite against a PostGIS service container
- Builds Tailwind CSS to verify frontend compilation
- If tests pass and the push is to
master, deploys to Dokku via git push
Dokku then:
- Builds a Docker image using the multi-stage
Dockerfile(CSS build + Python app) - Runs
collectstatic(inside the Dockerfile) - Runs
python manage.py migrate --noinput(fromapp.jsonpredeploy hook) - Starts the new container with gunicorn
- Runs the health check (
/health/) before routing traffic - Removes the old container
Runs on every push and pull request.
Test job:
- Spins up a PostGIS 17 service container
- Installs Python 3.14 + uv, Node.js 22
- Installs GIS system libraries (gdal, geos, proj)
- Builds Tailwind CSS (
npm ci && npm run build:css) - Runs the test suite (
uvx nox -s tests)
Deploy job (master only, after tests pass):
- Checks out the full git history
- Configures SSH with the Dokku deploy key
- Pushes to the Dokku remote
- Syncs ops scripts (
backup.sh,restore.sh) to the VPS via SCP - Purges the Cloudflare cache
Runs on pushes to master when docs or API files change. Exports the OpenAPI schema from a live Django instance, builds the documentation site with Zensical, and deploys to GitHub Pages at developers.bookcorners.org.
| Secret | Description |
|---|---|
DOKKU_SSH_PRIVATE_KEY |
SSH private key for pushing to Dokku |
DOKKU_HOST |
VPS hostname (e.g., vps.bookcorners.org) |
When CI/CD is unavailable or you need to deploy a hotfix:
# From your local machine, in the book-corners directory
git push dokku masterThe Dokku remote should already be configured:
# If not, add it:
git remote add dokku dokku@vps.bookcorners.org:book-corners# Watch build output in real-time during git push
# After deploy, check status on the VPS:
sudo dokku ps:report book-corners
sudo dokku logs book-corners --tailAfter every deploy, verify these manually or review in monitoring:
- Homepage loads with styles:
https://bookcorners.org/ - Static CSS returns 200:
https://bookcorners.org/static/css/app.css - Login page works:
https://bookcorners.org/login/ - Map page loads with markers:
https://bookcorners.org/map/ - Admin panel accessible:
https://bookcorners.org/admin/ - Health check passes:
https://bookcorners.org/health/
If a deploy introduces a bug, revert the commit on master and push again:
git revert HEAD
git push origin master
# CI will run tests and auto-deploy the revertFor an immediate rollback without waiting for CI:
git revert HEAD
git push dokku masterIf the container is misbehaving but the code is correct:
sudo dokku ps:rebuild book-corners# Push a specific commit to Dokku
git push dokku <commit-sha>:refs/heads/masterMigrations run automatically on every deploy via the app.json predeploy hook:
{
"scripts": {
"dokku": {
"predeploy": "python manage.py migrate --noinput"
}
}
}If a migration fails, the deploy is aborted and the previous container keeps running.
sudo dokku run book-corners python manage.py migratesudo dokku run book-corners python manage.py showmigrationsBackups use Borg with BorgBase as the offsite target.
- Database — Full PostgreSQL dump via
dokku postgres:export - Media files — User-uploaded photos from
/var/lib/dokku/data/storage/book-corners/media/
Operational scripts are versioned in the repo under scripts/:
scripts/backup.sh— nightly backup (database dump + media archive to Borg)scripts/restore.sh— interactive restore with selective mode support
Both scripts read configuration from /home/deploy/.env.backup on the VPS. They must be run as the deploy user (not with sudo), because borg authenticates to BorgBase using the deploy user's SSH key.
Prerequisite: The deploy user needs passwordless sudo for dokku commands (used by the scripts for database export/import):
echo 'deploy ALL=(ALL) NOPASSWD: /usr/bin/dokku' | sudo tee /etc/sudoers.d/deploy-dokkuNightly at 3 AM server time via cron (under the deploy user):
0 3 * * * /home/deploy/backup.sh >> /var/log/book-corners-backup.log 2>&1Use the restore.sh script on the VPS:
# List available archives
/home/deploy/restore.sh --list
# Preview what would be restored (no changes)
/home/deploy/restore.sh --dry-run
# Restore a specific archive (both DB and media)
/home/deploy/restore.sh book-corners-2026-02-28T03:00:00
# Restore only the database
/home/deploy/restore.sh --db-only
# Restore only media files
/home/deploy/restore.sh --media-onlyThe script prompts for confirmation before any destructive operation and suggests creating a test database first.
If the restore script is unavailable:
export BORG_PASSPHRASE="<your-passphrase>"
export BORG_REPO="<your-borg-repo-url>"
# Extract the dump from a borg archive
mkdir -p /tmp/restore && cd /tmp/restore
borg extract "$BORG_REPO::<archive-name>" --pattern "*/db.dump"
# Restore to a test database first
sudo dokku postgres:create book-corners-db-test
sudo dokku postgres:import book-corners-db-test < $(find . -name db.dump)
# Verify the data
sudo dokku postgres:connect book-corners-db-test
# In psql: SELECT count(*) FROM libraries_library;
# If everything looks good, restore to production
sudo dokku postgres:import book-corners-db < $(find . -name db.dump)
# Clean up
sudo dokku postgres:destroy book-corners-db-test --force
rm -rf /tmp/restoreThe deploy job in CI automatically syncs scripts/backup.sh and scripts/restore.sh to /home/deploy/ on the VPS after every successful deploy. No manual git pull or repo checkout is needed.
The sync step uses SCP (atomic copy to /tmp/ then mv into place) and runs with continue-on-error: true so a sync failure never blocks a deploy.
Prerequisite: The CI SSH key must be authorized for the deploy user:
cat ~/.ssh/dokku_deploy.pub | ssh deploy@vps.bookcorners.org 'cat >> ~/.ssh/authorized_keys'The production image is built in two stages:
Stage 1 — CSS builder (node:22-alpine):
- Installs npm dependencies
- Compiles Tailwind CSS to
static/css/app.css
Stage 2 — Python app (python:3.14-slim):
- Installs system dependencies (GDAL, GEOS, Proj, libjpeg, zlib, gettext)
- Installs Python packages via uv
- Copies compiled CSS from stage 1
- Runs
compilemessages(compiles .po translation files to .mo) - Runs
collectstatic - Serves via gunicorn on
$PORT
The app exposes a /health/ endpoint that Dokku checks during deployment. If the health check fails after 3 attempts, the deploy is rolled back automatically.
Configuration in app.json:
{
"healthchecks": {
"web": [{
"type": "startup",
"name": "web check",
"path": "/health/",
"attempts": 3,
"timeout": 5,
"wait": 5
}]
}
}Check the build output for errors. Common causes:
- PostGIS extension not available — verify the database image
collectstaticfails — check WhiteNoise and STATIC_ROOT configuration- npm install fails — check
package.jsonandpackage-lock.jsonare committed
# Check migration status
sudo dokku run book-corners python manage.py showmigrations
# View the full error
sudo dokku logs book-corners --tailThe old container keeps running if migration fails. Fix the migration and push again.
# View recent logs
sudo dokku logs book-corners --tail
# Check the process status
sudo dokku ps:report book-corners
# Check environment variables
sudo dokku config:show book-corners
# Run a management command to debug
sudo dokku run book-corners python manage.py check --deploy# Verify collectstatic ran during build (check deploy output)
# WhiteNoise serves static files — no separate nginx config needed
# Check the static file URL
curl -I https://bookcorners.org/static/css/app.css# Check certificate status
sudo dokku letsencrypt:list
# Renew manually if needed
sudo dokku letsencrypt:enable book-corners
# Verify Cloudflare SSL mode is "Full (Strict)"