Packaging GDAL Lambda Layers with Terraform

GDAL is the reason serverless geospatial processing is harder than serverless anything else. It is a large native library with a dependency graph — PROJ, GEOS, libtiff, libgeotiff, curl with TLS — that must be compiled against the function runtime’s exact base image, and it needs a data directory of coordinate reference definitions at a path it can find. A layer built on a developer laptop will import successfully and then fail on the first reprojection with an error about a missing PROJ database, which is the characteristic failure of this whole class of work. This guide extends Serverless Geospatial Processing within Geospatial Resource Provisioning.

What actually goes wrong

Four failures account for nearly every broken GDAL layer, and knowing the signature of each saves hours.

PROJ: proj_create_from_database: Cannot find proj.db. The library loaded, the shared objects resolved, and the coordinate reference data is not where PROJ expects it. The data files must be included in the layer and PROJ_LIB (or PROJ_DATA on newer versions) must point at their path inside the function filesystem. This is by far the most common failure and it appears only when a reprojection is attempted, so a smoke test that merely imports the library will not catch it.

GLIBC_2.34 not found. The layer was built against a newer base than the function runtime. Native dependencies must be compiled inside the same image the function runs on, which is why the build belongs in a container rather than on a workstation.

The layer exceeds the size limit. A full GDAL build with every driver is large, and the unzipped limit across all layers plus the function package is finite. Building with only the drivers you need — GTiff, COG, GeoJSON, PostgreSQL, and whatever else the workload genuinely opens — is usually the difference between fitting and not.

Cold starts of several seconds. Loading a large native library and initialising its driver registry is not free. It is tolerable for asynchronous processing and it is a problem on a synchronous path, which is the main argument for keeping heavy raster work off request-driven functions.

The four parts of a working GDAL layer A layer archive contains four things that must each be discoverable at runtime. Shared object libraries live under a lib directory and are resolved through the dynamic linker's library path. The PROJ data directory holds the coordinate reference database and is found only through the PROJ_LIB or PROJ_DATA environment variable. The GDAL data directory holds driver support files and is found through GDAL_DATA. The Python bindings live on the import path. A layer that includes the libraries and bindings but omits the PROJ data will import cleanly, pass a naive smoke test, and fail on the first reprojection with a message about a missing proj database. lib/ libgdal, libproj, libgeos found via LD_LIBRARY_PATH share/proj/ proj.db — the CRS database found ONLY via PROJ_LIB / PROJ_DATA share/gdal/ driver support files found via GDAL_DATA python/ osgeo bindings found via the import path The characteristic failure a layer with libraries and bindings but no PROJ data imports cleanly and passes a naive smoke test then fails on the first reprojection — so the smoke test must reproject, not merely import

Prerequisites and environment assumptions

Terraform 1.6 or later with hashicorp/aws pinned at ~> 5.60. Docker, because the layer must be built inside the function runtime’s base image and there is no reliable substitute. A bucket for layer artifacts, since layers above a few megabytes must be uploaded from object storage rather than inline. A pinned GDAL version — this is not optional for a spatial workload, because reprojection results can differ between PROJ versions at the last decimal places, and a floating version means two runs of the same pipeline can disagree.

Decide early whether you need a custom layer at all. Several maintained public GDAL layers exist and are adequate for common cases; building your own is worth it when you need a specific version pin, a specific driver set, or a smaller artifact than the general-purpose builds provide.

Step-by-step build and deploy

  1. Build inside the runtime base image. Use the function runtime’s own base image as the builder so every native dependency links against the same C library. This one decision eliminates the GLIBC class of failure entirely.

  2. Compile only the drivers you need. Configure the build to include the formats the workload actually opens. Each excluded driver is size, and size is the binding constraint.

  3. Lay the artifact out in the paths the runtime expects. Libraries under lib/, data under share/, Python bindings under python/. Getting the layout wrong produces an import error that looks like a missing package.

  4. Upload the archive and publish a layer version. Publish from the bucket, and let the version be derived from the artifact’s hash so that a rebuild producing identical bytes does not churn a new version.

  5. Set the environment variables in the function, not in the layer. A layer cannot set environment variables; the function must. PROJ_LIB, GDAL_DATA and LD_LIBRARY_PATH are the three, and omitting PROJ_LIB is the failure described above.

  6. Test with a reprojection, not an import. The deployment is not verified until a function has transformed a coordinate between two reference systems.

