Blue-Green GeoServer Deployments Behind an ALB

GeoServer is a stateful application pretending to be a stateless one. Its configuration — workspaces, stores, layers, styles, security rules — lives in a data directory on disk, and a rolling deployment that swaps containers underneath a shared data directory produces a window where two versions read and write the same catalogue with different schema expectations. The result is not a clean failure; it is a catalogue that mostly works with one layer definition missing, discovered days later by a user whose map lost a road network. Blue-green avoids that by never letting two versions touch one catalogue. This guide extends GeoServer Deployment Patterns within Geospatial Resource Provisioning.

The data directory is the whole problem

Everything difficult about deploying GeoServer follows from where the catalogue lives, and there are three arrangements with different consequences.

Shared filesystem, mounted read-write by every node. Convenient, and the arrangement that makes blue-green impossible in its pure form, because blue and green share the same catalogue by construction. Two versions writing it concurrently is exactly the corruption risk described above.

Baked into the image, read-only. The catalogue is built at image build time and shipped with the container. This makes GeoServer genuinely immutable and blue-green trivial: green carries its own catalogue and nothing is shared. The cost is that configuration changes require a rebuild and redeploy, so the administrative user interface becomes read-only in production — which is a discipline many teams want anyway, since a console-edited layer is unversioned infrastructure.

External catalogue in a database. A JDBC-backed catalogue moves configuration out of the filesystem, which solves the concurrency problem for reads but reintroduces it for schema: a version upgrade that changes the catalogue schema is a shared-database migration, and blue and green then disagree about the schema rather than about a directory.

For a version upgrade — the case blue-green is most needed for — the baked, read-only catalogue is the arrangement that actually delivers zero-downtime. The rest of this guide assumes it.

Shared catalogue compared with a baked per-colour catalogue In the shared arrangement, both the outgoing and incoming versions mount one data directory read-write during the deployment overlap, so two versions with different schema expectations read and write the same catalogue. The failure is rarely a clean error; it is usually a catalogue that mostly works with one layer definition lost. In the baked arrangement, each colour's container image carries its own read-only catalogue, so the blue and green fleets share nothing at all and the overlap window is harmless. Configuration changes then require a rebuild, which also makes every catalogue change a reviewed, versioned artifact. Shared data directory old version new version one catalogue two schema expectations The failure is not an error — it is a catalogue that mostly works, missing one layer. Baked, read-only per colour blue image its own catalogue green image its own catalogue nothing is shared, so the overlap window is harmless. A change now needs a rebuild — so it is reviewed. A console-edited layer is unversioned infrastructure, so a read-only production catalogue is usually a gain. A JDBC catalogue moves the problem rather than removing it: the shared schema becomes the contested resource.

Prerequisites and environment assumptions

Terraform 1.6 or later with hashicorp/aws pinned at ~> 5.60. A GeoServer container image with the data directory baked in and digest-pinned. An application load balancer with two target groups, one per colour. The database and object stores GeoServer reads must be reachable from both colours — a green fleet that cannot reach PostGIS Cluster Provisioning will pass a container health check and fail every real request, which is the reason the health check below probes a layer rather than a port.

Know what the version upgrade does to your styles and layer definitions. A GeoServer major upgrade can change how a style is interpreted, and the whole point of blue-green is that you can compare rendered output between colours before switching — so plan to actually do that comparison rather than treating the switch as automatic.

Step-by-step deployment

  1. Deploy green alongside blue, attached to its own target group. Green is not in the listener’s default action yet, so it receives no production traffic. It is, however, fully running and reachable through a test listener on a separate port.

  2. Warm green before it sees traffic. GeoServer’s first request per layer is expensive — it opens stores, reads style definitions and populates caches — so a cold fleet promoted directly to production produces a latency spike that looks like a failed deployment. Request a representative tile from each significant layer against the test listener first.

  3. Compare rendered output between colours. Fetch the same tile from blue and green and compare. Identical bytes mean the upgrade changed nothing visible; differences must be explained before switching, because a style interpreted differently by a new version is exactly what this step exists to catch.

  4. Shift traffic by weight, not all at once. Move ten per cent, watch error rate and latency for a few minutes, then continue. A weighted listener rule makes this a configuration change rather than a deployment.

  5. Hold blue for a full business cycle. Keep it running and reachable so a rollback is a weight change taking seconds. Destroying blue immediately after the switch converts a two-minute rollback into a redeployment.

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.60" }
  }
}

variable "green_weight" {
  type        = number
  default     = 0
  description = "0 to 100. Shift gradually; this variable IS the deployment."
}

resource "aws_lb_target_group" "blue" {
  name        = "geoserver-blue"
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    # Probe a real WMS GetCapabilities, not the container port. A GeoServer
    # that cannot reach PostGIS still binds 8080 and still answers TCP, so a
    # port check keeps a functionally dead node in rotation indefinitely.
    path                = "/geoserver/wms?service=WMS&version=1.3.0&request=GetCapabilities"
    matcher             = "200"
    interval            = 30
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 10
  }
}

resource "aws_lb_target_group" "green" {
  name        = "geoserver-green"
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    path                = "/geoserver/wms?service=WMS&version=1.3.0&request=GetCapabilities"
    matcher             = "200"
    interval            = 30
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 10
  }
}

resource "aws_lb_listener" "public" {
  load_balancer_arn = aws_lb.geoserver.arn
  port              = 443
  protocol          = "HTTPS"
  certificate_arn   = var.certificate_arn

  default_action {
    type = "forward"
    forward {
      target_group {
        arn    = aws_lb_target_group.blue.arn
        weight = 100 - var.green_weight
      }
      target_group {
        arn    = aws_lb_target_group.green.arn
        weight = var.green_weight
      }
      # Sticky sessions matter for GeoServer's administrative interface and for
      # any client relying on a session; without them a mid-shift request can
      # land on the other colour and appear to lose state.
      stickiness {
        enabled  = true
        duration = 300
      }
    }
  }
}

# A separate listener on a test port so green can be warmed and compared
# before it is in the public path at all.
resource "aws_lb_listener" "green_test" {
  load_balancer_arn = aws_lb.geoserver.arn
  port              = 8443
  protocol          = "HTTPS"
  certificate_arn   = var.certificate_arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.green.arn
  }
}

variable "vpc_id" { type = string }
variable "certificate_arn" { type = string }
# Step 2 and 3: warm green, then compare rendered output against blue.
LAYERS="topp:states osm:roads gis:parcels"
BBOX="-122.5,37.7,-122.3,37.9"

for layer in $LAYERS; do
  # Warm: the first request per layer opens stores and reads styles.
  curl -sf -o /dev/null "https://maps.example.org:8443/geoserver/wms\
?service=WMS&version=1.3.0&request=GetMap&layers=${layer}\
&bbox=${BBOX}&width=512&height=512&crs=EPSG:4326&format=image/png"

  # Compare: identical bytes mean the upgrade changed nothing visible.
  curl -sf -o "/tmp/blue-${layer//:/_}.png" "https://maps.example.org/geoserver/wms?...&layers=${layer}"
  curl -sf -o "/tmp/green-${layer//:/_}.png" "https://maps.example.org:8443/geoserver/wms?...&layers=${layer}"

  if ! cmp -s "/tmp/blue-${layer//:/_}.png" "/tmp/green-${layer//:/_}.png"; then
    echo "RENDER DIFFERS for ${layer} — explain before shifting traffic"
  fi
done
Shifting traffic by weight, with blue held for rollback Green begins at zero weight, receiving traffic only through a separate test listener while it is warmed layer by layer and its rendered output is compared byte for byte against blue. Weight then moves to ten per cent while error rate and latency are watched, then to fifty, then to one hundred. Throughout and for a full business cycle afterwards, blue remains running and healthy, so a rollback is a change to one weight variable taking seconds rather than a redeployment taking many minutes. green 0% warm + compare test listener only green 10% watch errors and latency green 50% both colours live green 100% blue still running Blue held for a full business cycle rollback is one weight variable, seconds — not a redeployment

Verification

At each weight, check the same four things: error rate at the load balancer by target group, p99 latency by target group, the health of every target in green, and a rendered tile fetched through the public endpoint. Comparing per target group rather than in aggregate is what makes a green-only problem visible while ninety per cent of traffic is still on blue.

After reaching full weight, verify the catalogue is complete on green rather than assuming it: GetCapabilities should list the same layer count as blue, and a spot check of styles on the most complex layers should render as expected. A missing layer at this point is recoverable in seconds; discovered a week later, after blue has been destroyed, it is a rebuild.

Four signals, read per target group rather than in aggregate At each weight the same four things are checked, and all four are read per target group rather than as a total. Error rate per target group makes a green-only failure visible while blue still carries most traffic; in aggregate a problem affecting ten per cent of requests is a small bump. The p99 latency comparison shows whether green is slower for a reason worth investigating before more traffic arrives. Target health confirms every green task is passing its check rather than a subset carrying the load. And a rendered tile fetched through the public endpoint confirms the path works end to end for a real user. Error rate per target group not as a total p99 latency green against blue before more traffic arrives Target health every green task not a subset carrying it A rendered tile public endpoint end to end, as a user In aggregate, a failure affecting the ten per cent on green is a small bump nobody investigates. Per target group it is unmistakable, which is the whole reason to shift by weight rather than all at once.

Preventing recurrence

  • Keep the production catalogue read-only. Every console edit is a change that exists in one colour and not the other, and blue-green cannot protect a catalogue that is being edited underneath it.
  • Make the render comparison a pipeline stage. An automated byte comparison across a fixed layer and bounding-box set turns “we should check the styles” into a gate, using the probe approach from Testing and Validation for Spatial IaC.
  • Health-check a real service response. A port check keeps a GeoServer that has lost its database connection in rotation, answering every request with an error.
  • Automate the hold-and-destroy schedule. Blue should be destroyed on a timer after a successful cycle, not when someone remembers, and not immediately.

Frequently Asked Questions

Can I do blue-green with a shared data directory?

Not meaningfully. The shared catalogue is exactly what the two colours must not share, so the deployment gives you two fleets and none of the isolation. Bake the catalogue into the image, or accept that you are doing a rolling deployment with its concurrency risk.

How long should the traffic shift take?

Long enough to observe a full traffic pattern at each weight, which for a map platform means at least several minutes per step and ideally spanning a peak. Shifting in under a minute gives the metrics no time to move and reduces the exercise to a slower way of switching instantly.

Do I need sticky sessions?

For the administrative interface and any session-dependent client, yes, or a mid-shift request lands on the other colour and appears to lose state. For pure stateless tile and WMS traffic they are unnecessary, and if the platform serves only that, leaving them off distributes load more evenly.

What if green renders differently and the difference is correct?

Then it is a deliberate visual change and should be released as one — announced, and ideally not on the same day as a version upgrade. The value of the comparison is that it forces the difference to be explained rather than discovered by a user.