Object Storage for Raster/Vector Workloads in Spatial IaC
Object storage is the persistence layer that most often drifts out of sync between environments on a spatial platform, because buckets accumulate ad-hoc lifecycle rules, CORS exceptions, and IAM grants long after the original deployment. Treating a bucket as an undifferentiated data dump is what breaks tile caches, leaks credentials, and inflates egress bills. This guide covers how to provision raster, vector, and tile storage as codified infrastructure within the broader Geospatial Resource Provisioning discipline, so that the hot path feeding PostGIS Cluster Provisioning and the compute fleet defined in Compute Node Orchestration consume exactly the assets they are authorized to, in every region, from the same commit.
Environment Parity and Configuration Drift Mitigation
Environment parity is the primary guardrail for object storage, and it fails quietly. A staging bucket that lacks the production KMS key, omits a tiles/ lifecycle transition, or carries a looser public-access posture will pass functional tests and then corrupt behavior only under production load. Parity must be enforced structurally: bucket name, storage class defaults, versioning state, encryption key, public-access block, CORS rules, and event-notification subscriptions all belong in the same module, parameterized only by environment and region.
The recurring drift sources for spatial buckets are specific. First, CORS exceptions added by hand so a browser map client can fetch tiles — these never make it back into code and break the next apply. Second, lifecycle transitions edited in the console to relieve a cost spike, which then archive objects that a time-series raster job still reads. Third, prefix-scoped IAM widened to s3:* during an incident and never narrowed again. Lock all three in the module and treat the bucket configuration as immutable infrastructure: changes flow through a pull request or they do not happen. A scheduled terraform plan (or pulumi preview) on a cron, failing the build on any non-empty diff, converts silent drift into a visible alarm before it reaches a promotion cycle.
CI/CD Validation and Operational Guardrails
Object-storage changes deserve the same scrutiny as application code, with checks tuned to the geospatial failure modes. Pre-apply pipeline stages should:
- Validate uploaded raster integrity by running
gdalinfoagainst staged sample objects, confirming projection, band count, and overview presence before the asset becomes addressable to consumers. - Lint policies-as-code with Open Policy Agent (OPA) or HashiCorp Sentinel to reject any plan that disables encryption, drops the public-access block, or grants wildcard
s3:*actions. - Diff lifecycle rules against the access-log-derived retention model, so a transition that would archive still-hot tiles fails review rather than silently shipping.
- Verify CORS and event-notification subscriptions are present and scoped to the expected origins and prefixes, catching the most common manual-exception regressions.
These checks belong in pull-request workflows alongside the access-boundary rules described in IAM Role Mapping for GIS, and they pair with automated rollback triggers: if a post-apply synthetic read/write against the tiles/ prefix fails, the pipeline reverts the bucket policy to the last known-good state from the State Backend Selection backend.
Resource Architecture and Service Integration
Object storage is the data plane for nearly every other spatial resource, and its prefix layout is an interface contract, not an implementation detail. A disciplined three-prefix scheme — rasters/, vectors/, tiles/ — lets IAM, lifecycle, and event rules attach to access patterns rather than to individual objects:
rasters/holds source imagery and Cloud-Optimized GeoTIFFs (COGs). Consumers stream byte ranges directly, so these objects stay on hot storage and are read by GDAL-based workers in the compute fleet and by raster ETL that hydrates spatial indexes.vectors/holds GeoParquet, GeoJSON, and FlatGeobuf extracts. These feed PostGIS Cluster Provisioning through foreign data wrappers orCOPY-based loaders.tiles/holds rendered raster and vector tile pyramids served by GeoServer Deployment Patterns behind a CDN. This prefix is the dominant egress and request driver.
Because the bucket endpoint and IAM role names become inputs to those downstream stacks, expose them as typed module outputs and consume them via remote state or stack references — never hard-code an ARN. This keeps the dependency graph explicit and lets a region or account move without editing every consumer.
Sizing the tile pyramid
Storage planning for the tiles/ prefix is driven by the pyramid geometry, not by guesswork. A full web-mercator pyramid rendered to maximum zoom Z contains a predictable object count, since each zoom level holds 4^z tiles:
$$ N_{\text{tiles}} = \sum_{z=0}^{Z} 4^{z} = \frac{4^{Z+1} - 1}{3} $$
Multiplying N_tiles by the mean encoded tile size yields the cache footprint; most platforms render densely only over populated areas, so apply a coverage fraction per level rather than assuming a full pyramid. Feeding this estimate into the Cost Estimation Frameworks pre-apply gate keeps a new high-zoom layer from quietly multiplying storage cost.
Runnable Configuration
The following Terraform provisions a hardened raster/vector/tile bucket with pinned providers, KMS encryption, versioning, an account-aligned public-access block, intelligent tiering for the tiles/ prefix, and prefix-scoped least-privilege access. Every geospatial-specific decision is annotated inline.
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
resource "aws_s3_bucket" "spatial_assets" {
bucket = "${var.environment}-spatial-assets-${var.region}"
tags = {
Workload = "raster-vector-pipeline"
DataClass = "internal-spatial"
Environment = var.environment
}
}
# Versioning protects against partial tile-pyramid overwrites and
# lets a corrupted ETL load be rolled back to the prior object version.
resource "aws_s3_bucket_versioning" "spatial_assets" {
bucket = aws_s3_bucket.spatial_assets.id
versioning_configuration {
status = "Enabled"
}
}
# Customer-managed KMS, not SSE-S3: spatial imagery is often
# licensed/regulated, so the key boundary must be auditable.
resource "aws_s3_bucket_server_side_encryption_configuration" "spatial_assets" {
bucket = aws_s3_bucket.spatial_assets.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = var.spatial_kms_key_id
}
bucket_key_enabled = true # cuts KMS request cost on high-fanout tile reads
}
}
# Deny public access at the bucket level even if a stray ACL slips through.
resource "aws_s3_bucket_public_access_block" "spatial_assets" {
bucket = aws_s3_bucket.spatial_assets.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Auto-tier only the tile cache: rendered tiles cool predictably,
# while source rasters and vectors stay on the hot path.
resource "aws_s3_bucket_intelligent_tiering_configuration" "tiles" {
bucket = aws_s3_bucket.spatial_assets.id
name = "tiles-archive"
filter {
prefix = "tiles/"
}
tiering {
access_tier = "ARCHIVE_ACCESS"
days = 90
}
}
# Least-privilege read scoped to the three spatial prefixes only.
resource "aws_iam_policy" "dataset_prefix_scoped" {
name = "${var.environment}-spatial-prefix-access"
description = "Least-privilege access scoped to rasters, vectors, and tiles"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:GetObject", "s3:ListBucket"]
Resource = [
aws_s3_bucket.spatial_assets.arn,
"${aws_s3_bucket.spatial_assets.arn}/rasters/*",
"${aws_s3_bucket.spatial_assets.arn}/vectors/*",
"${aws_s3_bucket.spatial_assets.arn}/tiles/*"
]
}
]
})
}
output "spatial_bucket_arn" {
description = "Consumed by PostGIS, GeoServer, and compute stacks via remote state."
value = aws_s3_bucket.spatial_assets.arn
}
Guardrails Embedded in Configuration
The configuration above encodes four guardrails that exist precisely because they are the parts teams forget under pressure.
State locking. The bucket definition lives in a remote backend with mandatory locking (S3 + DynamoDB, or the Pulumi Service), aligned with State Backend Selection. Concurrent applies on the same state are what produce half-written lifecycle rules and orphaned policies; the lock makes that race impossible rather than merely unlikely.
Secret and key management. No access keys appear in the module. Compute and middleware assume the prefix-scoped role at runtime, and the encryption key is referenced by ARN (var.spatial_kms_key_id) so key rotation is a key-policy change, never an infrastructure rewrite.
Network isolation. Internal consumers reach the bucket exclusively through a VPC gateway endpoint, keeping raster and tile traffic off the public internet and removing egress as an exfiltration path. The endpoint policy itself should restrict the same three prefixes, so a compromised node cannot enumerate the whole account.
Prefix scope as a blast-radius control. Because IAM, lifecycle, and intelligent tiering all bind to rasters/, vectors/, and tiles/, a mistake is contained to one access class. Widening a grant to s3:* or to the bucket root defeats every other guardrail at once, which is exactly why the OPA gate rejects it.
Troubleshooting and Failure Modes
- S3 prefix scope mismatch. A GeoServer node returns
403 AccessDeniedon tiles while raster reads succeed. The role was scoped torasters/*andvectors/*but thetiles/*ARN was dropped during a refactor. Diff the rendered policy against the three-prefix contract and restore the missing resource line. - VPC endpoint policy gap. Reads work from a developer laptop but time out from inside the VPC. The gateway endpoint exists but its policy omits
s3:GetObjectfor the bucket, so in-VPC requests are silently denied while public DNS still resolves. Align the endpoint policy with the prefix-scoped IAM policy. - Premature lifecycle archival. A time-series raster job throws on objects that moved to
ARCHIVE_ACCESS. The transition window was shorter than the analytics retention SLA. Validate transitions against real access logs before applying, and excluderasters/from any archive rule. - KMS throttling on tile fanout. A pan-and-zoom storm produces
ThrottlingExceptionfrom KMS because each tile read decrypts independently. Confirmbucket_key_enabled = trueso a single bucket-level data key serves the high-fanout reads. - CORS regression after redeploy. Browser map clients fail with opaque CORS errors after an
applyoverwrites a hand-added exception. Move the CORS rule into the module and verify it in CI, mirroring the approach in CORS & CSP Configuration.
Related
- Geospatial Resource Provisioning — the parent domain this storage layer sits within
- PostGIS Cluster Provisioning — the spatial datastore hydrated from the
vectors/prefix - GeoServer Deployment Patterns — the tile server that consumes the
tiles/prefix - Compute Node Orchestration — GDAL workers that stream from
rasters/ - Configuring S3 Lifecycle Rules for GIS Tiles — the detailed tiering walkthrough
- IAM Role Mapping for GIS — the access-boundary model the prefix scoping enforces