Secrets and Credential Management for Spatial Pipelines

A spatial deployment pipeline touches more sensitive material than most: the PostGIS master password, GeoServer’s admin credential, the signing key for a tile CDN, and the cloud provider credentials that provision all of it. Each of these can leak into a Terraform state file, a plan diff, or a CI job log if the pipeline is not designed to keep them out — and unlike a broken tile, a leaked database password is a breach, not an incident. This guide, part of the CI/CD, Drift Detection & Policy Governance framework, establishes how to source, inject, and rotate secrets for geospatial infrastructure so they never land in plaintext. It works alongside Pipeline Orchestration for Spatial Deployments, which runs the applies, and Policy as Code for Spatial Resources, which can gate a plan that would expose a credential before it ever merges.

Where a Spatial Pipeline Secret May and May Not Flow A secrets manager and an SSM parameter store hold the source-of-truth credentials. A CI runner assumes a short-lived OIDC role and reads the secret at apply time, then wires the value into three consumers: the PostGIS master password, the GeoServer admin credential, and the tile-CDN signing key. Two surfaces are marked as forbidden destinations for the plaintext value: the state backend, which must be encrypted and hold only references, and the CI job log, which must have the value masked. Rotation feeds new versions back into the secrets manager on a schedule. Secrets Manager versioned, KMS-encrypted SSM / Vault SecureString params CI Runner assume OIDC role read at apply time PostGIS master pw RDS / Aurora GeoServer admin REST credential Tile-CDN signing key signed URLs inject Rotation Lambda on schedule write new version back to source Plaintext must NEVER reach these State backend encrypted; refs only CI job log value masked sensitive = true suppresses plan output; encryption at rest covers the state object. blocked Design contract 1 - source of truth outside the repo 2 - short-lived creds, no static keys 3 - read at apply, never committed 4 - encrypt state, mask logs 5 - rotate on a schedule, blue/green

Environment Parity and Configuration Drift Mitigation

The first parity failure with secrets is not encryption — it is provenance. When a PostGIS password is typed into a GitHub Actions repository secret for production but pasted from a wiki page for staging, the two environments have already diverged, and no amount of downstream hardening will reconcile them. The mitigation is a single source of truth per environment: a secrets manager path such as prod/gis/postgis-master and staging/gis/postgis-master, provisioned by the same module with the environment as an input. The pipeline reads from the path; nobody types the value anywhere. This keeps the credential lifecycle governed by the same review process as the rest of the estate, and it means a promotion from staging to production changes a path prefix, not a hand-copied string.

Geospatial estates amplify the drift risk because a single logical credential is consumed by several services at once. The PostGIS master password is read by the GeoServer datastore configuration, by a tile-rendering worker pool, and by any batch reprojection job — so a secret that drifts between what the database expects and what one consumer holds does not fail loudly; it fails for a subset of tile requests while the health check on another path stays green. Treat each secret as having exactly one authoritative version and a defined set of consumers, and record that fan-out in the same repository that provisions the infrastructure, so the blast radius of a rotation is knowable before it happens.

The second parity trap is putting the secret’s value, rather than its reference, into a terraform.tfvars or a Pulumi config that lives in the repo. Even an encrypted config file drifts the moment someone edits it out of band, and it couples the credential’s lifecycle to the code’s git history — where it lingers in every clone forever. The durable pattern is to store only the ARN or the parameter name in version control and resolve the value at apply time through a data source. The infrastructure that decides where that resolved value is serialized is State Backend Selection; a backend without encryption at rest turns every secret-bearing resource into a plaintext exposure regardless of how cleanly you sourced it.

CI/CD Validation and Operational Guardrails

Secrets discipline lives or dies in the pipeline. The runner must authenticate to the cloud with short-lived, workload-identity credentials — OIDC federation on AWS, workload identity federation on GCP — so there is no static provider access key to leak in the first place. From there, the secret-handling contract has three enforceable checks. First, the plan output is inspected for the string values of known secrets; a plan that prints a password because a resource attribute was not marked sensitive fails the gate. Second, the job log is scanned by the CI platform’s secret-masking layer, and any command that would echo a resolved secret (a naive terraform output of a sensitive value, an unquoted set -x) is treated as a defect. Third, a Policy as Code for Spatial Resources rule rejects any resource that hardcodes a credential literal instead of referencing a manager.

The most dangerous log leak in a spatial pipeline is the debug apply. When an operator sets TF_LOG=DEBUG to diagnose a stuck GeoServer datastore, Terraform writes the full HTTP request body — including the datastore connection password — to the log, which is then archived by the CI system for weeks. Guardrail this by refusing debug-level logging on any pipeline that resolves secrets, and by routing genuine debugging to an ephemeral, access-controlled runner whose logs are discarded on completion. The same applies to pulumi up --debug, which will emit resolved config values.