# Build inside the runtime base image so every native dependency links against
# the same C library the function will run on. This removes the entire GLIBC
# class of failure.
FROM public.ecr.aws/lambda/python:3.12 AS build

ARG GDAL_VERSION=3.9.2
ARG PROJ_VERSION=9.4.1

RUN dnf install -y gcc gcc-c++ make cmake sqlite-devel libtiff-devel \
    libcurl-devel zlib-devel tar gzip && dnf clean all

# PROJ first: GDAL links against it.
RUN curl -sL https://download.osgeo.org/proj/proj-${PROJ_VERSION}.tar.gz | tar xz && \
    cmake -S proj-${PROJ_VERSION} -B /build/proj \
      -DCMAKE_INSTALL_PREFIX=/opt -DBUILD_TESTING=OFF && \
    cmake --build /build/proj --target install -j"$(nproc)"

# Only the drivers this workload opens. Every excluded driver is size, and
# size is the binding constraint on a layer.
RUN curl -sL https://download.osgeo.org/gdal/${GDAL_VERSION}/gdal-${GDAL_VERSION}.tar.gz | tar xz && \
    cmake -S gdal-${GDAL_VERSION} -B /build/gdal \
      -DCMAKE_INSTALL_PREFIX=/opt \
      -DGDAL_USE_INTERNAL_LIBS=ON \
      -DGDAL_ENABLE_DRIVER_GTIFF=ON \
      -DOGR_ENABLE_DRIVER_GEOJSON=ON \
      -DOGR_ENABLE_DRIVER_PG=ON \
      -DBUILD_PYTHON_BINDINGS=ON && \
    cmake --build /build/gdal --target install -j"$(nproc)"

