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.
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.comfor 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-exitcodefails 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-1regardless of where the rest of the stack lives. Encode a policy rule that fails anyaws_acm_certificatefeeding aviewer_certificateunless its provider alias resolves tous-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_certificatemust be created through theus-east-1provider alias — the single most common cause of a stuck apply. minimum_protocol_versionmust beTLSv1.2_2021or stronger;SSLv3andTLSv1/TLSv1.1policies are rejected.viewer_protocol_policymust beredirect-to-https, neverallow-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.63072000pins the browser to HTTPS for two years; combined withinclude_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 layer — redirect-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 fromus-east-1; issue it through a dedicatedus-east-1provider 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_validationblocks 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 lacksallow_overwrite. Confirm thefor_eachoverdomain_validation_optionsproduced one record per distinct domain, thatzone_idis the zone actually serving the domain’s NS records, and thatallow_overwrite = trueis 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 towms.example.comfails the handshake. The failing host is a distribution alias with no matching SAN. Add it tovar.tile_sans, re-apply, and confirm the certificate re-issued to include it — a wildcard*.tiles.example.comcovers one label only, so it will not match a bare apextiles.example.comor a deepera.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 tohttps://(or protocol-relative) and addupgrade-insecure-requeststo 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-exitcodedrift 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.
Related
- Network Security & Access Control — the parent domain this certificate automation sits within
- CORS & CSP Configuration — the header contract that assumes a valid HTTPS connection
- VPC Routing for Tile Servers — private routing that keeps the TLS-terminating edge the only entry point
- Configuring CORS Headers for Mapbox GL JS via IaC — aligning origins and headers for a GL JS map over HTTPS
- Automating ACM Certificates for Tile CDN Endpoints — a worked issuance, validation, and renewal walkthrough for this resource