Build a Bare-Metal Cloud Burst Game Fleet: 13 Steps [2026]

Game studios running dedicated multiplayer servers are hitting an uncomfortable number in their cloud bills. Server compute now accounts for roughly $7.9 billion of a $16.3 billion games-cloud market in 2026, nearly half the total spend, according to Tencent Cloud’s analyst coverage of the sector. Studios that scaled entirely on pay-as-you-go cloud instances during the 2020s are now re-running the math, and for steady, predictable concurrent-player loads, renting bare-metal capacity and reserving the cloud for overflow is coming out cheaper. This tutorial walks through building that hybrid: a bare-metal baseline fleet that handles normal traffic, plus an automated cloud burst layer that spins up only when real demand requires it.

You will end up with a working reference project: a containerized game server, a HashiCorp Nomad cluster spanning a bare-metal baseline and a cloud burst region, a Python watcher service that reads Prometheus metrics and triggers Terraform, and a DNS-based routing layer that steers players to whichever capacity pool is healthy. None of this requires Kubernetes, Agones, or a managed fleet service. It is 13 steps, roughly 3 to 4 hours end to end if you already have accounts with a bare-metal provider and a public cloud.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why Pure-Cloud Game Hosting Is Getting Too Expensive in 2026

Omdia’s Game Developer Collective survey found that AWS is used by 57% of surveyed studios, making it the leading cloud platform for games, according to Omdia’s Market Radar: Cloud Platforms for Games 2026 report. That dominance came from convenience, not necessarily cost efficiency. Dedicated game servers hold long-lived, stateful sessions that cannot be migrated mid-match the way a stateless web request can, which means studios typically provision for peak concurrent players and eat the idle cost the rest of the time.

Bare-metal infrastructure removes the hypervisor tax entirely: no virtualization overhead, no noisy neighbors stealing CPU cycles during a ranked match, and predictable network jitter. Inflect’s engineering team put it plainly in their breakdown of bare metal for game developers and online gaming platforms: persistent-world and matchmaking servers need consistent CPU and I/O performance that shared virtualized instances cannot guarantee under load. The problem with bare metal alone is that it is provisioned on contracts measured in weeks or months, not seconds, so it is a poor fit for the spiky traffic that comes with a new map launch, an esports weekend, or a surprise storefront feature.

That is the exact gap this architecture closes. You size the bare-metal fleet for your normal baseline load (say the 80th percentile of the last 90 days of concurrent players), then let a thin automation layer burst into cloud capacity only when real demand exceeds that baseline. Gameye’s infrastructure team describes this pattern directly: most game server scaling solutions default to Kubernetes-based tooling like Agones or GameLift FleetIQ, which require managing node pools, Helm charts, and horizontal pod autoscalers just to get moving, according to Gameye’s write-up on bare-metal baseline plus cloud burst. For a mid-size studio without a dedicated platform team, that operational overhead is often worse than the cost problem it is solving.

Separately, a 2026 industry report on backend solutions for multiplayer games found that serverless and containerized microservice architectures reduced operational overhead by 38% in the vendors it surveyed, a reminder that the backend services layer (login, inventory, chat) and the game server layer (the actual match process) scale on completely different curves and deserve completely different infrastructure decisions. This tutorial focuses only on the match-server layer, where the bare-metal plus burst pattern pays off most.

The genre of your game changes how urgently this matters. A slower-paced survival or simulation title with a few hundred concurrent players can often ride out a traffic spike on cloud autoscaling alone, since a few extra seconds of matchmaking delay barely registers. A competitive shooter or battle royale title with tens of thousands of concurrent players during a season launch is a different story: every second of delay shows up as a queue players can see, and every dollar of idle 24/7 peak-sized capacity shows up on next month’s infrastructure bill. The hybrid pattern in this tutorial earns its complexity fastest for exactly that second category, high-concurrency, latency-sensitive, spike-prone titles, which is also where most of the publicly discussed infrastructure incidents in the games industry tend to happen.

What Bare-Metal Plus Cloud Burst Actually Means

The architecture has three moving parts. First, a bare-metal baseline fleet: dedicated servers rented from a provider like OVHcloud, Latitude.sh, Hetzner, or a similar host, running your game server binary inside containers under a cluster orchestrator. Second, a capacity-watcher service that continuously compares real demand against the baseline’s known ceiling. Third, a cloud burst layer: an autoscaling group or managed instance group in AWS, Azure, or GCP that the watcher switches on only when the baseline is close to saturated, and switches back off once demand subsides.

Kubernetes is the most common orchestrator people reach for here, largely because of projects like Agones, the open-source layer that, as Google Cloud developer advocate Mark Mandel put it when the project launched, gives “Kubernetes native abilities to create, run, manage and scale dedicated game server processes within Kubernetes clusters using standard Kubernetes tooling and APIs.” Agones solved a real problem: Kubernetes was built for long-running, stateless services, and match servers are the opposite, short-lived and stateful, as the Nutanix team wrote up in their profile of Mandel’s work, Meet the Game Industry’s Kubernetes Whisperer. But Kubernetes brings its own operational weight, and for a two-pool hybrid architecture, that weight is often unnecessary.

This build uses HashiCorp Nomad instead. Nomad has native multi-region federation, it runs the same way on bare metal and in the cloud, and a single job specification can describe both your baseline and burst placements without a separate control plane for each. If your studio already has deep Kubernetes and Agones investment, the same burst logic in this tutorial, the watcher service, the Terraform burst module, and the DNS routing, ports over with minor changes. The point is the pattern, not the specific orchestrator.

Prerequisites and Tool Versions

Gather these before starting. Version ranges below are what this tutorial was tested against; use the latest stable release in each case unless noted.

Tool or AccountVersion / Tier UsedPurpose
Bare-metal provider accountAny provider with an API (OVHcloud, Latitude.sh, Hetzner Robot)Hosts the baseline fleet
AWS account (or Azure/GCP)IAM role with EC2 and Auto Scaling permissionsHosts the cloud burst layer
DockerLatest stable (24.x or newer)Containerizes the game server binary
HashiCorp Nomad1.8 or newerOrchestrates both pools from one control plane
HashiCorp Consul1.19 or newerService discovery and health checks
Terraform1.9 or newerProvisions the cloud burst auto scaling group
PrometheusLatest 2.xCollects CCU and node utilization metrics
Python3.11 or newerRuns the capacity-watcher service
A DNS provider with weighted/latency routingRoute 53, NS1, or CloudflareSteers players to healthy capacity

You should also know roughly how many concurrent players your baseline needs to carry. If you do not have that number yet, Step 1 covers how to derive it from existing telemetry.

Part 1: Measure Your Load and Set the Burst Threshold

Step 1: Calculate Your Baseline-to-Peak Ratio

Pull at least 60, ideally 90, days of concurrent-player data from your existing analytics or matchmaking logs. You are looking for two numbers: the 80th-percentile concurrent player count (your “normal” load) and the absolute peak (your worst-case spike, typically a launch day, patch day, or featured promotion). The ratio between those two numbers tells you how much headroom bare metal alone can realistically cover.

As a rule of thumb for this build: if your peak is less than 1.5x your 80th-percentile baseline, bare metal alone with some spare capacity might be enough, and cloud bursting is optional insurance. If your peak regularly runs 3x to 5x baseline, which is common around content updates, you need the burst layer to be fast and reliable, not an afterthought. Size your bare-metal contract for the 80th-percentile number plus roughly 20% headroom, and plan the burst layer to absorb everything above that.

Step 2: Set the Burst Trigger Threshold

Pick a utilization percentage of the baseline fleet that triggers a burst, and write it down before you touch any code. A common starting point is 75% of baseline server-slot capacity sustained for more than two consecutive minutes. Setting this too high (waiting until 95%+) leaves no time to spin up burst capacity before players start queuing or getting dropped; setting it too low wastes money by bursting for ordinary traffic noise. You will tune this number in Step 13 once you have real data, but 75% with a two-minute sustain window is a safe default for most session-based multiplayer games.

Part 2: Build the Bare-Metal Baseline Fleet

Step 3: Containerize the Game Server Binary

Whatever your game server binary is built in (C++, Go, Rust are the most common for dedicated servers), wrap it in a minimal container image. Keep the base image small so burst nodes in the cloud can pull and start it quickly later.

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates libstdc++6 && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /srv/gameserver
COPY ./build/gameserver /srv/gameserver/gameserver
COPY ./config/server.cfg /srv/gameserver/server.cfg

# Game port (UDP) and a health/metrics port (TCP)
EXPOSE 7777/udp
EXPOSE 8080/tcp

ENTRYPOINT ["/srv/gameserver/gameserver", "--config=/srv/gameserver/server.cfg"]

Push the built image to a registry both your bare-metal nodes and your future cloud burst nodes can reach, such as a private registry on Docker Hub, AWS ECR, or GitHub Container Registry. Tag it with the git commit hash, not “latest”, so the watcher service in Step 6 can pin exact versions during a burst.

Step 4: Provision and Join the Bare-Metal Nodes to Nomad

Order your dedicated servers (plan for at least 3 for quorum and failover) and install Nomad and Consul in server mode on all three, or in a separate small control-plane trio if your provider lets you mix instance sizes cheaply. The actual game-hosting bare-metal boxes join as Nomad clients.

  • Install Consul first on all nodes and confirm they form a cluster with consul members.
  • Install Nomad, pointing its consul stanza at the local Consul agent for automatic service registration.
  • Label bare-metal client nodes with a Nomad node class, for example pool=baremetal, so job placement can target them explicitly.
  • Confirm all nodes report ready with nomad node status.

Now define the baseline fleet as a Nomad job. This is the declarative heart of the baseline pool.

job "gameserver-baseline" {
  datacenters = ["baremetal-dc1"]
  type        = "service"

  constraint {
    attribute = "${node.class}"
    value     = "baremetal"
  }

  group "match-servers" {
    count = 40

    network {
      port "game" { static = 7777 }
      port "health" { static = 8080 }
    }

    task "gameserver" {
      driver = "docker"

      config {
        image = "registry.example.com/gameserver:GIT_SHA"
        ports = ["game", "health"]
      }

      resources {
        cpu    = 1500
        memory = 1024
      }

      service {
        name = "gameserver-baseline"
        port = "health"
        check {
          type     = "http"
          path     = "/healthz"
          interval = "10s"
          timeout  = "2s"
        }
      }
    }
  }
}

Run nomad job run gameserver-baseline.nomad.hcl and watch allocations land across your bare-metal nodes with nomad job status gameserver-baseline. The count = 40 here is illustrative; set it to match the slot count you calculated in Step 1.

Part 3: Build the Cloud Burst Engine

Step 5: Instrument Metrics With Prometheus

The watcher service needs a reliable signal for “how full is the baseline fleet right now.” Run Prometheus against your Nomad cluster using the Nomad metrics endpoint, and expose a custom gauge from your game server’s health port for active player sessions per instance.

# prometheus.yml
scrape_configs:
  - job_name: 'nomad'
    consul_sd_configs:
      - server: 'localhost:8500'
        services: ['gameserver-baseline']
    relabel_configs:
      - source_labels: [__meta_consul_service]
        target_label: job

rule_files:
  - 'burst_rules.yml'

Add an alerting rule that fires once baseline utilization crosses your threshold from Step 2:

# burst_rules.yml
groups:
  - name: burst-trigger
    rules:
      - alert: BaselineNearSaturation
        expr: (sum(gameserver_active_sessions) / sum(gameserver_max_sessions)) > 0.75
        for: 2m
        labels:
          severity: burst
        annotations:
          summary: "Baseline fleet above 75% for 2+ minutes"

This rule does not launch anything by itself. It is the signal the watcher service polls in the next step.

Step 6: Write the Capacity-Watcher Service

This is the piece that makes the whole architecture work without a human watching a dashboard at 2 a.m. It polls Prometheus, and when the burst condition is true, it calls Terraform (Step 7) to bring up cloud capacity, then registers that capacity with Nomad as a burst job.

import time
import requests
import subprocess
import logging

PROM_URL = "http://localhost:9090/api/v1/query"
BURST_QUERY = "sum(gameserver_active_sessions) / sum(gameserver_max_sessions)"
BURST_THRESHOLD = 0.75
SCALE_DOWN_THRESHOLD = 0.45
POLL_INTERVAL_SEC = 30
SUSTAIN_CYCLES = 4  # 4 * 30s = 2 minutes

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("capacity-watcher")

def query_utilization():
    resp = requests.get(PROM_URL, params={"query": BURST_QUERY}, timeout=5)
    resp.raise_for_status()
    result = resp.json()["data"]["result"]
    return float(result[0]["value"][1]) if result else 0.0

def trigger_burst():
    log.info("Threshold sustained. Applying Terraform burst module.")
    subprocess.run(
        ["terraform", "apply", "-auto-approve", "-var=burst_enabled=true"],
        cwd="./terraform/burst",
        check=True,
    )

def trigger_scale_down():
    log.info("Demand subsided. Tearing down burst capacity.")
    subprocess.run(
        ["terraform", "apply", "-auto-approve", "-var=burst_enabled=false"],
        cwd="./terraform/burst",
        check=True,
    )

def main():
    above_count = 0
    bursting = False
    while True:
        util = query_utilization()
        log.info("Baseline utilization: %.1f%%", util * 100)

        if util > BURST_THRESHOLD:
            above_count += 1
        else:
            above_count = 0

        if above_count >= SUSTAIN_CYCLES and not bursting:
            trigger_burst()
            bursting = True

        if bursting and util < SCALE_DOWN_THRESHOLD:
            trigger_scale_down()
            bursting = False

        time.sleep(POLL_INTERVAL_SEC)

if __name__ == "__main__":
    main()

Run this as a small systemd service on one of your control-plane nodes, not on a baseline game-hosting node. It needs the Terraform binary, cloud credentials, and network access to Prometheus, but it does not need to run anywhere near the player traffic path.

Step 7: Define the Cloud Burst Terraform Module

The burst module provisions an Auto Scaling Group that stays at zero instances until the watcher flips burst_enabled to true. Using Terraform here, instead of calling the AWS API directly, gives you a reviewable, versioned record of every burst event and makes the teardown symmetric with the launch.

variable "burst_enabled" {
  type    = bool
  default = false
}

variable "burst_instance_type" {
  type    = string
  default = "c6i.2xlarge"
}

resource "aws_launch_template" "burst_gameserver" {
  name_prefix   = "burst-gameserver-"
  image_id      = var.burst_ami_id   # pre-baked AMI, see Common Pitfalls
  instance_type = var.burst_instance_type

  user_data = base64encode(templatefile("${path.module}/bootstrap.sh.tpl", {
    nomad_server_addr = var.nomad_server_addr
    node_class         = "cloudburst"
  }))
}

resource "aws_autoscaling_group" "burst_gameserver" {
  name                = "gameserver-burst-asg"
  min_size            = var.burst_enabled ? 2 : 0
  max_size            = 12
  desired_capacity    = var.burst_enabled ? 2 : 0
  vpc_zone_identifier = var.burst_subnet_ids

  launch_template {
    id      = aws_launch_template.burst_gameserver.id
    version = "$Latest"
  }

  tag {
    key                 = "Pool"
    value               = "cloudburst"
    propagate_at_launch = true
  }
}

The min_size and desired_capacity flipping between 0 and 2 on the burst_enabled flag is the entire mechanism. Nothing exotic, which is the point: the fewer moving parts in the burst path, the less likely it fails exactly when you need it most.

Step 8: Auto-Join Burst Nodes to the Nomad Federation

The bootstrap.sh.tpl referenced above runs on first boot of each burst instance. It installs Nomad as a client, points it at your bare-metal Nomad servers (or a regional Nomad server group if you federate by region), and labels itself so the burst job can target it specifically.

#!/bin/bash
set -euo pipefail

curl -fsSL https://releases.hashicorp.com/nomad/latest/nomad_latest_linux_amd64.zip -o /tmp/nomad.zip
unzip -o /tmp/nomad.zip -d /usr/local/bin

cat </etc/nomad.d/client.hcl
datacenter = "cloudburst"
data_dir   = "/opt/nomad"

client {
  enabled = true
  servers = ["${nomad_server_addr}"]
  node_class = "${node_class}"
}
EOF

systemctl enable --now nomad
docker pull registry.example.com/gameserver:GIT_SHA

Once that instance reports into Nomad, deploy a second job spec, gameserver-burst.nomad.hcl, identical to the baseline job but constrained to node.class == "cloudburst". The watcher service can run nomad job run on this spec right after the Terraform apply completes, or you can let a Nomad autoscaler poll the same node class once nodes appear.

Part 4: Route Players and Control Cost

Step 9: Steer Players With Health-Gated DNS

Your matchmaker needs to know which pool, baseline or burst, has open slots, and it needs that answer to update within seconds of capacity changing. Use Consul's health checks (already running from Step 4) as the source of truth, and have your DNS provider or a thin internal API query Consul before assigning a new session to a server IP. Do not rely on long-TTL DNS alone; set a TTL of 30 seconds or less on any record pointing at burst-pool endpoints, since those endpoints come and go.

AWS's own games engineering team groups exactly this kind of workload, matchmaking, session assignment, and chat, as requiring different database and routing patterns from the long-running match server itself, a distinction covered in the AWS for Games compute blog. The matchmaker should always ask "which healthy pool has room" rather than hardcoding a preference for bare metal or cloud; that keeps the burst layer invisible to players beyond slightly different latency.

In practice this means the matchmaker's capacity lookup should never special-case "baseline" or "burst" as separate code paths. Query a single abstraction, a list of healthy endpoints tagged with pool, region, and open-slot count, and let a simple scoring function pick the best match: prefer the lowest-latency pool with available room, and only fall back to a higher-latency pool if every closer option is full. That scoring function is also where you enforce fairness during a genuine capacity crunch, so one disproportionately large guild or party doesn't starve everyone else of the last open slots on the baseline fleet while burst capacity is still spinning up.

Step 10: Implement Graceful Drain Before Scale-Down

When the watcher decides to scale burst capacity back down, it must never kill an instance mid-match. Add a drain step: mark the Nomad allocation as ineligible for new sessions, wait for active matches on that node to finish (or hit a maximum drain timeout), then terminate.

#!/bin/bash
set -euo pipefail

NODE_ID="$1"
MAX_DRAIN_SECONDS=900

echo "Marking $NODE_ID ineligible for new allocations..."
nomad node eligibility -disable "$NODE_ID"

echo "Draining existing allocations (max ${MAX_DRAIN_SECONDS}s)..."
nomad node drain -enable -deadline "${MAX_DRAIN_SECONDS}s" -yes "$NODE_ID"

echo "Drain complete. Safe to terminate $NODE_ID."

Wire this script into the trigger_scale_down() function from Step 6, calling it against each burst node before the Terraform apply that sets desired_capacity back to zero. Your game server binary should also catch SIGTERM and finish or gracefully end the current match rather than exiting immediately.

Step 11: Add Cost Guardrails

Automated scaling without a ceiling is how a single misconfigured threshold turns into a five-figure cloud bill overnight. Add two layers of protection: a hard max_size in the Terraform Auto Scaling Group (already set to 12 in Step 7, adjust to your actual worst-case need), and a billing alert that pages someone the moment burst spend in a day exceeds a number you have decided in advance is your "something is wrong" line. Most cloud providers support a budget-threshold webhook; point it at the same alerting channel as your on-call rotation, not just an email that gets ignored over a weekend.

Part 5: Test, Launch, and Operate

Step 12: Load-Test the Burst Path Before You Need It

Never let the first real burst event be the first time the burst path actually runs. Simulate load against your baseline fleet with a tool like k6 or a simple session-flooding script until Prometheus reports utilization above your threshold, and confirm, end to end, that: the watcher detects the sustained breach, Terraform applies successfully, the new nodes join Nomad, the burst job schedules, Consul health checks pass, and the matchmaker starts routing new sessions to the burst pool, all within a time window that does not cause visible queueing for players.

Time this run. If the whole sequence from threshold breach to the first burst server accepting players takes longer than your players' tolerance for matchmaking delay, usually somewhere between 30 and 90 seconds depending on genre, go back to Step 7 and pre-bake your AMI so cloud-init has less work to do at boot.

Step 13: Monitor, Tune the Threshold, and Document the Runbook

After your first few real burst events, revisit the 75% threshold and two-minute sustain window from Step 2 with actual data. If bursts are triggering for brief traffic noise and tearing down a few minutes later, raise the sustain window. If players report degraded matchmaking before the burst visibly kicks in, lower the threshold or shorten the sustain window. Write down, in a shared runbook, exactly what each alert means, what the watcher does automatically, and what a human should check if the watcher's automated response fails, because at some point it will, usually during the exact traffic spike you built this system to handle.

Bare Metal vs Cloud Burst vs Pure Cloud: A Worked Cost Example

The table below is an illustrative capacity-planning example for a mid-size game holding roughly 2,000 average concurrent players with occasional spikes to 6,000, not a quote from any specific vendor. Use it as a model to plug your own numbers into, not as a universal price.

ApproachHow Capacity Is SizedCost Behavior at 2,000 Avg CCU / 6,000 Peak
Pure cloud, provisioned for peakFixed fleet sized for the 6,000-player worst case, running 24/7Pays peak-level compute rates around the clock, even when real load is a third of capacity
Pure cloud, autoscaledCloud instances scale up and down automatically with no bare-metal baselineAvoids idle waste but pays on-demand cloud rates for 100% of capacity, including the steady baseline load
Bare metal onlyFixed contract sized for the baseline, no burst layerLowest cost per core for the baseline, but risks dropped sessions or matchmaking delays whenever real demand exceeds the contracted fleet
Bare metal + cloud burst (this tutorial)Bare-metal baseline covers the steady 2,000 CCU; cloud burst covers only the delta above thatPays bare-metal rates for roughly 80% of total capacity-hours and on-demand cloud rates only for the burst windows

The exact savings depend heavily on your specific peak-to-baseline ratio from Step 1. Games with smooth, predictable traffic see the smallest gains from bursting, since bare metal alone may already cover most scenarios. Games with sharp seasonal or event-driven spikes, think a battle royale title during a new season launch, see the largest gains, because the alternative is paying cloud-peak rates year-round just to cover a handful of spike days.

Common Pitfalls When Building Hybrid Game Server Capacity

  • Setting the burst threshold with no headroom. Triggering at 95% baseline utilization leaves no time to spin up and warm cloud capacity before players start queuing; 70 to 80% with a short sustain window gives the pipeline room to work.
  • Ignoring regional latency when picking a burst cloud region. A burst region chosen purely for cost or capacity availability, without checking round-trip latency to your actual player base, can make the burst pool unusable for twitch-reaction games even when it has plenty of free slots.
  • Skipping the pre-baked AMI step. Installing Docker, pulling images, and configuring Nomad from scratch on every burst boot can easily add several minutes of dead time exactly when you need capacity fastest.
  • No automatic scale-down path. Teams that build the burst-up logic carefully and treat scale-down as an afterthought end up with forgotten Auto Scaling Groups quietly billing for days after a traffic spike passed.
  • Treating bare-metal nodes like disposable cloud VMs. Bare-metal hardware fails differently than cloud instances, a bad NIC or disk does not get silently replaced by a hypervisor, so you need your own out-of-band monitoring and a real incident process with your provider.
  • Never testing the burst path until a real spike arrives. The first live fire test of an untested automation path, during the one event where you most need it to work, is how studios end up manually launching cloud instances during a launch-day incident.
  • Overlooking cross-environment data transfer costs. Moving game state, player data, or logs between the bare-metal environment and the cloud burst environment can add meaningful egress costs that do not show up until the first invoice after a big burst event.

Troubleshooting Guide

IssueLikely CauseFix
Burst never triggers despite clearly high loadPrometheus query path or metric names don't match what the game server actually exposesQuery Prometheus directly with the exact BURST_QUERY string from Step 6 and confirm it returns a non-empty result
Cloud burst nodes take 5+ minutes to accept playersNo pre-baked AMI; cloud-init installing Docker and Nomad from scratch at bootBake an AMI with Docker, Nomad, and the game server image already pulled, as noted in Step 7's pitfalls
Players get routed to burst nodes before they're readyDNS TTL too long, or the matchmaker isn't gating on Consul health checksDrop TTL to 30 seconds or less; require a passing Consul health check before a node appears in the routing pool
Nomad job stays "pending" and never schedules on bare metalResource stanza in the job spec requests more CPU or memory than any single node has freeRun nomad node status -verbose to check real free capacity, then right-size the resources block
Burst capacity never scales back down after load dropsWatcher stuck in bursting = True state, or Terraform apply for scale-down is silently failingCheck watcher logs for the trigger_scale_down() call, then run terraform plan manually to see the pending diff
Unexpected cost spike after a burst eventScale-down ran but drain never completed, leaving the Auto Scaling Group pinned at its minimum of 2 instead of 0Confirm the drain script (Step 10) actually completed before the Terraform apply ran; add a hard timeout so a stuck drain can't block teardown forever
Terraform apply during a burst event fails with a state lock errorTwo burst/scale-down triggers fired close together and both tried to apply at onceUse remote state with locking (S3 + DynamoDB, or Terraform Cloud) and add a simple file or distributed lock inside the watcher before calling terraform apply
Active player sessions drop when a burst node is torn downNo graceful drain; the instance was terminated while matches were still running on itConfirm SIGTERM handling exists in the game server binary and that Step 10's drain script runs before termination, not after
Burst region has noticeably worse latency for most playersBurst region picked for lowest cost or nearest free capacity, not nearest to the actual player baseAdd a latency-aware fallback list of regions in the Terraform burst module, ordered by player proximity rather than cost alone

Advanced Tips for Scaling the Pattern Further

  • Pre-warm ahead of known events. If you know a patch, tournament, or content drop is coming, have the watcher force a burst ahead of time rather than waiting for the threshold to trip reactively. A scheduled pre-warm removes the cold-start risk entirely for predictable traffic events.
  • Use spot or preemptible instances for the burst layer only. Since burst capacity is, by definition, overflow and not your baseline, it can tolerate occasional reclamation far better than your core fleet. Running burst nodes on spot or preemptible pricing cuts the already-smaller burst bill further, as long as your matchmaker gracefully handles a burst node disappearing early.
  • Add a second burst provider for redundancy. Once the single-cloud-burst pattern is solid, extend the Terraform module to support a second cloud as a fallback if your primary burst region is capacity-constrained or experiencing an outage of its own.
  • Layer your container images for faster cold starts. Split your game server image into a rarely-changing base layer and a small, frequently-updated application layer, so burst nodes pulling a new build only download the delta, not the whole image.
  • Push routing decisions closer to the player with edge points of presence. If your burst regions multiply over time, a lightweight edge layer that picks the nearest healthy pool, rather than a centralized router, keeps matchmaking latency low even as your footprint grows.

The Complete Working Project