# The layer layout the runtime expects. share/proj is the directory whose
# absence produces "Cannot find proj.db" at the first reprojection.
RUN mkdir -p /layer/lib /layer/share /layer/python && \
    cp -a /opt/lib/*.so* /layer/lib/ && \
    cp -a /opt/share/proj /layer/share/ && \
    cp -a /opt/share/gdal /layer/share/ && \
    cp -a /opt/lib/python3.12/site-packages/osgeo /layer/python/ && \
    cd /layer && zip -qr9 /gdal-layer.zip .
terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.60" }
  }
}

resource "aws_s3_object" "gdal_layer" {
  bucket = var.artifact_bucket
  key    = "layers/gdal-${var.gdal_version}-${filesha256(var.layer_zip_path)}.zip"
  source = var.layer_zip_path
  etag   = filemd5(var.layer_zip_path)
}

resource "aws_lambda_layer_version" "gdal" {
  layer_name          = "gdal"
  s3_bucket           = aws_s3_object.gdal_layer.bucket
  s3_key              = aws_s3_object.gdal_layer.key
  compatible_runtimes = ["python3.12"]
  # Derived from the artifact hash: an identical rebuild publishes no new
  # version, so layer versions track real changes rather than build runs.
  source_code_hash = filebase64sha256(var.layer_zip_path)
  description      = "GDAL ${var.gdal_version} with PROJ ${var.proj_version}, GTiff/COG/GeoJSON/PG only"
}

resource "aws_lambda_function" "reproject" {
  function_name = "raster-reproject"
  role          = aws_iam_role.reproject.arn
  runtime       = "python3.12"
  handler       = "handler.main"
  layers        = [aws_lambda_layer_version.gdal.arn]

  # Raster work is memory and CPU bound; CPU is allocated in proportion to
  # memory, so under-provisioning memory slows the reprojection twice over.
  memory_size = 3008
  timeout     = 300

  environment {
    variables = {
      # A layer cannot set these. The function must, and omitting PROJ_LIB is
      # exactly the failure that only appears on the first reprojection.
      PROJ_LIB        = "/opt/share/proj"
      GDAL_DATA       = "/opt/share/gdal"
      LD_LIBRARY_PATH = "/opt/lib:/var/lang/lib:/lib64:/usr/lib64"
      # Sensible defaults for reading COGs over the network.
      GDAL_DISABLE_READDIR_ON_OPEN  = "EMPTY_DIR"
      GDAL_CACHEMAX                 = "512"
      VSI_CACHE                     = "TRUE"
    }
  }
}

variable "artifact_bucket" { type = string }
variable "layer_zip_path" { type = string }
variable "gdal_version" { type = string }
variable "proj_version" { type = string }

The three read-tuning variables at the end matter more than they look. GDAL_DISABLE_READDIR_ON_OPEN stops GDAL listing a prefix every time it opens an object, which on a bucket with many thousands of scenes turns one open into one request instead of hundreds. VSI_CACHE keeps range reads in memory across calls within an invocation. Together they are frequently the difference between a Cloud Optimized GeoTIFF read that takes 200 milliseconds and one that takes eight seconds, and they cost nothing.

What each smoke test actually proves Importing the osgeo bindings proves that the shared libraries resolved and that the Python path is correct, and nothing else — it passes on a layer with no coordinate reference database at all. Opening a raster additionally proves the GDAL driver support files are present. Only transforming a coordinate between two reference systems reads the PROJ database, which is the file most often missing, so only that test proves the layer is complete. A deployment verified by import alone will fail in production on its first reprojection. import osgeo proves: libraries, import path open a raster proves: GDAL data present transform a coordinate the only test that reads proj.db layer verified deploy with confidence A deployment verified by import alone fails in production at the first reprojection, not at deploy time.

Verification

Invoke the function with a payload that forces a coordinate transformation — EPSG:4326 to EPSG:3857 is sufficient — and assert the returned coordinates. If it returns the PROJ database error, PROJ_LIB is wrong or the data directory was not copied into the layer.

Then confirm the versions the function actually loaded, not the ones you intended to build: osgeo.gdal.__version__ and the PROJ version reported by osgeo.osr. A build that silently linked against a system PROJ will report a different version here, and that is worth knowing before it changes your reprojection results. Finally measure cold-start duration and a representative Cloud Optimized GeoTIFF read with and without the read-tuning variables, so the numbers are recorded rather than assumed.

Check the versions loaded, not the versions built A layer build can succeed while linking against a library other than the one it compiled, so the versions that matter are the ones the running function reports. Query the GDAL version from the bindings and the PROJ version from the spatial reference module inside a real invocation, and compare both against the build arguments. A mismatch is worth knowing before it changes reprojection results at the last decimal places, because that class of difference is discovered much later, in a comparison that suddenly stops matching, and is very hard to attribute after the fact. What you built GDAL_VERSION build argument PROJ_VERSION build argument recorded in the layer description What the function reports osgeo.gdal version at runtime PROJ version from osgeo.osr a mismatch shifts reprojection results That class of difference surfaces much later, in a comparison that stops matching, and is hard to attribute then.

Preventing recurrence

  • Build the layer in CI, never on a workstation. A layer built on a laptop is unreproducible and will eventually be rebuilt by someone whose toolchain differs.
  • Pin GDAL and PROJ and record them in the layer description. When reprojection output changes, the first question is which PROJ produced it, and the layer description is where the answer should already be.
  • Keep the reprojection smoke test in the deployment pipeline. It is the only test that exercises the file most likely to be missing.
  • Watch the unzipped size against the limit. Layers grow as drivers are added, and the limit is reached suddenly during a deploy rather than gradually during development.

Frequently Asked Questions

Why not use a container image instead of a layer?

Container images are often the better answer for GDAL specifically: the size limit is far higher, the build is a plain Dockerfile, and there is no layer path convention to get wrong. Layers remain preferable when several functions share one GDAL build and you want a single artifact to update, and when cold-start behaviour with a small function package matters.

Can I set PROJ_LIB in the layer instead of the function?

No. Layers contain files only; environment variables belong to the function. Every function using the layer must set them, which is why they belong in a shared module rather than being copied into each function definition.

How much memory does raster processing need?

More than feels necessary, because CPU is allocated in proportion to memory: under-provisioning slows the work twice, once through memory pressure and once through a smaller CPU share. Start near the top of the range for raster work and measure down, rather than starting low and wondering why it is slow.

Does a public prebuilt GDAL layer avoid all of this?

It avoids the build, not the configuration — you still set the environment variables, and you still need a reprojection smoke test. What you give up is the version pin and the driver-set control, which for a pipeline whose outputs must be reproducible across years is usually the reason to build your own.