This document describes the deployment process for the OGM API using Kamal, a modern deployment tool that uses Docker containers.
The OGM API is deployed to production using Kamal, which orchestrates Docker containers across servers. The application consists of:
- Web Service: FastAPI application (Python/uvicorn)
- Worker Service: Celery worker for OGM harvests, cache jobs, and async processing
- Accessories (supporting services):
- PostgreSQL database
- Elasticsearch search engine
- Redis cache/message broker
gem install kamalVerify installation:
kamal version- SSH access to the deployment server (
ogm.geo4lib.app) - GitHub Container Registry (GHCR) access
- Environment variables for secrets (see Secrets section)
- Docker installed on the target server
- SSH key-based authentication configured
- Port access: 80, 443 (web), 9200 (Elasticsearch), 5432 (PostgreSQL), 6379 (Redis)
The deployment configuration is defined in config/deploy.yml:
service: ogm-api
image: ewlarson/opengeometadata-apiThe application is deployed to the existing ogm-api Kamal service so it can reuse the current production hostnames and data accessories while serving the OpenGeoMetadata-branded backend.
servers:
web:
hosts:
- ogm.geo4lib.app
worker:
hosts:
- ogm.geo4lib.app
cmd: bash -lc "cd /app/backend && exec celery -A app.tasks.worker worker -E --loglevel=INFO --concurrency=${CELERY_WORKER_CONCURRENCY:-2} --prefetch-multiplier=1"Deploys both the web app and the Celery worker to the same production server at ogm.geo4lib.app.
proxy:
ssl: true
host: ogm.geo4lib.app
app_port: 8000
healthcheck:
path: /api/docs- Automatic SSL certificate management via Let's Encrypt
- Application runs on port 8000 internally
- Health checks performed against
/api/docsendpoint
registry:
server: ghcr.io
username: ewlarson
password:
- KAMAL_REGISTRY_PASSWORDUses GitHub Container Registry for Docker images.
Secrets are managed through .kamal/secrets file. This file should NEVER contain raw credentials.
Add these environment variables to your local shell before deployment, or store the
GHCR token in .kamal/registry-password or macOS Keychain so every new terminal
can deploy:
# GitHub Container Registry token (required for pulling images)
export KAMAL_REGISTRY_PASSWORD="your_github_token"
# One-time local setup for file-backed deploys; .kamal/ is gitignored
install -m 600 /dev/null .kamal/registry-password
printf '%s' "$KAMAL_REGISTRY_PASSWORD" > .kamal/registry-password
# Or let the repo helper store the token and verify GHCR auth
make kamal-registry-login
# Optional one-time local setup for macOS Keychain-backed deploys
security add-generic-password -U -a ewlarson -s ogm-api-ghcr-token -w "$KAMAL_REGISTRY_PASSWORD"
# OpenAI API key (for AI features)
export OPENAI_API_KEY="your_openai_key"
# Optional: OpenAI model configuration
export OPENAI_MODEL="gpt-4"The .kamal/secrets file references these secrets:
- KAMAL_REGISTRY_PASSWORD: GitHub Container Registry authentication
- DATABASE_URL: PostgreSQL connection string for the Kamal accessory network
- POSTGRES_PASSWORD: PostgreSQL superuser password
- ADMIN_USERNAME / ADMIN_PASSWORD: Basic auth credentials for admin endpoints
- OGM_WEBHOOK_SECRET: GitHub webhook signature secret for OGM repo events
- GITHUB_TOKEN: GitHub API token for nightly repo discovery and harvest orchestration
- OPENAI_API_KEY / OPENAI_MODEL: Optional AI feature configuration
If the nightly OGM workflow fails with GitHub API error listing repos: 401
and Bad credentials, the deployed GITHUB_TOKEN has expired, been revoked, or
was copied incorrectly. Update the secret source used by .kamal/secrets, then
reboot or redeploy the app containers so Kamal rewrites the host env file and the
running web/worker/cron roles receive the new value.
The repo also includes .github/workflows/ogm-nightly-sync.yml, which SSHes to the
production host nightly and runs the in-container OGM repo refresh + harvest trigger.
Configure these GitHub Actions secrets for that workflow:
- OGM_KAMAL_SSH_HOST: production SSH hostname (for example
ogm.geo4lib.app) - OGM_KAMAL_SSH_PORT: optional SSH port, defaults to
22 - OGM_KAMAL_SSH_USER: SSH username with Docker access on the host
- OGM_KAMAL_SSH_PRIVATE_KEY: private key matching that SSH user
Kamal also runs a cron role from config/deploy.yml. The container loads
config/crontab through backend/scripts/start_cron.sh, which snapshots the
container environment for cron jobs before launching cron -f.
The OGM nightly harvest command is present in config/crontab, but it is gated by
OGM_NIGHTLY_CRON_ENABLED=false by default because the GitHub Actions workflow
above is currently the active nightly scheduler. To move scheduling fully into
Kamal cron, set OGM_NIGHTLY_CRON_ENABLED=true and disable the scheduled
GitHub Actions trigger so production does not enqueue duplicate harvests.
These are passed to every web container:
env:
clear:
ELASTICSEARCH_URL: http://ogm-api-elasticsearch:9200
REDIS_HOST: ogm-api-redis
REDIS_PORT: "6379"
ELASTICSEARCH_INDEX: opengeometadata_api
REDIS_TTL: "604800"
LOG_LEVEL: DEBUG
ENDPOINT_CACHE: "true"
GAZETTEER_CACHE_TTL: "3600"
RESOURCE_REPRESENTATION_DURABLE_STORE: database
API_RESPONSE_DURABLE_CACHE_STORE: database
VISUAL_ASSET_DURABLE_STORE: database
VISUAL_ASSET_CACHE_TTL_SECONDS: "0"
ENABLE_FAST_EMBEDDINGS: "false"
APP_MODE: production
APP_ENV: production
APPLICATION_URL: https://ogm.geo4lib.app
CRON_LOCAL_TIMEZONE: America/Chicago
OGM_TRIGGER: nightly
OGM_NIGHTLY_CRON_ENABLED: "false"
secret:
- ADMIN_USERNAME
- ADMIN_PASSWORD
- DATABASE_URL
- GITHUB_TOKEN
- OPENAI_API_KEY
- OPENAI_MODELKamal manages supporting services as "accessories". Each runs in its own container.
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:9.0.0
env:
discovery.type: single-node
xpack.security.enabled: "false"
ES_JAVA_OPTS: "-Xms2g -Xmx2g"- Single-node configuration
- 2GB heap size
- Security disabled (bound to localhost only)
- Kamal directory source
esdata, currently resolved to/home/ewlarson/ogm-api-elasticsearch/esdataon the production host
postgres:
image: postgres:15
env:
POSTGRES_USER: ogm_api_user
POSTGRES_DB: btaa_ogm_api- Existing PostgreSQL 15 accessory used by the current production deployment
- Initialized via
config/init.sql - FAST vector embeddings are disabled in Kamal by default because the current production database image does not expose the
vectorextension - Kamal directory source
pgdata, currently resolved to/home/ewlarson/ogm-api-postgres/pgdataon the production host
redis:
image: redis:7.2
cmd: redis-server --appendonly yes --protected-mode yes --bind 0.0.0.0- Redis 7.2
- Append-only file (AOF) persistence enabled
- Current production accessory runs without Redis AUTH; the app should omit
REDIS_PASSWORDunless/until the accessory is explicitly recreated with auth - Kamal directory source
redisdata, currently resolved to/home/ewlarson/ogm-api-redis/redisdataon the production host
For first-time deployment, set up the server infrastructure:
# Set up Kamal on the server (creates directories, installs proxy, etc.)
kamal setupThis command will:
- Install Kamal proxy (Traefik)
- Create necessary directories
- Start all accessories
- Deploy the application
For code updates and routine deployments:
# Deploy the application
kamal deployThis will:
- Build the Docker image
- Push to GitHub Container Registry
- Pull the image on the server
- Perform a rolling restart with zero downtime
- Run health checks
If you need to run the nightly OGM pipeline outside its normal schedule:
kamal app exec "python /app/backend/scripts/trigger_ogm_nightly_sync.py"That command refreshes the discovered OpenGeoMetadata repository list and enqueues the scheduled OGM harvest tasks for enabled repos.
After migrations, indexing, or a large OGM harvest, run the generated-cache primer so resource JSON, thumbnails, and static maps are ready before first user traffic asks for them.
Run a bounded foreground smoke:
kamal app exec "cd /app/backend && python scripts/prime_generated_caches.py --limit 100"Start a full background run:
kamal app exec "cd /app/backend && ./scripts/start_cache_prime_background.sh"Watch progress:
kamal app exec "tail -f /app/backend/logs/prime_generated_caches.log"Full runs write durable database-backed generated resources and visual assets by
default. Add --hydrate-assets only for a bounded hotset or for a Redis host
sized to hold full image bodies:
kamal app exec "cd /app/backend && ./scripts/start_cache_prime_background.sh --hydrate-assets --limit 500"See Generated Cache Priming for the full command reference.
To build and push a new image without deploying:
# Build and push image
kamal build pushTo deploy a specific image tag:
# Deploy a specific version
kamal deploy --version=v1.2.3[[memory:4252071]]
# View Elasticsearch logs
kamal accessory logs elasticsearch
# View PostgreSQL logs
kamal accessory logs postgres
# View Redis logs
kamal accessory logs redis
# Follow logs in real-time
kamal accessory logs elasticsearch --follow# Restart Elasticsearch
kamal accessory restart elasticsearch
# Restart PostgreSQL
kamal accessory restart postgres
# Restart Redis
kamal accessory restart redis# Remove an accessory
kamal accessory remove elasticsearch
# Boot an accessory
kamal accessory boot elasticsearch# Reboot all accessories
kamal accessory reboot -a# View recent logs
kamal app logs
# Follow logs in real-time
kamal app logs --follow
# View last 100 lines
kamal app logs --lines 100# Open a shell in the running container
kamal app exec -i bash
# Run a one-off command
kamal app exec "python /app/backend/scripts/run_migrations.py"
# Run database migrations
kamal app exec "python /app/backend/scripts/run_migrations.py"# Restart the application (zero-downtime)
kamal app restart# Stop the application
kamal app stop# Start the application
kamal app start# SSH to the server
kamal app exec -i bash
# Or use direct SSH
ssh ewlarson@ogm.geo4lib.app# Show server details
kamal details# List all containers
kamal app containersAfter deploying new code that includes database migrations:
# Execute migrations in the container
kamal app exec "python /app/backend/scripts/run_migrations.py"Create a logical PostgreSQL backup on the host. A logical dump is safe while PostgreSQL is running; copying its live data directory is not:
# SSH to server
ssh ewlarson@ogm.geo4lib.app
# Create a restricted backup directory and a custom-format dump
umask 077
ogm_backup_dir="$HOME/ogm-api-backups"
install -d -m 700 "$ogm_backup_dir"
docker exec ogm-api-postgres \
pg_dump --format=custom -U ogm_api_user btaa_ogm_api \
> "$ogm_backup_dir/postgres-$(date -u +%Y%m%dT%H%M%SZ).dump"
# Confirm the newest dump is non-empty and structurally readable
ogm_backup_file="$(find "$ogm_backup_dir" -type f -name 'postgres-*.dump' -print | sort | tail -n1)"
test -s "$ogm_backup_file"
docker exec -i ogm-api-postgres pg_restore --list < "$ogm_backup_file" >/dev/nullCopy the verified dump to an access-controlled off-host backup system and record its identifier before a deployment or migration window.
# Copy a verified custom-format backup to the server
scp postgres-YYYYMMDDTHHMMSSZ.dump ewlarson@ogm.geo4lib.app:ogm-api-backups/
# SSH to server
ssh ewlarson@ogm.geo4lib.app
# Restore only during an approved recovery window
docker exec -i ogm-api-postgres \
pg_restore --clean --if-exists -U ogm_api_user -d btaa_ogm_api \
< "$HOME/ogm-api-backups/postgres-YYYYMMDDTHHMMSSZ.dump"# Execute index rebuild in the container
kamal app exec "python /app/backend/scripts/run_index.py"# SSH to server and check Elasticsearch
ssh ewlarson@ogm.geo4lib.app
curl -X GET "localhost:9200/_cat/indices?v"
curl -X GET "localhost:9200/opengeometadata_api/_search?size=1&pretty"If a deployment causes issues, you can quickly rollback:
# Rollback to previous version
kamal rollback [VERSION]To find available versions:
# List recent deployments
kamal app imagesThe application health check is configured to use /api/docs:
# Check health status
curl https://ogm.geo4lib.app/api/docs# Check deployment status
kamal details
# View application logs
kamal app logs --lines 200
# Check container status
kamal app containers
# Verify environment variables
kamal app exec "sh -lc 'env | grep -i elasticsearch'"Issue: Container won't start
# Check logs for errors
kamal app logs --lines 100
# Verify image was built correctly
kamal app images
# Check server resources
ssh ewlarson@ogm.geo4lib.app "docker ps -a"Issue: Database connection failures
# Verify PostgreSQL is running
kamal accessory logs postgres
# Check database accessibility
kamal app exec "nc -zv ogm-api-postgres 5432"Issue: Elasticsearch not responding
# Check Elasticsearch logs
kamal accessory logs elasticsearch
# Verify Elasticsearch is healthy
ssh ewlarson@ogm.geo4lib.app "curl localhost:9200/_cluster/health?pretty"Here's a typical workflow for deploying changes:
# 1. Store and verify the GHCR token once per token rotation
make kamal-registry-login
# 2. Ensure app secrets are set
export OPENAI_API_KEY="your_key"
# 3. Verify configuration
kamal config
# 4. Deploy the application
kamal deploy
# 5. Monitor deployment
kamal app logs --follow
# 6. Verify deployment
curl https://ogm.geo4lib.app/api/docs
# 7. If needed, run migrations
kamal app exec "python /app/backend/scripts/run_migrations.py"
# 8. If needed, rebuild search index
kamal app exec "python /app/backend/scripts/run_index.py"
# 9. If needed, warm generated resource, thumbnail, and static-map caches
kamal app exec "cd /app/backend && ./scripts/start_cache_prime_background.sh --limit 5000"For automated deployments via GitHub Actions or similar:
# Example GitHub Action step
- name: Deploy with Kamal
env:
KAMAL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
gem install kamal
kamal deploy- Never commit secrets: Keep
.kamal/secretssafe but never commit actual credentials - SSH key security: Use strong SSH keys for server access
- Registry tokens: Rotate GHCR tokens regularly
- Database passwords: Use strong passwords for PostgreSQL
- Firewall rules: Ensure only necessary ports are exposed
- SSL certificates: Kamal handles Let's Encrypt automatically
- Volume security: Persistent volumes contain sensitive data
Kamal bind-mounts three persistent host directories. The relative directory
sources in config/deploy.yml are stable production identity and must not
change during a repository or image transfer:
esdata->/home/ewlarson/ogm-api-elasticsearch/esdata: Elasticsearch index datapgdata->/home/ewlarson/ogm-api-postgres/pgdata: PostgreSQL database filesredisdata->/home/ewlarson/ogm-api-redis/redisdata: Redis append-only cache data
These directories persist across deployments and container restarts. They are
not Docker named volumes; docker volume commands do not back them up. Kamal
derives the resolved host paths from the deployment user, service, accessory,
and relative source name. Do not replace the relative sources with new absolute
paths unless the data is deliberately migrated in a separate maintenance
window.
# SSH to server
ssh ewlarson@ogm.geo4lib.app
# Confirm the configured directories and mounts
find "$HOME" -maxdepth 2 -type d -name 'ogm-api-*' -print
docker inspect ogm-api-postgres --format '{{json .Mounts}}'Use the logical PostgreSQL procedure above for database backups. Use a provider/filesystem snapshot for the three bind directories only with an explicit consistency plan. Never archive the live PostgreSQL data directory as if it were an ordinary file tree.
Adjust heap size in config/deploy.yml:
ES_JAVA_OPTS: "-Xms4g -Xmx4g" # Increase to 4GBModify Redis command for different persistence:
cmd: redis-server --appendonly yes --maxmemory 2gbCurrently configured for single-server deployment. For horizontal scaling, add more servers:
servers:
web:
hosts:
- ogm1.geo4lib.app
- ogm2.geo4lib.appRemove old unused containers:
kamal pruneWarning: This will destroy all data!
# Remove everything
kamal remove
# Start fresh
kamal setupFor deployment issues:
- Check the application logs:
kamal app logs - Check accessory logs:
kamal accessory logs [name] - Verify server connectivity:
kamal details - Review this documentation
- Check Kamal documentation: https://kamal-deploy.org/