Putting the 13 steps together, your repository should end up structured roughly like this:

hybrid-gameserver-fleet/
├── docker/
│   └── Dockerfile
├── nomad/
│   ├── gameserver-baseline.nomad.hcl
│   └── gameserver-burst.nomad.hcl
├── prometheus/
│   ├── prometheus.yml
│   └── burst_rules.yml
├── terraform/
│   └── burst/
│       ├── main.tf
│       ├── variables.tf
│       └── bootstrap.sh.tpl
├── scripts/
│   ├── capacity_watcher.py
│   └── drain_node.sh
└── RUNBOOK.md

With that structure in place, the operating loop is: Prometheus watches real player load against the bare-metal baseline; the capacity watcher polls that signal every 30 seconds; when the baseline sustains above 75% utilization for two minutes, Terraform provisions cloud burst nodes from a pre-baked AMI; those nodes join the Nomad federation and start a mirrored game server job; Consul health checks gate them into the matchmaker's routing pool; and once demand drops back below 45% utilization, the same pipeline drains and tears the burst nodes down in reverse. The bare-metal fleet never stops running, and the cloud burst fleet never runs when it is not needed, which is the entire economic case for building it this way.

Frequently Asked Questions

What is bare-metal cloud bursting for game servers?

It is a hybrid hosting pattern where a fixed fleet of dedicated bare-metal servers handles normal, predictable player traffic, while a cloud layer (AWS, Azure, or GCP) automatically provisions extra capacity only during demand spikes, then tears that capacity back down once load returns to normal.

Is bare metal actually cheaper than pure cloud for dedicated game servers?

For the steady, predictable portion of your traffic, generally yes, since bare-metal contracts avoid both the cloud markup and the hypervisor overhead that cuts into usable CPU and I/O performance. For unpredictable spikes, pure on-demand cloud capacity is still the right tool, which is exactly why the hybrid pattern pairs both.

Do I need Kubernetes to build this architecture?

No. This tutorial uses HashiCorp Nomad specifically because it natively federates across a bare-metal datacenter and a cloud region from one control plane without a separate Kubernetes cluster for each. If your team already runs Kubernetes with Agones, the same watcher-plus-Terraform burst logic applies; only the orchestration layer changes.

How fast can a cloud burst region actually come online?

With a pre-baked AMI that already has Docker, Nomad, and the game server image installed, new burst nodes typically join the fleet and pass health checks within one to two minutes of the Auto Scaling Group's launch. Without a pre-baked image, full package installation at boot can push that past five minutes, which is usually too slow to prevent visible matchmaking delays.

What happens to active player sessions when the baseline runs out of room?

Existing sessions already placed on the bare-metal baseline are unaffected; they continue running on the server they started on. Only new matchmaking requests get routed to the burst pool once it comes online and passes health checks. No live match is ever moved between pools mid-session.

Can I use this pattern alongside AWS GameLift or Agones instead of Nomad?

Yes. The capacity-watcher logic, Terraform burst module, and DNS/health-gated routing are orchestrator-agnostic. You would replace the Nomad job specs with GameLift fleet configurations or Agones Fleet and FleetAutoscaler manifests, but the trigger-and-teardown mechanism stays the same.

How do I stop a misconfigured threshold from causing a runaway cloud bill?

Set a hard max_size ceiling in the Terraform Auto Scaling Group so no bug or feedback loop can scale past a number you have already decided is acceptable, and pair it with a cloud billing alert that pages your on-call rotation the moment daily burst spend crosses a threshold you set in advance.

What team size can realistically maintain this architecture?

Once built and tested, the day-to-day operational load is light, mostly watching dashboards and occasionally tuning the burst threshold, which one infrastructure engineer can typically own alongside other responsibilities. The heavier lift is the initial build and the load-testing in Step 12, which is worth budgeting real time for before relying on it during a live traffic spike.

Sofia Lindström

Sofia Lindström

Editor-in-Chief

Sofia Lindström is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles