x-app-image: &app-image # Prebuilt by .github/workflows/build-docker.yml (workflow_dispatch) and # pushed to GHCR -- `docker compose pull` grabs that instead of building # locally. `build:` is still here as a fallback for local iteration # (`docker compose build` tags the result with this same name). The build # itself is just an `npm run build -w app` layered onto the official # dmstraub/gramps-webapi image now (see deploy/Dockerfile), so it's fast # either way -- no from-source gramps/gramps-web-api clone anymore. image: ghcr.io/dsblank/gramps-connect:latest build: context: .. dockerfile: deploy/Dockerfile x-app-env: &app-env # Multi-tree mode, required for the SharedPostgreSQL backend. GRAMPSWEB_TREE: "*" GRAMPSWEB_NEW_DB_BACKEND: sharedpostgresql GRAMPSWEB_POSTGRES_HOST: postgres GRAMPSWEB_POSTGRES_PORT: "5432" GRAMPSWEB_POSTGRES_USER: ${POSTGRES_USER} GRAMPSWEB_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # Required in multi-tree mode: without this, media files are not # scoped per tree and are exposed across trees (gramps-web-api warns # loudly about this at startup if left False). GRAMPSWEB_MEDIA_PREFIX_TREE: "True" # Leave GRAMPSWEB_SECRET_KEY unset in .env to have the entrypoint # generate and persist one on first boot instead. GRAMPSWEB_SECRET_KEY: ${GRAMPSWEB_SECRET_KEY:-} GRAMPSWEB_ADMIN_USER: ${GRAMPSWEB_ADMIN_USER} GRAMPSWEB_ADMIN_PASSWORD: ${GRAMPSWEB_ADMIN_PASSWORD} GRAMPSWEB_CELERY_CONFIG__broker_url: redis://redis:6379/0 GRAMPSWEB_CELERY_CONFIG__result_backend: redis://redis:6379/0 # Flask-Limiter defaults to in-memory rate-limit storage, which is # per-process and resets on restart -- point it at redis instead. GRAMPSWEB_RATELIMIT_STORAGE_URI: redis://redis:6379/2 GRAMPSWEB_BASE_URL: ${PUBLIC_URL} GRAMPSWEB_DISABLE_TELEMETRY: "True" # This backend is now the same unmodified image gramps-web itself runs # on (see deploy/Dockerfile), so a separately-hosted gramps-web frontend # (a different origin than this deployment's own PUBLIC_URL) can point # at /api/ here too -- but Flask-Cors rejects it by default until its # origin is allowed here. Empty by default (same-origin gramps-connect # keeps working either way); set to that origin, or "*", in deploy/.env # to opt in. GRAMPSWEB_CORS_ORIGINS: ${GRAMPSWEB_CORS_ORIGINS:-} # Outgoing mail (password-reset links, new-user notifications) via an # SMTP relay -- gramps-web-api defaults to a nonexistent localhost:465, # so these all stay empty/unset until real values are set in deploy/.env # (see .env.example). GRAMPSWEB_EMAIL_USE_STARTTLS=true is what a typical # port-587 submission relay (Brevo, SendGrid, Gmail, ...) needs -- # without it gramps-web-api defaults to implicit TLS (SMTP_SSL), which # port 587 doesn't speak. Must be lowercase "true"/"false": Flask's # from_prefixed_env() runs each env value through json.loads(), and # gramps-web-api's SMTP config resolver does an `is True` identity check # on the result -- "True"/"False" (capitalized) fail json.loads, stay # plain strings, and silently fail that identity check (which is what # happened here: the config quietly fell back to implicit TLS despite # this being set to the string "True"). GRAMPSWEB_EMAIL_HOST: ${GRAMPSWEB_EMAIL_HOST:-} GRAMPSWEB_EMAIL_PORT: ${GRAMPSWEB_EMAIL_PORT:-587} GRAMPSWEB_EMAIL_HOST_USER: ${GRAMPSWEB_EMAIL_HOST_USER:-} GRAMPSWEB_EMAIL_HOST_PASSWORD: ${GRAMPSWEB_EMAIL_HOST_PASSWORD:-} GRAMPSWEB_EMAIL_USE_STARTTLS: ${GRAMPSWEB_EMAIL_USE_STARTTLS:-true} GRAMPSWEB_DEFAULT_FROM_EMAIL: ${GRAMPSWEB_DEFAULT_FROM_EMAIL:-} # Not a GRAMPSWEB_* setting -- read directly by deploy/Dockerfile's CMD # to size gunicorn's --timeout. A single-file media upload (POST # /api/media/, or the ZIP archive endpoint's write_upload_to_file) is # read synchronously inside the request, so it counts as one long # "silent" request from gunicorn's point of view; the stock 120s default # kills the worker mid-upload on anything slow/large enough, which # surfaces to the browser as a bare "Internal Server Error" with no JSON # body. Raise further in deploy/.env for very large media libraries or # slow client connections. GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-600} x-app-volumes: &app-volumes # /root/.gramps/grampsdb (not /app/db) is where the official # dmstraub/gramps-webapi image sets GRAMPS_DATABASE_PATH -- see its own # Dockerfile and gramps-web-docs' docker-compose-base example. Each tree # gets a metadata directory here even under the SharedPostgreSQL backend # (the actual row data lives in postgres; this still holds per-tree # name.txt/database.txt). - app-db:/root/.gramps/grampsdb - app-media:/app/media - app-indexdir:/app/indexdir - app-users:/app/users - app-secret:/app/secret # Import/export/media-archive-upload write a temp file here from the app # container's request handler, then a Celery task on the worker container # reads it back -- must be shared, or the worker gets "No such file". - app-cache:/app/cache # Shared for the same reason as app-cache above -- matches the gramps_tmp # volume in gramps-web-docs' own reference docker-compose.yml. - app-tmp:/tmp services: app: <<: *app-image environment: <<: *app-env volumes: *app-volumes # No published port here -- caddy is the only entrypoint (see below), # so nothing can reach app directly over plain HTTP bypassing TLS. depends_on: - postgres - redis restart: unless-stopped # "Answers HTTP at all" -- any status, a 401 included, since there's no # unauthenticated health route. gunicorn only starts once the # entrypoint's `user migrate` has finished, so healthy also means the # user database is on the current schema; the worker waits for that. healthcheck: test: - CMD - python3 - -c - | import urllib.request, urllib.error try: urllib.request.urlopen("http://localhost:5000/api/metadata/", timeout=5) except urllib.error.HTTPError: pass interval: 10s timeout: 10s retries: 3 start_period: 120s # Import, media-archive-upload, search-indexing, etc. all dispatch through # Celery (GRAMPSWEB_CELERY_CONFIG__* above) instead of running inline -- # without a worker consuming that queue, those requests return a task ID # that never completes. worker: <<: *app-image environment: <<: *app-env # The app service owns migrations/admin-seeding on startup; running # both concurrently against the same sqlite USER_DB_URI volume risks # "database is locked" (see deploy/docker-entrypoint.sh). SKIP_MIGRATE_AND_SEED: "1" volumes: *app-volumes # Waits for app to be healthy, i.e. done migrating the shared user # database: a task run against the old schema fails (after a # gramps-web-api upgrade adding a column, e.g. 3.25.0's # users.token_version: "no such column"). depends_on: postgres: condition: service_started redis: condition: service_started app: condition: service_healthy # --pool=solo: avoid celery's default prefork pool. Suspected cause of # "Import failed" with no real error (see investigation notes) -- # Gramps' plugin registration lazily touches real GTK/GObject state # (gtk_icon_theme_get_for_screen fires on first plugin load), which # doesn't appear to survive celery forking a child process cleanly. command: celery -A gramps_webapi.celery worker --loglevel=info --pool=solo restart: unless-stopped postgres: image: postgres:16 environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - postgres-data:/var/lib/postgresql/data restart: unless-stopped redis: image: valkey/valkey:alpine restart: unless-stopped caddy: # Stock Caddy plus the caddy-ratelimit module (deploy/ratelimit.caddy), # prebuilt by build-docker.yml the same way as the app image; `build:` # is the local fallback. image: ghcr.io/dsblank/gramps-connect-caddy:latest build: context: . dockerfile: Dockerfile.caddy volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./ratelimit.caddy:/etc/caddy/ratelimit.caddy:ro - caddy-data:/data - caddy-config:/config ports: - "80:80" - "443:443" depends_on: - app restart: unless-stopped volumes: app-db: app-media: app-indexdir: app-users: app-secret: app-cache: app-tmp: postgres-data: # caddy-data holds the generated local CA / certs -- persisting it means # restarts don't regenerate the CA and re-trigger a browser trust prompt. caddy-data: caddy-config: