Skip to content
getgeolens.com

Self-host on managed cloud services

Run GeoLens on a cloud host with managed services behind it: PostgreSQL instead of the bundled database container, object storage instead of a local volume, and optionally a managed cache. The application containers do not change. Everything a provider needs is passed as environment variables.

This page covers what is true on every cloud. The steps for provisioning those services on one particular cloud live in geolens-deployments, next to the Terraform recipes that automate the same work.

You wantGo here
Compose on one host with bundled containersInstall Guide
Compose on one host pointed at managed servicesThis page, then the cloud page below
Provisioning steps for AWS, Azure, Google Cloud or DigitalOceanclouds/ in geolens-deployments
Terraform for AWS ECS Fargateterraform/aws-ecs-fargate
KubernetesThe Helm chart, or Deploy on Amazon EKS

Whichever you pick, the database bootstrap and the environment contract below still apply. The Terraform recipe runs the bootstrap for you as a one-shot task. The Helm chart does not: its migrate hook runs Alembic and expects the extensions, schemas and reader role to exist already, and fails with schema "data" does not exist when they do not.

PostgreSQL 13 or newer with PostGIS 3.x. The baseline migration checks the server version and aborts below 13, because gen_random_uuid() is used as a column default. The bundled image ships PostgreSQL 18 and PostGIS 3.6, which is what each release is tested against.

Pick 15 or newer unless you have a reason not to. Semantic search needs pgvector 0.5 or later, and managed providers tend to offer the vector extension only on newer engines. On RDS it arrives with PostgreSQL 15.2 and 14.7. Whether your provider can give 13 or 14 pgvector is a separate question from whether the application supports them, so confirm before committing to a version.

The baseline migration requires postgis, pg_trgm, vector and unaccent, and creates none of them. All four have to exist before the first migration runs.

An S3-compatible bucket with STORAGE_PROVIDER=s3, or Azure Blob Storage with STORAGE_PROVIDER=azure. On S3 the browser uploads directly to the bucket with presigned URLs, so the bucket needs a CORS policy naming your application origin. Azure has no presigned path, so uploads go through the API and no CORS policy is needed.

REDIS_URL pointed at a Redis or Valkey instance. With it unset the application caches in process memory, which is correct for a single instance and wrong for several, because each one then holds its own copy.

Read “single instance” as a single API worker, not a single container. Since 1.19.0 the same store also counts rate limits, so without it each API worker counts its own buckets and a configured limit is enforced once per worker. The bundled production compose file runs two workers by default, so most deployments want the store even on one host. The limits still apply without it, they are just counted separately, and each worker logs rate_limit_storage_not_configured at startup.

One feature needs it whatever the instance count. Refreshing a protected remote service with a supplied token passes that token to the worker through the shared store, so with no store configured the refresh endpoint refuses the request up front rather than failing an hour later. A first import or a re-upload commit still works without one, because the token travels with the task instead.

The API serves the catalog, tile gateway and feature endpoints. The worker runs background ingestion from the same image with a different entrypoint. TiTiler renders raster tiles for any catalog holding raster data. The frontend runs the bundled nginx.

The frontend is the application edge, not a static file server. It proxies /api, maps /raster-tiles/... onto the API’s raster proxy, blocks the unauthenticated /api/metrics, and rate-limits anonymous raster traffic. Send all public traffic through it. Serving the built bundle from a bucket or CDN skips that edge and breaks raster tiles.

TiTiler runs no GeoLens code. It reads objects through GDAL, which uses AWS’s own variable names rather than the S3_* set the API reads, so a hand-assembled deployment has to set them on the TiTiler container separately. The bundled Compose entrypoint and the Helm chart derive them for you. Each cloud page lists the values for its provider.

Two hops between containers are wired by environment variable, and both default to Compose service names that exist only under Compose. The frontend proxies /api to API_UPSTREAM (http://api:8000) and the API reaches raster rendering at TITILER_BASE_URL (http://titiler:8000). Neither resolves once the containers are separate cloud deployments. Set both to whatever your topology makes reachable, whether that is a loopback address inside one task or a service discovery name. Left alone, /api returns an nginx upstream error and raster tiles never render. TiTiler also has to move off port 8000 wherever it shares a network namespace with the API.

The Docker Compose setup runs scripts/init-db.sh automatically. On a managed database, run the same script from a host that can reach it. It lives in the GeoLens repository alongside the role reconciler it calls, so clone the repo first if this host does not already have a checkout (a container built from the published images does not carry it), then run it with the connection details exported:

Terminal window
git clone https://github.com/geolens-io/geolens.git && cd geolens
POSTGRES_USER=geolens POSTGRES_DB=geolens \
POSTGRES_HOST=geolens-db.abc123.us-east-1.rds.amazonaws.com POSTGRES_PORT=5432 \
PGPASSWORD=<password> ./scripts/init-db.sh

POSTGRES_HOST/POSTGRES_PORT also accept the libpq names PGHOST/PGPORT. The script creates the postgis, pg_trgm, vector, and unaccent extensions, the catalog and data schemas, and the geolens_reader read-only role with its default privileges, then hands off to the same role reconciler the bundled deployment uses. It is idempotent, so re-running it against an already-initialized database is safe.

postgis and vector are not PostgreSQL “trusted” extensions, so the connecting login needs superuser-equivalent authority (rds_superuser, cloudsqlsuperuser, or your provider’s equivalent); pg_trgm and unaccent are trusted and only need CREATE on the database. On Azure Flexible Server, allowlist all four in the azure.extensions server parameter first; no client-side SQL can do that for you. pg_stat_statements is optional; the script skips it with a notice if your provider does not ship it.

Database schema migrations run automatically when the API container starts. After pointing the API at your managed database, start the container and Alembic will apply any pending migrations.

That default breaks if you adopt a least-privilege runtime login via GEOLENS_RUNTIME_DB_ROLE: the API refuses to start (exit code 64) unless you also set GEOLENS_API_RUN_MIGRATIONS=false, because the runtime login must never attempt schema or extension DDL. In that setup, run migrations as a separate, ordered step against a login that has migration privileges, using MIGRATION_DATABASE_URL_OVERRIDE.

Managed database providers support and usually require TLS. Two variables control it:

  • DATABASE_SSL_MODE: verify-full where you can, require otherwise.
  • DATABASE_SSL_CA_CERT: path to the CA certificate file, required by verify-full.

The two are not equivalent. require encrypts the connection but does not check who is on the other end, so an attacker positioned on the path can present their own certificate and read the credentials. verify-full adds that check, which is why it is the right setting whenever the connection crosses anything you do not control.

What stops people using it is that verify-full verifies the hostname against the certificate, and a managed instance reached by bare IP usually presents a certificate naming something else. Reach the instance by a name its certificate carries and verify-full works. Where you cannot, keep the connection on a private network the provider gives you and treat require as the compromise it is.

GeoLens converts postgresql:// to postgresql+asyncpg:// and strips any sslmode parameter from the URL, since DATABASE_SSL_MODE owns that setting. An explicit postgresql+asyncpg:// or postgresql+psycopg:// is accepted too.

GeoLens terminates no TLS of its own. Put a reverse proxy or a load balancer in front of the frontend container on port 8080 and terminate there. On a managed platform the platform’s own load balancer or domain mapping usually does it for you.

On a single host, Nginx, Caddy and Traefik all work. Caddy obtains and renews a Let’s Encrypt certificate on its own once the site address is a public hostname. Traefik needs an ACME certificate resolver defined and named on the TLS router, and without that it quietly serves its own default certificate. Nginx needs certbot or an equivalent alongside it.

The frontend is the only thing that should be reachable. It already proxies /api and /raster-tiles, so the proxy in front of it needs one upstream and no path rules.

Afterwards set PUBLIC_APP_URL to the public hostname and PUBLIC_API_URL to that hostname plus /api. Both feed browser redirects, OGC self-links, OAuth redirects and generated distribution URLs, so leaving them on an internal address hands out links nobody outside can follow.

The neutral set. Storage and database values change per cloud; the cloud pages list the deltas. Configuration Reference has every variable.

Terminal window
# --- Managed database ---
DATABASE_URL_OVERRIDE=postgresql://geolens:<password>@<host>:<port>/geolens
DATABASE_SSL_MODE=require
# DATABASE_POOL_PRE_PING defaults to true since 1.0.0; no need to set it.
# --- Object storage (S3-compatible; see the Azure page for STORAGE_PROVIDER=azure) ---
STORAGE_PROVIDER=s3
S3_BUCKET=geolens-uploads
S3_REGION=<region>
S3_ACCESS_KEY_ID=<access-key-id>
S3_SECRET_ACCESS_KEY=<secret-access-key>
# S3_ENDPOINT stays unset on native AWS S3 and is required everywhere else.
# --- Cache (optional; omit for a single instance) ---
REDIS_URL=redis://<cache-host>:6379/0
# --- Application ---
PUBLIC_APP_URL=https://geolens.example.com
PUBLIC_API_URL=https://geolens.example.com/api
JWT_SECRET_KEY=<openssl rand -hex 32>
GEOLENS_ADMIN_USERNAME=admin
GEOLENS_ADMIN_PASSWORD=<strong-password>
# Hides /docs and /redoc and marks the OAuth session cookie Secure. Set it
# explicitly: when it is unset the posture falls back to LOG_JSON, which is
# only kept for backward compatibility.
ENVIRONMENT=production
LOG_JSON=true

PUBLIC_APP_URL and PUBLIC_API_URL drive browser redirects, OGC self-links and generated distribution URLs, so they have to be the externally reachable HTTPS URLs once TLS is terminated at the edge.

Everything above is the same everywhere. What changes is the storage provider, whether S3_ENDPOINT is set, the database port, and how the containers reach each other.

CloudDatabaseStorageGuide
AWSRDS for PostgreSQL, port 5432S3, S3_ENDPOINT unsetclouds/aws.md
AzurePostgreSQL Flexible Server, port 5432, azure.extensions allowlist firstBlob Storage, STORAGE_PROVIDER=azureclouds/azure.md
Google CloudCloud SQL, port 5432, reached by private IP or the Auth ProxyCloud Storage over the S3 API, S3_ENDPOINT=https://storage.googleapis.comclouds/google-cloud.md
DigitalOceanManaged PostgreSQL, port 25060, trusted sources requiredSpaces, S3_ENDPOINT=https://<region>.digitaloceanspaces.comclouds/digitalocean.md

Each page carries that cloud’s provisioning steps, its CORS snippet where one applies, and its environment delta.

Provision the database, run the init script, and copy the data across before pointing the API at it:

Terminal window
docker compose exec db pg_dump -U geolens -Fc geolens > geolens_backup.dump
pg_restore -h <managed-db-host> -U geolens -d geolens geolens_backup.dump

Then provision the object store, which is an S3-compatible bucket with the CORS policy its presigned uploads need, or an Azure Blob container, which needs none. Swap the environment variables above, start the containers, and check health:

Terminal window
curl https://your-domain.com/api/health

Use the /api prefix. The frontend’s nginx has no /health location, so a request for the bare /health path returns the SPA shell with a 200 and tells you nothing. The unprefixed /health exists only on the API container itself, on port 8000.

GeoLens exposes Prometheus-format metrics and ships reference configuration under infra/monitoring/ in the repository: a scrape config, alert rules, and a Grafana dashboard covering request rate, latency, job queue depth, pool use and tile cache hit ratio.

There are two endpoints and they serve different series. The API exposes /metrics on its own port. The worker runs a separate metrics server on port 8001 and serves the job-queue series there, because the gauges are refreshed by a loop inside the worker process. Both are unauthenticated and excluded from the OpenAPI schema.

MetricServed onTypeDescription
http_requests_total{handler,method,status}apicounterAll HTTP requests handled by FastAPI
http_request_duration_seconds{handler,method}apihistogramRequest latency by route handler
http_requests_inprogress{handler,method}apigaugeCurrently in-flight requests
geolens_db_pool_checkedoutapigaugeConnections checked out from the SQLAlchemy pool
geolens_db_pool_checkedinapigaugeIdle connections in the pool
geolens_db_pool_overflowapigaugeOverflow connections currently open
geolens_db_pool_sizeapigaugeConfigured pool size
geolens_jobs_queue_depth{queue}workergaugeProcrastinate jobs in todo state by queue
geolens_jobs_active{queue}workergaugeProcrastinate jobs in doing state by queue
geolens_jobs_completed_total{queue}workercounterSuccessfully completed Procrastinate jobs
geolens_jobs_failed_total{queue}workercounterFailed Procrastinate jobs

Scrape both. The worker’s metrics port is internal-only and is not published by the bundled compose files, so the collector has to reach it on the private network. Miss that target and the job-queue panels and the queue-backlog alert stay empty. On Cloud Run use the Managed Service for Prometheus; on ECS use the AWS Distro for OpenTelemetry collector sidecar.

The API and worker write structured logs to stdout. LOG_JSON=true emits one JSON object per line, which any aggregator ingests without parsing rules.

Since 1.18.0, liveness and readiness are separate. Point liveness at GET /health/live, which runs no dependency checks, and keep readiness on GET /health, which checks the database, object store and cache:

livenessProbe:
httpGet:
path: /health/live
port: 8000
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 10

Pointing liveness at /health is the pre-1.18.0 shape and means a database, object store or cache outage restart-loops an otherwise-healthy API, instead of failing readiness and dropping out of the load balancer. Readiness returns 200 with {"status": "healthy", "providers": {...}} when every provider reports "status": "ok", and 503 with "status": "degraded" when any probe fails.

The migration aborts with type "geometry" does not exist. PostGIS was never created on this database. Being available in the provider’s parameter group is not the same as the extension existing. Run the init script.

The migration aborts naming vector or unaccent. The baseline migration requires all four extensions and creates none of them. On Azure this is usually the azure.extensions allowlist rather than a privilege problem.

Search queries fail with missing operator classes. pg_trgm is not created.

Uploads fail with a missing Access-Control-Allow-Origin header. The bucket has no CORS policy allowing your application origin. It has to name the public application origin, scheme included.

The application will not start, or reports could not translate host name. Check the connection URL shape against the TLS section above, and check that the host is reachable from wherever the container runs. On DigitalOcean the cluster also rejects connections until the client is added as a trusted source.

/api returns an nginx upstream error, or raster tiles never render. API_UPSTREAM or TITILER_BASE_URL is still at its Compose default. See the container section above.

Anything cloud-specific beyond this lives on that cloud’s page.