Ephemeral credentials are the guardrail that shrinks the window of exposure to near zero. Rather than a long-lived PostGIS application password, the pipeline can provision database access through short-lived credentials — an RDS IAM authentication token valid for fifteen minutes, or a Vault dynamic database secret with a lease that expires after the job. The tile-serving layer still needs a durable credential to stay connected, but batch jobs, migrations, and one-shot spatial imports should use a token that is worthless the moment the job ends. This is the same principle that makes the PostGIS Cluster Provisioning baseline safe to automate: the fewer long-lived secrets exist, the fewer can leak.

Resource Architecture and Service Integration

The secret store sits upstream of nearly every tier, so its integration surface has to be modeled deliberately. The data tier is the primary producer: when Terraform provisions an RDS PostGIS instance, it can generate the master password and write it straight into Secrets Manager, so the value exists in the manager and in the encrypted state, but never in a variable file or a human’s clipboard. The consuming tiers then read that secret by reference. GeoServer, provisioned under GeoServer Deployment Patterns, reads the datastore password at container start from an injected environment variable or a mounted secret, not from its baked image. The tile-CDN signing key follows the same path: stored once, read by the edge configuration and by any service that mints signed URLs.

Access to the store must be scoped as tightly as access to the state backend. The identity that reads prod/gis/postgis-master should be able to read that one secret and nothing else — a wildcard secretsmanager:GetSecretValue on * hands any compromised tile worker the keys to the entire estate. This least-privilege boundary is the same discipline documented in IAM Role Mapping for GIS, applied to the secret ARN rather than the raster bucket. The KMS key that encrypts the secret is a second gate: grant kms:Decrypt only to the roles that legitimately consume the secret, and the key policy becomes an independent audit trail of who could ever have read the value.

The orchestration engine shapes how the reference is resolved. Terraform reads a secret through a data source at plan time and can pass it into a resource marked sensitive, keeping it out of the diff; the value still lands in state, so the encrypted backend is non-negotiable. Pulumi’s secrets provider encrypts individual config values before they are serialized, so a pulumi config set --secret value is ciphertext in both the config file and the state object. Either way, the credential should be resolved as late as possible — at apply time from the manager — rather than captured into a long-lived artifact that outlives the run.

Runnable Configuration

The configuration below provisions an RDS PostGIS instance whose master password is generated in Terraform, stored in AWS Secrets Manager, and wired into the database without the plaintext ever appearing in a variable file or the plan diff. Provider versions are pinned so a plan today reproduces months from now.

