Skip to content
getgeolens.com

Deploy on Amazon EKS

Run GeoLens on Amazon EKS using the community Helm chart. This page covers only what is specific to Kubernetes on AWS; the managed-service prerequisites — creating the PostGIS extensions, the database initialization SQL, and the S3 CORS policy for presigned uploads — are shared with the Compose path and live in Self-host on AWS, GCP, or DigitalOcean.

  • An EKS cluster. Kubernetes 1.31–1.36 are offered by EKS today; this guide was validated on 1.33. Note that 1.33 left standard support on 2026-07-29, so give a new cluster a standard-support version (1.34–1.36): extended-support versions bill the control plane at several times the standard rate.

  • An RDS PostgreSQL instance (see Database below).

  • An S3 bucket in the same region.

  • An ingress controller and a way to issue certificates. The chart renders a standard Ingress (ingress.className plus annotations), so any controller that reconciles Ingress works — a Gateway API implementation alone is not enough, because the chart renders no HTTPRoute. This guide was validated with ingress-nginx and cert-manager, but ingress-nginx was retired upstream in March 2026 and no longer receives releases or security fixes, so pick a maintained controller for a new internet-facing install: on EKS the natural choice is the AWS Load Balancer Controller; Traefik and HAProxy qualify too. The routing and client-IP mechanics below translate to other controllers; TLS does not. With ALB, skip the cert-manager steps — the listener terminates with an ACM certificate — and set its three deltas together:

    ingress:
    className: alb
    annotations:
    # each of these overrides an ALB default that breaks this chart:
    alb.ingress.kubernetes.io/scheme: internet-facing # default is internal
    alb.ingress.kubernetes.io/target-type: ip # instance targets need NodePort; the chart's Service is ClusterIP
    alb.ingress.kubernetes.io/certificate-arn: <acm-arn> # ALB ignores tls.secretName

The chart’s images are published for both linux/amd64 and linux/arm64, so Graviton node groups are a reasonable choice on cost.

RDS PostgreSQL 18 carries everything GeoLens needs, verified on 18.4:

| Extension | Version on RDS PG 18.4 | | --- | --- | | postgis | 3.6.3 | | postgis_raster | 3.6.3 | | vector (pgvector) | 0.8.2 | | pg_trgm | 1.6 | | unaccent | 1.1 | | pg_stat_statements | 1.12 |

pg_stat_statements is in shared_preload_libraries on the default postgres18 parameter group already, so it needs no parameter-group change — just CREATE EXTENSION.

Create the extensions, schemas and reader role once with a privileged role before installing, exactly as in the shared prerequisites. The migrate hook fails with schema "data" does not exist otherwise.

Set the TLS mode through the chart, not through the DSN:

database:
sslMode: require

verify-full additionally authenticates the server and needs the RDS CA bundle mounted as a file into the api, worker and migrate pods — the migrate hook runs before the api rolls, so a bundle it cannot read fails the upgrade there. The chart README’s external-database section carries that recipe.

Use storage.backend: s3. Give the application a principal scoped to the one bucket:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket", "s3:GetBucketLocation", "s3:ListBucketMultipartUploads"],
"Resource": "arn:aws:s3:::YOUR-BUCKET"
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject", "s3:PutObject", "s3:DeleteObject",
"s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"
],
"Resource": "arn:aws:s3:::YOUR-BUCKET/*"
}
]
}

TiTiler only ever reads managed rasters, so it can take a narrower principal — s3:GetObject under rasters/* and tenants/*/rasters/* only — selected with titiler.s3Credentials. That split was verified against real IAM: TiTiler rendered tiles while being denied both writes and reads outside the raster prefixes.

staging.persistence is for a shared /app/staging filesystem. On EKS with storage.backend: s3, leave it off. Uploads go to S3 under a relative key and the worker fetches them from there, so the api/worker handoff never needs a shared volume — verified by ingesting vector and raster data across pods with no shared volume at all.

Enabling it with chart defaults cannot work on a stock EKS cluster, for two independent reasons:

  • accessModes defaults to ReadWriteMany, which the EBS CSI driver rejects outright (Volume capabilities not supported). RWX on AWS means EFS.
  • An empty storageClass renders no storageClassName, which requires a cluster default StorageClass. An eksctl-built cluster has gp2 but does not mark it default, so the claim sits Pending forever.

If you do want a shared filesystem, use the EFS CSI driver and set staging.persistence.storageClass explicitly.

Start from examples/values-aws.yaml in the chart repo, then:

Terminal window
helm repo add geolens https://geolens-io.github.io/geolens-deployments
helm upgrade --install geolens geolens/geolens -f values-aws.yaml

Keep the database DSN and admin credentials in a Secret you manage and point secrets.existingSecret at it, so nothing sensitive reaches a shell history or a values file in Git.

The chart routes all traffic to the frontend, whose bundled nginx is the application edge: it proxies /api, caches raster tiles, blocks the unauthenticated /api/metrics, and rate-limits anonymous raster traffic. Do not add ingress paths that bypass it.

ingress:
enabled: true
className: nginx
host: geolens.example.com
annotations:
cert-manager.io/cluster-issuer: letsencrypt
tls:
- hosts: [geolens.example.com]
secretName: geolens-tls

With ingress-nginx and cert-manager in front, the edge behaves correctly end to end: security headers survive both nginx layers, /api/metrics returns 404, public raster tiles cache and hit, and dataset exports keep a strong ETag with working range requests.

frontend.trustedProxyCidrs tells the app edge which proxies to trust when resolving X-Forwarded-For. Without it, every request is attributed to the ingress controller, so the anonymous raster rate limit shares one bucket across all users and access logs lose the real client.

On EKS that setting is necessary but not sufficient. With the default externalTrafficPolicy: Cluster, kube-proxy translates the source address before the ingress controller ever sees it — measured on a real cluster, the controller’s own log showed a node IP for a request from a public one, so there was nothing left for the app to recover.

helm upgrade runs Alembic as a pre-upgrade hook Job before any new pod rolls, then rolls the Deployments. An upgrade from chart 0.4.23 / app 1.14.0 to 1.14.1 was validated with live state present: dataset IDs, feature counts, visibility and duplicate-named maps all came through unchanged, and a bound PVC kept the same underlying volume.

An RWO staging PVC does not work here. The api and worker Deployments both mount it, and an EBS volume attaches to one node at a time, so the two consumers collide whenever the scheduler separates them — a Multi-Attach error, in steady state as much as during an upgrade. Rolling upgrades make the collision certain (the replacement pod comes up before the outgoing one dies, and dropping to one replica does not help: a one-replica RollingUpdate still surges), but a non-surging strategy such as Recreate or maxSurge: 0 only removes the overlap within a single Deployment — nothing pins api and worker to the same node.

That leaves EFS, which is ReadWriteMany and sidesteps the whole question. Simplest of all: leave staging.persistence off and use S3, per the section above.

The chart ships no backup workload. On EKS that means:

  • The database is your provider’s responsibility. RDS automated backups plus point-in-time restore work, and were drilled end to end: a deleted dataset was recovered by restoring to a timestamp before the incident. Two timings matter — the restorable window trails real time by several minutes, and provisioning the restored instance took about ten more. A restore also produces a new endpoint, so recovery ends by re-pointing the DSN — secrets.databaseUrlOverride, or your external Secret under secrets.existingSecret — and then restarting the pods that consume it (kubectl rollout restart deploy/<release>-api deploy/<release>-worker): running pods keep the old environment until they restart. The RUNBOOK linked below has the full ordered cutover, including quiescing the writers first.

  • Object storage is covered by nothing GeoLens runs. Enable bucket versioning before you need it:

    Terminal window
    aws s3api put-bucket-versioning --bucket YOUR-BUCKET \
    --versioning-configuration Status=Enabled

A database-only restore recovers your catalog asymmetrically, which is the part worth internalising:

  • Vector datasets come back whole — their features live in PostGIS. A lost object costs the original upload file and the quicklook thumbnail, not the data.
  • Uploaded rasters do not. A COG you ingested exists only in your bucket, so the row returns reading published while every tile request fails, and nothing in the catalog marks it broken.
  • Rasters imported by reference are fine, as far as GeoLens is concerned: STAC and public-COG imports keep the upstream asset URL and are served from it, so a restore recovers a working pointer — for as long as that upstream asset exists, which is someone else’s retention policy.

The full procedure, including the Kubernetes-specific steps, is in RUNBOOK.md.