Skip to content
getgeolens.com

Upgrade Guide

How to upgrade GeoLens between versions.

Most GeoLens installs are prebuilt-image installs, the ones created with curl -fsSL https://getgeolens.com/install.sh | sh. Their .env records COMPOSE_FILE=docker-compose.prod.yml and a pinned GEOLENS_VERSION. For these, the recommended upgrade is the one-command, backup-first flow:

Terminal window
# From your install directory
./scripts/upgrade.sh # upgrade to the newest published release
./scripts/upgrade.sh 1.15.1 # or pin an explicit target version

scripts/upgrade.sh (equivalently, bash scripts/install.sh --upgrade) performs, in order:

  1. Syncs release files, then pulls images. Where the install is a git checkout it checks the target tag’s compose files and mounted helper scripts out over the install (a non-git install warns and continues pull-only); then docker compose pull --ignore-buildable. The app keeps serving throughout, so the download costs no downtime.
  2. Stops api + worker, then takes the pre-upgrade backuppg_dump -Fc to a timestamped file under backups/pre-upgrade/. The outage starts here. The upgrade aborts if the dump fails, is empty, or does not read back cleanly end to end (nothing else is touched).
  3. Runs migrations via the fail-closed one-shot migrate service. A failed migration aborts before the app is started.
  4. Pins the new GEOLENS_VERSION in .env, once the migrations have committed.
  5. Starts the stack and waits for every service to report healthy.

On success it keeps the pre-upgrade dump and prints the rollback recipe for reference; on any failure it stops, leaves your data in the dump, and prints the same recipe. If it fails while the app is stopped for the migration, it brings the previous version back up first and checks that it stayed up, so a failed upgrade does not leave the instance down. The exception is a migration that already committed a revision the old release has never heard of — that release cannot boot against the new schema, and restoring the pre-upgrade dump is then the only way back.

If you instead build from source (git clone install, COMPOSE_FILE=docker-compose.yml), upgrade by updating the checkout and rebuilding. See Source-build upgrade below. scripts/upgrade.sh detects a source install and prints those instructions instead of running.

Check which path you are on:

Terminal window
grep -E '^(COMPOSE_FILE|GEOLENS_VERSION)=' .env

Major versions may include breaking changes. Always read the changelog and release notes for the target version before upgrading.

  1. Back up your database before any major upgrade. Use a custom-format (-Fc) dump, the format scripts/restore.sh expects for rollback. Skip this step entirely if you are running scripts/upgrade.sh in step 4 — it takes the same backup for you, and it pulls the new images before it stops anything, so the download costs no downtime. Do it by hand only when you are driving the upgrade manually, and pull the images first if so.

    Terminal window
    # Stop the writers first. pg_dump does not block writers, so a write
    # acknowledged during the dump would be missing from the backup.
    # The outage starts here.
    docker compose -f docker-compose.prod.yml stop api worker
    mkdir -p backups/pre-upgrade
    DUMP="backups/pre-upgrade/geolens_$(date +%Y%m%d_%H%M%S).dump"
    docker compose -f docker-compose.prod.yml exec -T db \
    pg_dump -U geolens -d geolens -Fc --no-owner --no-acl > "$DUMP"
    # Read the archive back end to end before trusting it. A -Fc dump
    # truncated mid-write is non-empty and still passes `pg_restore --list`.
    docker compose -f docker-compose.prod.yml exec -T db \
    pg_restore -f /dev/null < "$DUMP"
  2. Review the changelog for breaking changes, removed features, or required configuration changes.

  3. Check .env.example for new required environment variables. Compare with your current .env:

    Terminal window
    diff <(grep -v '^#' .env.example | grep -v '^$' | cut -d= -f1 | sort) \
    <(grep -v '^#' .env | grep -v '^$' | cut -d= -f1 | sort)
  4. Run the upgrade (prebuilt install). The one command does the image pull, backup, fail-closed migration, version bump, and health gate:

    Terminal window
    ./scripts/upgrade.sh 1.15.1 # replace with your target version
  5. Verify all services are healthy:

    Terminal window
    docker compose ps
    curl -fsS http://localhost:8080/api/health | jq .status # expect: "healthy"

    Use /api/health (proxied to the API), not a bare /health — through the bundled Nginx a bare /health is answered by the frontend SPA with HTML and status 200, so it can never fail even when the API is down.

Rollback is re-pin the previous version + restore the pre-upgrade -Fc dump. Schema migrations move forward only; alembic downgrade is not a supported rollback, and you must never psql < dump a custom-format (-Fc) file (it is not plain SQL). Restore with scripts/restore.sh, which validates the dump and restores it via pg_restore.

Terminal window
# 1. Re-pin the previous version in .env (edit the GEOLENS_VERSION= line).
# Image tags are bare semver, e.g. the version you upgraded FROM:
# GEOLENS_VERSION=1.12.0
# 2. Restore the pre-upgrade dump the upgrade created. restore.sh stops
# api/worker, runs pg_restore, and restarts them afterward.
./scripts/restore.sh backups/pre-upgrade/geolens_pre_<old>_to_<new>_<timestamp>.dump
# 3. Bring the previous version back up.
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml ps

A failed scripts/upgrade.sh run prints this exact recipe (with the real dump path filled in) before it exits, so you can copy it from the upgrade output.

For installs that build from source (COMPOSE_FILE=docker-compose.yml), upgrade by updating the checkout and rebuilding instead of pulling images. Take the -Fc backup first (step 1 above, substituting -f docker-compose.yml), which stops the writers, then:

Terminal window
# 1. Stop the writers FIRST if they are still running. The outage starts here.
# Unlike the prebuilt flow, the build cannot happen while the app serves:
# this compose file bind-mounts ./backend/app into the api container, so
# checking out the new tag in step 2 swaps the running app's code the
# moment it lands.
docker compose -f docker-compose.yml stop api worker
# 2. Update the checkout to the new release tag and rebuild.
git fetch --tags origin
git checkout v1.15.1 # replace with your target tag
docker compose -f docker-compose.yml build
# 3. Run migrations (fail-closed) BEFORE starting the app.
docker compose -f docker-compose.yml up -d --no-deps migrate
docker compose -f docker-compose.yml logs migrate # confirm it exited 0
# 4. Bring the stack up and verify health. The outage ends here.
docker compose -f docker-compose.yml up -d
docker compose -f docker-compose.yml ps

To roll a source build back, restore the -Fc dump with scripts/restore.sh (as above) and git checkout the previous tag, never alembic downgrade.

Each release’s authoritative record of what changed is its CHANGELOG entry. Read the target version’s section — Added, Changed, Fixed, Removed, Security — before upgrading; operator actions are called out there. A few older releases also carry an explicit Upgrade notes heading. The notes below cover the releases with material operator action.

Section titled “1.19.0: shared rate-limit store; tile links retire on unpublish”
  • Set REDIS_URL if you run more than one API worker. Rate limits are now counted in that shared store when it is set, and once per API worker when it is not. The bundled production compose file runs UVICORN_WORKERS=2 by default, so on an install without the store a configured 60 requests a second is enforced as 60 per worker. Limits are never switched off by the absence of a store, only counted separately, and a configured store that goes unreachable falls back the same way: it logs rate_limit_storage_unreachable, then rate_limit_storage_recovered when it returns. Each API worker logs rate_limit_storage_not_configured at startup while the variable is unset, and a URL whose scheme is neither redis:// nor rediss:// logs rate_limit_storage_scheme_unsupported and counts per worker as well.
  • Tile links already handed out stop working when a dataset is unpublished or made private. A signed tile template used to keep serving for the rest of its window. The raster, vector and cluster routes now all retire outstanding templates on that transition. The counter behind this rolls on any publication-status or visibility change, in either direction, so publishing a dataset, or making it public again, retires its outstanding templates too. The application caches a dataset’s metadata for a minute, so the change takes hold within that. Ordinary edits, reuploads and replaces leave live templates alone. Re-mint any template pinned in a script or a saved GIS project after changing either setting. See Tile endpoints.
  • Migration 0060 adds datasets.publication_version. It is a plain column add with a server default of 0, and it is what a signed tile link now binds to. No operator action beyond running the migration.
  • Importing from a file URL now downloads in the background. The endpoint answers once it has validated the request instead of holding the connection open, so a large file or a slow origin is no longer bounded by your proxy’s read timeout. URL_IMPORT_FETCH_MAX_SECONDS caps a single download and defaults to 1800 (30 minutes). Two things change for an API caller: the import response’s status is no longer always pending, and a job’s step can read downloading. The fetch runs on the download queue, which the default worker already subscribes to, so at the stock WORKER_CONCURRENCY=1 a slow origin holds that worker’s only slot for the whole transfer. To keep long downloads off ordinary imports, run a second worker with WORKER_QUEUES=download and drop download from the main worker’s list.
  • strict_cog on a raster import is honoured. The commit body omitted the field, so it was dropped before the raster path read it and every import converted a non-COG GeoTIFF whatever the caller asked for. Sending true now fails the job instead of rewriting the file. Combining it with resampling, nodata_override, srid_override, or a compression other than the default DEFLATE is refused with a 422 naming the fields that clash. No caller could set the field before this release, so nothing that used to work behaves differently.
  • Overlay packages must be rebuilt. The extension API version is now 9, bumped twice in this release. CatalogPort gained a required resolve_embedding_config, generate_embedding takes the resolved embedding configuration as a pin, get_record_embedding returns the row’s model and fingerprint alongside the vector, get_embedding_distances takes that pair as required keywords, and get_nearest_record_ids takes the caller’s already-read anchor — the vector together with that pair. An overlay that declares a different version is refused at load. Installs without overlay packages are unaffected.
  • No re-embed is required. Rows written before this release keep matching on model name alone until they are regenerated; upgrading changes nothing an operator sees and triggers no re-embed.

1.13.0: extension API version 7; dataset delete detaches registered tables

Section titled “1.13.0: extension API version 7; dataset delete detaches registered tables”
  • Overlay packages must be rebuilt. The extension API version is now 7. An overlay built against an earlier version is refused at load, so rebuild every overlay package before deploying this release. Installs without overlay packages are unaffected.
  • Deleting a registered dataset no longer drops its table. The catalog row, grants, tiles, and caches are removed while the operator’s physical table survives. Datasets GeoLens ingested itself are still dropped in full. The delete dialog states which of the two will happen. If you had scripted around the old drop-everything behavior, re-check it.

1.12.0: raster CRS override assigns instead of reprojecting

Section titled “1.12.0: raster CRS override assigns instead of reprojecting”
  • A raster CRS override now assigns the CRS rather than reprojecting to it. Supplying an EPSG code at import or replace time relabels the raster in place: pixel values, the pixel grid, and the corner coordinates all pass through untouched, and only the CRS they are read under changes. A dataset ingested with an override therefore lands wherever those coordinates put it in the CRS you named, which may not be where the same override put it before. Rasters are still reprojected at serve time and at export time; reproject-at-ingest is not offered. Re-check any ingest automation that passed an override expecting a reprojection.
  • Because the conversion no longer resamples, the uploaded file is deleted after a successful lossless ingest instead of being kept as a second permanent copy.
  • Migration 0041 is a data migration. It rewrites stored WKT1 CRS definitions to WKT2:2019 in batches; deploy logs report converted to WKT2:2019: N row(s). The downgrade is a no-op.

1.11.0: shared credential store for protected service refreshes

Section titled “1.11.0: shared credential store for protected service refreshes”
  • Passing a service token to a refresh now requires REDIS_URL (Valkey/Redis), so the credential can reach the worker without touching disk. Deployments that only refresh public sources need no change.

1.10.0: PROMETHEUS_MULTIPROC_DIR for multi-worker metrics

Section titled “1.10.0: PROMETHEUS_MULTIPROC_DIR for multi-worker metrics”
  • New env var, required for multiprocess metrics: PROMETHEUS_MULTIPROC_DIR. Both the dev and prod Compose files set it to a tmpfs-backed path. On a custom deployment running UVICORN_WORKERS > 1, point it at a writable directory that is empty on boot, or /metrics serves single-process (non-aggregated) counters.
  • Pull or rebuild before recreating the api container. The entrypoint script that prepares the directory ships in the image, not in the Compose file, so recreating api to pick up the new variable without a new image fails to boot.

1.7.0: restart the backup container; two endpoint shapes changed

Section titled “1.7.0: restart the backup container; two endpoint shapes changed”
  • Restart the backup container once after upgrading (any full stack restart covers it). The backup daemon reads its entrypoint at container start, so an already-running daemon keeps the old cycle and will not produce the new globals-*.sql role dump until it restarts. That artifact is what makes a fresh-cluster restore work — see Backups & restore.
  • Antimeridian-crossing extents are now served in RFC 7946 spec form, a bbox with west > east (e.g. [178, -19, -178, -17] for Fiji) at the dataset and collection endpoints, instead of a flattened [-180, …, 180] span. Clients that assume west <= east need to handle the wrap.
  • GET /tiles/raster-auth-check/ is gone from the API contract and both SDKs. Anyone calling it directly should use the raster tile URL from GET /tiles/token/{dataset_id}/ instead.
  • DEMO_MODE is ignored. The Demo Mode banner was replaced by the admin-configurable Site Banner (Settings -> General), which carries custom text, a color choice, and per-session dismissal. Env-only deployments configure it with BANNER_ENABLED, BANNER_TEXT, and BANNER_COLOR. The /api/auth/config response no longer includes the demo_mode field.
  • Set BACKUP_MAX_AGE_MINUTES if you run a non-daily BACKUP_SCHEDULE. The backup container now reports unhealthy when backups stop succeeding, judged against this setting (default 1560 minutes = 26 hours, sized for the default daily schedule). Roughly 1.5x your backup interval is the right value.

1.5.0: PostgreSQL 18 + PostGIS 3.6 (breaking for self-hosted)

Section titled “1.5.0: PostgreSQL 18 + PostGIS 3.6 (breaking for self-hosted)”

scripts/upgrade.sh does not apply to this release. The bundled database moved from PostgreSQL 17 + PostGIS 3.5 to PostgreSQL 18 + PostGIS 3.6. An existing PG 17 pgdata volume cannot be opened by PG 18, so the upgrade is a dump -> fresh volume -> restore, not an image pull.

  • Bundled-database installs: follow the procedure in RUNBOOK.md section 6. scripts/upgrade.sh compares the running server’s major version against the one the target release bundles and stops before anything changes — no image pull, no version pin, no database write — printing that procedure instead.
  • Managed or external database installs (DATABASE_URL_OVERRIDE): the check is skipped, since the bundled image’s version says nothing about the database you actually use. Run your provider’s PostgreSQL 17 -> 18 upgrade, then deploy as usual. The minimum supported external version remains PostgreSQL 13.
  • The backup image’s pg_dump moves to 18 in lockstep, because a v17 pg_dump cannot dump a PG 18 server.

1.4.8: PostgreSQL max_connections raised to 80

Section titled “1.4.8: PostgreSQL max_connections raised to 80”
  • max_connections is raised to 80 in the bundled db/postgresql.conf to cover the API-side job-queue connector. Recreate or restart the db container after upgrading so the new value takes effect. A running container keeps its old limit until it restarts.

1.4.7: database SSL modes fail closed; migrations 0018–0024

Section titled “1.4.7: database SSL modes fail closed; migrations 0018–0024”
  • DATABASE_SSL_MODE now fails closed. The undocumented allow and verify-ca values no longer boot; switch to prefer or verify-full before upgrading.
  • Back up before applying migrations 0018 through 0024. The standard scripts/upgrade.sh flow takes this backup automatically. These migrations add tenant identifiers, database roles, row-security policies, and tenant data-schema support; default single-tenant deployments keep their current behavior.
  • Run these migrations with a database role that can create roles. Managed-PostgreSQL users may need a separate migration credential with CREATEROLE; keep the API and worker on their existing least-privilege runtime credential. On a busy database, lock-sensitive steps time out after five seconds instead of queueing behind application traffic — retry during a quieter window if that happens.

The backend now enforces a 32-character minimum on JWT_SECRET_KEY at startup (HS256 requires >= 256 bits of entropy). A deployment with a shorter secret will fail fast on the next restart with:

FATAL: JWT_SECRET_KEY must be at least 32 characters. Generate one with: openssl rand -hex 32

Before upgrading, verify your secret length:

Terminal window
echo -n "$JWT_SECRET_KEY" | wc -c

If it reports fewer than 32, generate a replacement and update your .env:

Terminal window
JWT_SECRET_KEY=$(openssl rand -hex 32)

Rotating the key invalidates all issued JWT tokens. All users will be logged out and need to sign in again. Plan the rotation during a low-traffic window, or coordinate with your user base.

.env.example ships JWT_SECRET_KEY= empty, and the validator also rejects well-known public placeholders such as dev-only-change-me-in-production. scripts/install.sh generates a real openssl rand -hex 32 secret on first run, so installer-driven deployments are unaffected; hand-edited .env files must set a real openssl rand -hex 32 value.

Other env hardening in this release:

  • Secret fields (POSTGRES_PASSWORD, JWT_SECRET_KEY, GEOLENS_ADMIN_PASSWORD, ANTHROPIC_API_KEY, OPENAI_API_KEY, S3_SECRET_ACCESS_KEY, TILE_SIGNING_SECRET) are now stored as Pydantic SecretStr internally. Values are masked in logs, repr(), and validation-error output. Application behavior is unchanged; this is a defense-in-depth improvement.
  • LOG_LEVEL values are now validated against the stdlib logging set (DEBUG, INFO, WARNING, ERROR, CRITICAL). A typo like LOG_LEVEL=verbose now fails at startup instead of crashing later.
  • ENV_ONLY_CONFIG is now documented in .env.example. It is optional.
  • The backend/.env symlink has been removed. Host-side workflows (cd backend && uv run pytest) now resolve ./.env at the project root via the Settings env_file path. No action required unless you had local scripts depending on backend/.env as a literal path.
  • VITE_API_PROXY_TARGET (docker-compose.yml frontend service) was renamed to API_PROXY_TARGET. The old name still works for one release via a fallback in vite.config.ts; update your local compose overrides when convenient.

The landing page has been removed. The root route (/) now serves the Search page directly. The SHOW_LANDING_PAGE environment variable has been removed from backend config and the branding API.

What this means:

  • Existing bookmarks to / will show the Search page instead of the landing page.
  • The /search route redirects to /; existing /search bookmarks continue to work.
  • Remove SHOW_LANDING_PAGE from your .env if present (it is ignored but produces no error).

GeoLens 1.0.0 is the first public release. Prior to 1.0.0, the project was internally versioned as 2.0 -> 13.0 during pre-public development. Those legacy versions never shipped to anyone outside the project.

If you somehow have a checkout from a pre-1.0.0 internal build:

  • No data migration is required. The 1.0.0 schema is compatible with the most recent pre-public versions; Alembic migrations apply normally on the first 1.0.0 startup.
  • The version number resets, but the codebase moves forward. 1.0.0 is the cumulative state of all prior internal work, not a downgrade.
  • No git checkout v13.x rollback path exists from 1.0.0. If you need to roll back, restore from the database backup you took before upgrading (see Pre-upgrade checklist).
  • No environment variables changed at the 1.0.0 boundary. Your existing .env from any pre-public build continues to work without modification.

For all subsequent upgrades, follow the standard procedure above.