terraform {
  required_version = ">= 1.10.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60" # pin so plans are reproducible
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

# 1. Generate the master password inside the run. It exists only in state
#    (which is encrypted) and in Secrets Manager -- never in a tfvars file.
resource "random_password" "postgis_master" {
  length           = 32
  special          = true
  override_special = "!#$%*()-_=+[]{}" # exclude chars that break connection URIs
}

# 2. Create the secret container, encrypted with a customer-managed KMS key.
resource "aws_secretsmanager_secret" "postgis_master" {
  name                    = "prod/gis/postgis-master"
  kms_key_id              = aws_kms_key.secrets.arn
  recovery_window_in_days = 7 # allow undelete after an accidental removal
}

# 3. Store the credential as JSON so rotation can manage user + password together.
resource "aws_secretsmanager_secret_version" "postgis_master" {
  secret_id = aws_secretsmanager_secret.postgis_master.id
  secret_string = jsonencode({
    username = "gis_admin"
    password = random_password.postgis_master.result
  })
}

# 4. Wire the generated password into RDS. manage_master_user_password is an
#    alternative that hands the whole lifecycle to Secrets Manager; here we
#    keep the explicit wiring to show the sensitive path end to end.
resource "aws_db_instance" "postgis" {
  identifier                  = "prod-gis-postgis"
  engine                      = "postgres"
  engine_version              = "16.3"
  instance_class              = "db.r6g.large"
  allocated_storage           = 200
  username                    = "gis_admin"
  password                    = random_password.postgis_master.result # sensitive; stays out of plan
  db_subnet_group_name        = aws_db_subnet_group.postgis.name
  vpc_security_group_ids      = [aws_security_group.postgis.id]
  storage_encrypted           = true
  parameter_group_name        = aws_db_parameter_group.postgis.name
  backup_retention_period     = 14
  deletion_protection         = true
  skip_final_snapshot         = false
  final_snapshot_identifier   = "prod-gis-postgis-final"
}

# 5. A PostGIS-tuned parameter group. shared_buffers is set in AWS 8 kB blocks,
#    NOT a percentage string -- 32768 blocks = 256 MB.
resource "aws_db_parameter_group" "postgis" {
  name   = "prod-gis-postgis16"
  family = "postgres16"

  parameter {
    name  = "shared_buffers"
    value = "32768" # 8 kB blocks -> 256 MB; integer units, never "25%"
  }
}

# Never expose the value as a root output. If a module must return it,
# mark it sensitive so it is redacted from CLI and plan output.
output "postgis_secret_arn" {
  value       = aws_secretsmanager_secret.postgis_master.arn
  description = "ARN consumers read at runtime; the value itself is never output."
}

Note: shared_buffers above is expressed as 32768, an AWS integer count of 8 kB blocks (256 MB), not a percentage string. RDS PostgreSQL memory parameters must use these integer block units; a value like "25%" is rejected by the parameter group. See PostGIS Cluster Provisioning for the full memory-tuning baseline.

For managed rotation, AWS Secrets Manager can attach a rotation Lambda to the secret and drive the password change end to end; the mechanics of doing that without dropping live spatial queries are covered in Rotating PostGIS Credentials in Terraform Without Downtime. For the provider reference on the resources above, see the AWS Secrets Manager Terraform documentation.

Guardrails Embedded in Configuration

The first guardrail is sensitive = true and its equivalents. Any Terraform output, variable, or resource attribute that carries a credential must be marked sensitive so it is redacted from the plan, the apply summary, and the CLI. Marking an output sensitive does not encrypt it in state — that is the backend’s job — but it does stop the value from being printed into a CI log where it would be archived. In Pulumi the analogue is declaring the config value with --secret, which yields a ciphertext everywhere the value would otherwise appear in cleartext.

The second guardrail is encryption at rest for every store the secret touches. The state backend must have server-side encryption enabled, the Secrets Manager secret must be bound to a customer-managed KMS key whose policy names only legitimate consumers, and any SSM parameter holding a credential must be a SecureString, never a plain String. This is defence in depth: even if a state object is exfiltrated, the ciphertext is useless without the KMS grant, and the KMS access itself is logged.

The third guardrail is the generation boundary. Prefer generating credentials inside the run — random_password, or the provider’s native manage_master_user_password — over accepting them as inputs. A password that is generated, stored, and consumed entirely within the automation has no origin outside the system, so there is no wiki page, no Slack message, and no operator laptop where it also exists. This shrinks the set of places a leak can originate to the ones you have already hardened.

The fourth guardrail is scoped read access, expressed as code. The IAM policy that grants secretsmanager:GetSecretValue should name the exact secret ARN, and the KMS key policy should grant kms:Decrypt only to the consuming roles. Wildcards here are the difference between one compromised tile worker leaking one password and it leaking every credential in the account.

Troubleshooting and Failure Modes

Secret written in plaintext to the state file. A resource attribute that holds a credential but is not recognized as sensitive — a custom provider field, a local-exec command line, a template_file rendering a connection string — serializes the value into state as cleartext. Anyone with read access to the state object, or a copy of it in a CI cache, can recover it. The fix is twofold: mark the attribute sensitive so it is at least redacted from output, and ensure the backend is encrypted so the at-rest object is ciphertext. Audit by grepping a state export for known secret fragments; if any appear, treat the secret as compromised and rotate it immediately.

Plaintext credential in a CI job log. A pipeline that runs terraform apply with TF_LOG=DEBUG, or echoes a resolved output for debugging, writes the password into a log the CI platform retains for weeks. The symptom is a secret value visible in a build’s archived output. Remediate by disabling debug logging on secret-resolving pipelines, enabling the platform’s secret masking on all known values, and — because the log has already been written — rotating the exposed credential rather than assuming the archive is private.

Rotation broke live database connections. A naive rotation that changes the PostGIS password in the database and the secret simultaneously drops every pooled connection whose credential is now stale, so GeoServer and the tile workers begin returning connection errors mid-request until they reconnect. The symptom is a spike in database authentication failures at the exact rotation timestamp. The durable fix is a dual-secret, blue/green rotation that keeps the old credential valid until every consumer has picked up the new one; the full procedure is in Rotating PostGIS Credentials in Terraform Without Downtime.

Over-broad secret read grant. An IAM policy that grants secretsmanager:GetSecretValue on * so a tile worker can read one datastore password gives any compromise of that worker access to every secret in the account — provider keys, CDN signing keys, other databases. The symptom is often invisible until an audit; the fix is to scope the grant to the specific secret ARN and to scope the KMS Decrypt grant to the same role, so the key policy becomes a second, independent boundary.

Tile-CDN signing key committed to the repository. A signing key pasted into a config file or a Pulumi program literal lives in git history forever, so revoking it requires rotating the key and rewriting or accepting the exposure of history. The symptom is the key appearing in a git grep across the repo. Prevent it with a Policy as Code for Spatial Resources rule that fails any plan containing a credential literal, and a pre-commit secret scanner that blocks the push before it becomes permanent.