Orchestrating COG Generation Pipelines with Step Functions
You want a raw GeoTIFF dropped into an S3 prefix to become a validated Cloud-Optimized GeoTIFF (COG) in the tile bucket automatically, with no operator in the loop and no half-written objects when a conversion fails. The reliable way to build that is an AWS Step Functions state machine that validates the upload, runs gdal_translate with overviews on a Lambda or AWS Batch task, verifies the result, and only then publishes. This task-specific guide belongs to Raster Data Pipeline Provisioning within the broader Geospatial Resource Provisioning discipline, and it focuses on the orchestration itself: the ASL definition, the retry and catch semantics, the Map state for parallel tiles, and the cost of getting it wrong.
Symptom Identification and Triage
The failures that push teams toward a state machine are recognizable, and each one points at a different missing piece of orchestration. Before writing any ASL, classify what is actually going wrong:
- Corrupt or non-COG tiles reaching the serving path:
rio cogeo validatefails on objects intiles/, or a tile client reads garbage. A conversion crashed and left a truncated file where a reader could see it. This is a missing validate-before-publish gate. - Conversions that silently never happen: an upload lands but no COG appears, and there is no error anywhere. A fire-and-forget Lambda trigger swallowed the failure. This needs a durable orchestrator with a catch clause, not a raw S3-to-Lambda notification.
States.Timeoutor Lambda 15-minute limit hit: a large orthomosaic exceeds Lambda’s ceiling mid-gdal_translate. The compute target is wrong for the payload size — the job belongs on Batch.- Retry storms and duplicated outputs: the same source is converted repeatedly and produces duplicate objects. The state machine is not idempotent and its retry policy does not distinguish transient from permanent errors.
Prerequisites and Environment Assumptions
This guide assumes an AWS target with a raw-upload prefix and a tile bucket already provisioned per Object Storage for Raster & Vector. To deploy the orchestration you will need:
- Terraform
>= 1.7.0with the AWS provider pinned to~> 5.60. Unpinned providers cause phantom diffs on the state machine definition, so pin the version explicitly. - A GDAL container image referenced by digest for the Batch path (a rebuilt
:latestchanges COG block size and predictor and breaks readers), plus a Lambda packaged with GDAL for the small-file path. - An SQS queue with a dead-letter queue receiving the S3
ObjectCreatedevent, so uploads are a durable trigger rather than a fire-and-forget notification. The task role must read the ingest prefix, write staging, and promote totiles/, following IAM Role Mapping for GIS. rio cogeoandgdalinfoavailable in the validation task and on the operator’s machine for the verification step below.
Step-by-Step Remediation
Build the state machine so that no object reaches the serving path unless it was validated both before and after conversion.
-
Validate the upload before spending compute. The first state runs
gdalinfoon the raw object and asserts it is a readable, georeferenced raster with a known projection. Rejecting a bad upload here costs a few hundred milliseconds instead of a multi-minute Batch job that would fail anyway. The geospatial reason: a source with no CRS or a broken IFD will produce a COG that validates structurally but is spatially meaningless. -
Branch by payload size to the right compute. A
Choicestate routes small rasters to a Lambda task and large ones to an AWS Batch job. Lambda’s 15-minute ceiling and 10 GB memory cannot build overviews on a large orthomosaic, so anything above the threshold must go to Batch. Route on estimated decompressed pixel count fromgdalinfo, not on the S3 object size, because a highly compressed source expands far larger than its byte count. -
Convert to COG with overviews. The task runs
gdal_translatewith the COG driver and internal overviews, writing to astaging/key derived deterministically from the source key and version so a replay is idempotent:gdal_translate "/vsis3/${SRC_BUCKET}/${SRC_KEY}" \ "/vsis3/${STAGING_BUCKET}/${OUT_KEY}" \ -of COG \ -co COMPRESS=DEFLATE \ -co PREDICTOR=2 \ -co OVERVIEWS=AUTO \ -co BLOCKSIZE=512 \ -co NUM_THREADS=ALL_CPUS -
Validate the output, then publish atomically. A post-conversion state runs
rio cogeo validateon the staging object and only on success copies it totiles/and updates the manifest. The manifest write is last, so a reader never sees a tile whose entry does not yet exist, and never sees an object the converter did not confirm.
Here is the core of the state machine as pinned Amazon States Language, with retry and catch on every task and a Map state that builds per-region tile sets in parallel. The ${...} tokens are Terraform templatefile interpolations resolved at apply time:
{
"Comment": "Ingest a raw GeoTIFF, convert to COG with overviews, validate, publish",
"StartAt": "ValidateUpload",
"States": {
"ValidateUpload": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "${validate_lambda_arn}",
"Payload.$": "$"
},
"Retry": [
{
"ErrorEquals": ["Lambda.ServiceException", "Lambda.TooManyRequestsException"],
"IntervalSeconds": 2,
"MaxAttempts": 4,
"BackoffRate": 2.0
}
],
"Catch": [
{ "ErrorEquals": ["BadRasterError"], "Next": "RejectUpload" }
],
"ResultPath": "$.validation",
"Next": "RouteBySize"
},
"RouteBySize": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.validation.megapixels",
"NumericGreaterThan": ${size_threshold_mp},
"Next": "ConvertOnBatch"
}
],
"Default": "ConvertOnLambda"
},
"ConvertOnLambda": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "${cog_lambda_arn}",
"Payload.$": "$"
},
"Retry": [
{
"ErrorEquals": ["States.Timeout", "Lambda.ServiceException"],
"IntervalSeconds": 5,
"MaxAttempts": 2,
"BackoffRate": 2.0
}
],
"Catch": [
{ "ErrorEquals": ["States.ALL"], "Next": "SendToDLQ" }
],
"ResultPath": "$.cog",
"Next": "ValidateCog"
},
"ConvertOnBatch": {
"Type": "Task",
"Resource": "arn:aws:states:::batch:submitJob.sync",
"Parameters": {
"JobDefinition": "${batch_job_defn}",
"JobQueue": "${batch_job_queue}",
"JobName": "cog-convert",
"Parameters": {
"input_key.$": "$.detail.object.key",
"output_key.$": "$.cog.out_key"
}
},
"Retry": [
{
"ErrorEquals": ["Batch.AWSBatchException"],
"IntervalSeconds": 10,
"MaxAttempts": 3,
"BackoffRate": 2.0
}
],
"Catch": [
{ "ErrorEquals": ["States.ALL"], "Next": "SendToDLQ" }
],
"ResultPath": "$.cog",
"Next": "ValidateCog"
},
"ValidateCog": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "${validate_cog_lambda_arn}",
"Payload.$": "$"
},
"Catch": [
{ "ErrorEquals": ["States.ALL"], "Next": "SendToDLQ" }
],
"Next": "PublishTiles"
},
"PublishTiles": {
"Type": "Map",
"ItemsPath": "$.cog.regions",
"MaxConcurrency": 8,
"Iterator": {
"StartAt": "PromoteRegion",
"States": {
"PromoteRegion": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "${publish_lambda_arn}",
"Payload.$": "$"
},
"End": true
}
}
},
"Next": "UpdateManifest"
},
"UpdateManifest": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "${manifest_lambda_arn}", "Payload.$": "$" },
"End": true
},
"RejectUpload": { "Type": "Fail", "Error": "BadRasterError", "Cause": "Source failed gdalinfo validation" },
"SendToDLQ": {
"Type": "Task",
"Resource": "arn:aws:states:::sqs:sendMessage",
"Parameters": { "QueueUrl": "${dlq_url}", "MessageBody.$": "$" },
"Next": "FailRun"
},
"FailRun": { "Type": "Fail", "Error": "PipelineError", "Cause": "Conversion failed; message sent to DLQ" }
}
}
The Terraform that ships this definition pins the provider and templates the ARNs so the identical ASL deploys to every environment:
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
resource "aws_sfn_state_machine" "cog" {
name = "${var.environment}-cog-generation"
role_arn = var.pipeline_role_arn
definition = templatefile("${path.module}/cog.asl.json", {
validate_lambda_arn = var.validate_lambda_arn
cog_lambda_arn = var.cog_lambda_arn
validate_cog_lambda_arn = var.validate_cog_lambda_arn
publish_lambda_arn = var.publish_lambda_arn
manifest_lambda_arn = var.manifest_lambda_arn
batch_job_defn = var.batch_job_defn_arn
batch_job_queue = var.batch_job_queue_arn
dlq_url = var.dlq_url
size_threshold_mp = var.size_threshold_mp # megapixels, not bytes
})
logging_configuration {
log_destination = "${aws_cloudwatch_log_group.cog.arn}:*"
include_execution_data = true
level = "ALL" # full history: COG builds are expensive to reproduce
}
}
resource "aws_cloudwatch_log_group" "cog" {
name = "/aws/states/${var.environment}-cog-generation"
retention_in_days = 30
}
Note: If the Lambda COG task reads from an RDS-backed catalog, keep any parameter-group memory setting (for example
shared_buffers) expressed in AWS integer units — 8 kB blocks — never a percentage string, so the value resolves identically across instance classes.
Verification
Confirm the pipeline produces genuinely valid COGs, not just objects that exist.
-
Validate the published object as a COG.
rio cogeo validateis the authoritative check — it asserts the internal tiling and overview structure a naivegdal_translatecan omit:rio cogeo validate s3://prod-tiles/tiles/scene-2026-07/cog.tif # expect: "…is a valid cloud optimized GeoTIFF" -
Inspect the structure with gdalinfo. Confirm the overviews and block size the pipeline was supposed to build actually landed:
gdalinfo /vsis3/prod-tiles/tiles/scene-2026-07/cog.tif | grep -E "Overviews|Block" # expect internal overviews (e.g. 4096x4096, 2048x2048, …) and Block=512x512 -
Confirm the execution path. In the Step Functions console or via
aws stepfunctions get-execution-history, verify the run took the expected branch (Lambda for small, Batch for large), thatValidateCogsucceeded beforePublishTiles, and thatUpdateManifestwas the final state. An execution that reachedSendToDLQmeans the object was never promoted, which is the correct fail-closed behavior.
Preventing Recurrence
Encode the guarantees so a future change cannot reintroduce corrupt tiles:
- Validate-before-publish is non-negotiable. Keep
ValidateCogas a required predecessor ofPublishTilesin the ASL, and add a CI check that fails any state-machine change where a publish path bypasses the COG validation state. - Pin the GDAL image by digest. A policy-as-code rule should reject any Batch job definition referencing a floating tag, mirroring the digest discipline in Raster Data Pipeline Provisioning.
- Right-size the Lambda-to-Batch threshold. Route on decompressed megapixels and review the threshold whenever a new high-resolution source class is onboarded, so large payloads never hit the Lambda timeout. The provisioning trade-offs for the Lambda side are covered in Provisioning Lambda for On-the-Fly Raster Reprojection.
- Scheduled drift check. Run
terraform planon a cron and fail the build on any non-empty diff, so a console edit to the state machine or the Batch sizing surfaces immediately.
Frequently Asked Questions
Should the conversion run on Lambda or AWS Batch?
Both, chosen by a Choice state on payload size. Lambda handles small rasters cheaply with fast cold-to-result latency, but its 15-minute timeout and 10 GB memory cannot build overviews on a large orthomosaic. Route anything above your megapixel threshold to Batch, which can request tens of gigabytes of memory and hours of runtime.
How do I stop a failed conversion from publishing a corrupt COG?
Convert to a staging/ prefix, run rio cogeo validate in a dedicated ValidateCog state, and only copy to tiles/ on success. The publish step must never write directly to the serving path, and the manifest update must be the last write so readers never see an object the validator did not confirm.
What does the Map state buy me here?
The Map state fans out per-region (or per-tile-set) publishing in parallel with a bounded MaxConcurrency, so a scene covering several output regions promotes concurrently without you writing a parallel loop. Keeping concurrency bounded avoids overwhelming the S3 request rate on the tiles/ prefix.
How is the pipeline cost dominated?
By Batch compute time on large mosaics and by S3 requests during publish, not by Step Functions state transitions, which are fractions of a cent. Size Batch memory from the tile-pyramid math and keep small files on Lambda so you pay for a multi-vCPU job only when the payload genuinely needs it.
Related
- Raster Data Pipeline Provisioning — the parent guide covering the full ingest-to-publish pipeline
- Geospatial Resource Provisioning — the domain this orchestration sits within
- Provisioning Lambda for On-the-Fly Raster Reprojection — the Lambda-side provisioning the small-file path reuses
- Object Storage for Raster & Vector — the bucket and prefix layout the pipeline reads and writes