TLS and Certificate Automation for Tile Endpoints

Every tile a browser fetches over https:// is gated by a certificate that must be valid, in scope for the exact subdomain serving the tile, and renewed before it expires — and a single lapsed or mis-scoped certificate takes an entire basemap offline with a hard NET::ERR_CERT_* interstitial that no application code can recover from. TLS provisioning for tile, WMS, and WMTS endpoints therefore belongs in version-controlled infrastructure alongside the rest of the Network Security & Access Control framework, not in a console where a certificate quietly ages out. It has to stay coordinated with the header contract defined in CORS & CSP Configuration — because a strict-transport policy and a mixed-content-free page are worthless if the certificate behind them is invalid — and with the private data plane in VPC Routing for Tile Servers, so the edge that terminates TLS is the only reachable entry point to the tile origin.

Map delivery makes certificate automation unusually unforgiving. A web map fans a session out across several hostnames — tiles.example.com, a.tiles.example.com through d.tiles.example.com for sharded raster CDNs, wms.example.com for OGC services, style.example.com for glyphs and sprites — and the browser validates the certificate on every one independently. One host missing from the certificate’s subject alternative names (SANs) produces a map where three of four tile shards render and the fourth throws a certificate error, an intermittent, zoom-dependent failure that is miserable to reproduce by hand. Codifying issuance, DNS validation, distribution association, and the TLS policy in Terraform turns all of that into a reviewable artifact that promotes cleanly from staging to production.

End-to-End ACM Certificate Automation for a Tile CDN A left-to-right pipeline. Terraform declares an aws_acm_certificate in us-east-1 with the apex host tiles.example.com plus sharded SANs a-d.tiles.example.com and wms.example.com. ACM returns DNS validation records, which Terraform writes as CNAMEs into the Route53 hosted zone for the domain. An aws_acm_certificate_validation resource blocks until ACM confirms the records and issues the certificate. The issued certificate ARN is referenced by the CloudFront distribution viewer_certificate, which terminates TLS at the edge for the browser WebGL tile client. Behind CloudFront, the tile origin sits in a private subnet reachable only through the distribution. A dashed loop shows ACM auto-renewing the certificate as long as the validation CNAMEs remain in place. Terraform aws_acm_certificate + validation + Route53 records ACM · us-east-1 SANs: tiles.example.com a-d.tiles.example.com wms.example.com emits DNS validation CNAMEs Route53 zone _acme CNAME records prove domain control CloudFront viewer_certificate = issued ARN terminates TLS TLSv1.2_2021 policy aliases = SAN hosts HSTS + redirect Browser WebGL tile client validates every host Tile origin private subnet edge-only ingress request write CNAMEs validate issued ARN HTTPS tiles origin fetch ACM auto-renews while CNAMEs remain The certificate for a CloudFront tile CDN must live in us-east-1 and cover every tile hostname the map fetches solid = provisioning / request path · dashed = validation + renewal loop and trust boundary

Environment parity and configuration drift mitigation

The reliable way to ship a certificate outage is to issue and attach certificates by hand, once per environment, and let the three sets drift. Staging picks up a wildcard, production gets a hand-typed SAN list that omits a shard host, and the divergence only surfaces the day a new subdomain goes live. The durable pattern is a single parameterized certificate module whose structure — the domain-validation loop, the TLS policy, the HSTS header, the CloudFront association — is byte-for-byte identical across environments, with only the domain name, the SAN list, and the hosted-zone id changing per stage.

Certificate drift is corrosive precisely because it is invisible until an expiry or a new host exercises it. An operator who requests a one-off certificate in the ACM console to unblock a launch produces a distribution whose viewer_certificate references an ARN that exists in no state file; the next apply either detaches it or, if the drift is masked, the certificate ages out with nobody owning its renewal. Mitigation is codified, not procedural:

  • Single source of truth for the SAN list. The apex host and every tile subdomain live in one versioned variable, never typed into a console. Adding e.tiles.example.com for a new shard is a pull request that re-issues the certificate with the expanded SAN set, not a click.
  • Scheduled drift detection. A nightly terraform plan -detailed-exitcode fails the job when the live viewer certificate, TLS policy, or alias list diverges from committed state, surfacing an out-of-band console certificate within a day.
  • Remote state locking. Certificate and distribution changes are frequently made under launch pressure; concurrent applies without a state lock can interleave a certificate swap with an alias change and leave a distribution pointing at a half-provisioned certificate. Locking is non-negotiable — see State Backend Selection for the backends that enforce it.
  • Region discipline as a policy check. A CloudFront viewer certificate MUST be issued in us-east-1 regardless of where the rest of the stack lives. Encode a policy rule that fails any aws_acm_certificate feeding a viewer_certificate unless its provider alias resolves to us-east-1.

CI/CD validation and operational guardrails

Certificate defects are cheap to catch before merge and brutal to debug once a map is dark, so the checks belong in the pre-merge pipeline. Static analyzers (checkov, tfsec, or pulumi-policy/CrossGuard) parse the rendered configuration and fail the build on the constraints that matter for tile delivery:

  • The certificate feeding any CloudFront viewer_certificate must be created through the us-east-1 provider alias — the single most common cause of a stuck apply.
  • minimum_protocol_version must be TLSv1.2_2021 or stronger; SSLv3 and TLSv1 / TLSv1.1 policies are rejected.
  • viewer_protocol_policy must be redirect-to-https, never allow-all, so a plaintext tile request cannot leak or fail silently.
  • The certificate’s SAN set must be a superset of the distribution aliases — an alias with no matching SAN is a guaranteed handshake failure for that host.

Beyond static checks, the pipeline should run a live handshake assertion against an ephemeral preview. A scripted openssl s_client -connect tiles-preview.example.com:443 -servername tiles-preview.example.com </dev/null confirms the served chain matches the requested host and that the negotiated protocol is TLS 1.2 or 1.3 before the change is allowed to promote. New HSTS directives should be introduced with a short max-age first (for example 300) and ramped only after you have confirmed every tile subdomain serves cleanly over HTTPS — an HSTS header with includeSubDomains and a long max-age is effectively irreversible in the browser and will pin a broken subdomain to HTTPS with no downgrade path. The worked issuance-and-validation walkthrough in Automating ACM Certificates for Tile CDN Endpoints shows the same guardrails applied under a real pending-validation failure.

Resource architecture and service integration

TLS is terminated at the edge, but the certificate only behaves correctly when the layers around it agree. The header contract in CORS & CSP Configuration assumes the connection is already HTTPS: a Strict-Transport-Security header and an upgrade-insecure-requests CSP directive are only meaningful once the certificate validates, and a mixed-content block — an http:// glyph or sprite URL inside an otherwise-https:// map — will kill the render regardless of how clean the certificate is. Certificate automation and header automation are two halves of one contract; the concrete origin/alias alignment for a GL JS client is worked through in Configuring CORS Headers for Mapbox GL JS via IaC.

The private data plane closes the loop. Because CloudFront terminates TLS, the tile origin behind it must accept traffic only from the distribution — a pattern detailed in Security Group Hardening — so nobody can reach the origin over plaintext and bypass the edge certificate entirely. When the origin is a self-managed tile server or GeoServer fleet spanning subnets, the routes that keep those hops private are the subject of VPC Routing for Tile Servers; a certificate on the edge does nothing for an origin that is directly reachable on :80. For an internal ALB fronting a GeoServer fleet, the same ACM certificate mechanism applies, except the certificate is issued in the ALB’s own region rather than us-east-1, and the SANs cover the internal service hostnames.

Runnable configuration

The configuration below issues one ACM certificate covering the apex tile host and its sharded subdomains, wires DNS validation into a Route53 hosted zone, blocks on validation, and attaches the issued certificate to a CloudFront distribution with a modern TLS policy and HTTPS redirect. The certificate is created through a dedicated us-east-1 provider alias because a CloudFront viewer certificate is only accepted from that region. The pinned provider is deliberate — a certificate change should be reproducible against a known provider version.

terraform {
  required_version = ">= 1.7.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60" # pin: acm validation + cloudfront viewer cert schema is version-sensitive
    }
  }
}

# CloudFront viewer certificates MUST be issued in us-east-1, wherever the rest of the stack lives.
provider "aws" {
  alias  = "use1"
  region = "us-east-1"
}

variable "tile_domain"     { type = string } # apex tile host, e.g. "tiles.example.com"
variable "tile_sans"       { type = list(string) } # shard + service hosts, e.g. ["a.tiles.example.com", ...]
variable "hosted_zone_id"  { type = string }

resource "aws_acm_certificate" "tiles" {
  provider                  = aws.use1
  domain_name               = var.tile_domain
  subject_alternative_names = var.tile_sans # every host the map fetches must appear here
  validation_method         = "DNS"          # DNS validation renews unattended; email does not

  lifecycle {
    create_before_destroy = true # avoid a gap when the SAN set changes and the cert re-issues
  }
}

# One validation CNAME per distinct domain in the cert; for_each keeps them stable across plans.
resource "aws_route53_record" "acm_validation" {
  for_each = {
    for dvo in aws_acm_certificate.tiles.domain_validation_options :
    dvo.domain_name => {
      name   = dvo.resource_record_name
      type   = dvo.resource_record_type
      record = dvo.resource_record_value
    }
  }

  zone_id         = var.hosted_zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = 60
  allow_overwrite = true # re-issuance can reuse a validation name; do not fail the apply on it
}

# Blocks until ACM confirms the CNAMEs and issues the cert — nothing downstream references
# the raw cert ARN, so a still-pending cert can never attach to the distribution.
resource "aws_acm_certificate_validation" "tiles" {
  provider                = aws.use1
  certificate_arn         = aws_acm_certificate.tiles.arn
  validation_record_fqdns = [for r in aws_route53_record.acm_validation : r.fqdn]
}

resource "aws_cloudfront_distribution" "tiles" {
  enabled = true
  aliases = concat([var.tile_domain], var.tile_sans) # must be a subset of the cert SANs

  # ... origin (tile server / S3), default_cache_behavior, restrictions ...

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate_validation.tiles.certificate_arn
    ssl_support_method       = "sni-only"       # SNI, not a dedicated IP — required for multi-SAN
    minimum_protocol_version = "TLSv1.2_2021"   # rejects TLS 1.0/1.1 and weak ciphers
  }

  default_cache_behavior {
    # ... target_origin_id, allowed_methods, cache policy ...
    viewer_protocol_policy = "redirect-to-https" # never allow-all for tiles
  }
}

HSTS is set as a response header, not on the certificate. Attach it through a CloudFront response headers policy so the map is pinned to HTTPS after the first visit:

resource "aws_cloudfront_response_headers_policy" "tiles_hsts" {
  name = "tiles-hsts"
  security_headers_config {
    strict_transport_security {
      access_control_max_age_sec = 63072000 # 2 years, in seconds (integer) — ramp from a low value
      include_subdomains         = true      # only once EVERY tile subdomain serves clean HTTPS
      preload                    = true
      override                   = true
    }
  }
}

Note — access_control_max_age_sec. This is a duration in seconds as an integer, not a percentage or a duration string. 63072000 pins the browser to HTTPS for two years; combined with include_subdomains, it is effectively irreversible, so ramp from a small value (e.g. 300) and only widen it after confirming every tile subdomain in the SAN list serves cleanly over HTTPS.

Guardrails embedded in configuration

Several protections are encoded in the resources above rather than left to operator memory. The validation dependency is structural: because viewer_certificate references aws_acm_certificate_validation.tiles.certificate_arn and not the raw certificate ARN, Terraform cannot attach a still-pending certificate to the distribution — the graph forces issuance to complete first. create_before_destroy on the certificate means expanding the SAN set to add a shard host re-issues without a window where the distribution has no valid certificate.

Region correctness is enforced by the provider alias, so a reviewer sees provider = aws.use1 on the certificate and knows the CloudFront constraint is respected; drop the alias and the apply fails at attach time with an opaque error. The private origin is the precondition, not an optional layerredirect-to-https and a strict TLS policy at the edge are only meaningful if the origin cannot be hit directly over plaintext, which the security-group rules from Security Group Hardening guarantee.

Finally, renewal is automatic but not unconditional. ACM renews a DNS-validated certificate on its own only while the validation CNAMEs remain resolvable in the hosted zone. Deleting those records — or destroying the zone in a refactor — silently disarms renewal, and the certificate lapses 60 days later with no apply to warn you. Keep the aws_route53_record.acm_validation resources in state for the life of the certificate, and treat their removal as a breaking change reviewed the same way a certificate swap is.

Troubleshooting and failure modes

  • Certificate in the wrong region for CloudFront. The apply fails at the distribution with The specified SSL certificate ... must be in the US East (N. Virginia) region, or the certificate simply never appears in the CloudFront console picker. CloudFront only accepts a viewer certificate from us-east-1; issue it through a dedicated us-east-1 provider alias as shown above. This is distinct from an ALB certificate, which must live in the ALB’s own region.
  • Validation records missing, certificate stuck in PENDING_VALIDATION. aws_acm_certificate_validation blocks and eventually times out because the _acme-style CNAMEs were never written, were written into the wrong hosted zone, or collide with an existing record and the apply lacks allow_overwrite. Confirm the for_each over domain_validation_options produced one record per distinct domain, that zone_id is the zone actually serving the domain’s NS records, and that allow_overwrite = true is set.
  • SAN does not cover a tile subdomain. Three of four sharded tile hosts render and the fourth throws NET::ERR_CERT_COMMON_NAME_INVALID, or the map works on the apex host but a WMS call to wms.example.com fails the handshake. The failing host is a distribution alias with no matching SAN. Add it to var.tile_sans, re-apply, and confirm the certificate re-issued to include it — a wildcard *.tiles.example.com covers one label only, so it will not match a bare apex tiles.example.com or a deeper a.b.tiles.example.com.
  • Mixed-content block despite a valid certificate. The certificate is valid and the page loads over HTTPS, but tiles, glyphs, or sprites silently fail because a style JSON references an http:// URL. The browser blocks the active mixed content and the map never paints. Fix the style/source URLs to https:// (or protocol-relative) and add upgrade-insecure-requests to the CSP in CORS & CSP Configuration; no certificate change resolves a mixed-content defect.
  • Silent renewal failure after a zone refactor. A certificate that renewed for years suddenly expires because a hosted-zone cleanup removed the validation CNAMEs and ACM could no longer confirm domain control. The nightly plan -detailed-exitcode drift check catches the missing records before expiry; the durable fix is to keep the validation records owned by the same stack as the certificate so they cannot be orphaned.