Standing up a dedicated game server fleet by hand is fine for a weekend LAN party. It falls apart the moment a launch trailer drops and 40,000 players try to join at once. Most studios that have been burned by a manual EC2 console click-through eventually land on the same fix: Terraform. Writing your AWS game server infrastructure as code means a security group typo gets caught in a code review instead of in a 3 a.m. incident channel, and spinning up a second region for EU players becomes a copy-pasted module instead of a weekend of forgetting a step.
This tutorial walks through provisioning a multi-region dedicated game server fleet on AWS using Terraform 1.16.5 and the HashiCorp AWS provider, version 6.68.0, both current as of early October 2026. By the end, you will have a reusable module that launches an Auto Scaling Group of EC2 instances running a UDP game server process, locks down networking with properly scoped security groups, and routes players to the nearest healthy region through AWS Global Accelerator. We will also cover state management, cost control, and the pitfalls that trip up most teams the first time they try this.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why Terraform for Game Server Infrastructure in 2026
Game server fleets have a specific set of infrastructure demands that make Terraform a natural fit. You need identical environments across multiple AWS regions (so a player in Frankfurt gets the same build as a player in Virginia), you need networking rules that are easy to audit for the exact UDP port ranges your game binary uses, and you need the ability to tear everything down and rebuild it without a human remembering fifteen manual console steps. According to the 2025 Stack Overflow Developer Survey, Terraform was used by 17.8% of respondents in the cloud-development category and admired by 56.4% of the developers who used it, a strong signal that it is both broadly adopted and well-liked among practitioners who could pick other tools.
AWS itself has started pointing studios toward infrastructure as code rather than console clicking. AWS’s own Games Industry Lens in its Well-Architected documentation recommends using an IaC tool “such as AWS CloudFormation or Terraform by HashiCorp” specifically for configuring VPCs, subnet layouts, and the dependencies required to launch game services, so that teams can refer back to a template instead of reconstructing an environment from memory. That is the exact gap this tutorial fills: a working, versioned Terraform configuration for a dedicated game server fleet, not a one-off console walkthrough that goes stale the day someone changes a setting by hand.
Separately, AWS reports that Amazon Web Services was used by 43.3% of respondents in the same Stack Overflow cloud-development category, making it the most common cloud target for exactly the kind of Terraform configuration this guide builds. Pairing the most-used cloud with one of the most-admired IaC tools is not a niche combination, it is the default stack a growing share of backend and DevOps engineers reach for first.
Prerequisites and Exact Versions
Pin your versions before you write a single resource block. Terraform and the AWS provider both ship frequent point releases, and a configuration written against an old provider can fail silently against a newer one. Here is what this tutorial was tested against:
| Tool / Component | Version Used | Release Date | Notes |
|---|---|---|---|
| Terraform CLI | 1.16.5 | October 2, 2026 | Latest stable release at time of writing |
| hashicorp/aws provider | 6.68.0 | October 7, 2026 | Pin with a ~> 6.68 version constraint |
| AWS CLI v2 | latest version | â | Used only for credential setup and verification |
| terraform-aws-modules/eks/aws (optional) | 21.29.0 | October 7, 2026 | Only needed if you later migrate to Kubernetes |
| Operating system for game server AMI | Amazon Linux 2023 | â | Works for both x86_64 and Graviton (arm64) builds |
You will also need an AWS account with programmatic access keys (or better, an SSO profile), a registered domain or subdomain you control for the Global Accelerator DNS record, and a compiled or containerized build of your game server binary that listens on a known UDP port range. If you are experimenting rather than deploying a production title, a simple UDP echo server or an open-source dedicated server binary works fine for following along.
If you already run AWS GameLift fleets and are evaluating whether to move to self-managed EC2 plus Terraform, it is worth reading our breakdown of AWS GameLift Local Zones setup across nine zones first, since GameLift and the raw EC2-plus-Terraform approach solve the same problem with very different tradeoffs on cost and control.
Step 1: Install Terraform 1.16.5 and Configure the AWS Provider
Install Terraform through your package manager of choice, or download the binary directly from HashiCorp’s releases page. On macOS with Homebrew:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
terraform -version
# Terraform v1.16.5
# on darwin_arm64
On Linux, use the official HashiCorp apt or yum repository rather than a distro package, since distro-maintained Terraform builds frequently lag several minor versions behind. Once installed, configure your AWS credentials through a named profile so you never hardcode keys into a Terraform file:
aws configure --profile gameserver-infra
# AWS Access Key ID: ****************
# AWS Secret Access Key: ****************
# Default region name: us-east-1
# Default output format: json
Create a working directory and a versions.tf file that pins both the Terraform binary version range and the AWS provider version. Pinning matters here more than in most projects: the AWS provider ships new resource arguments almost weekly, and an unpinned >= 6.0 constraint can pull in breaking changes mid-sprint.
terraform {
required_version = "~> 1.16"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.68"
}
}
backend "s3" {
bucket = "gameserver-infra-tfstate"
key = "fleet/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "gameserver-tf-locks"
encrypt = true
}
}
provider "aws" {
region = var.primary_region
profile = "gameserver-infra"
}
provider "aws" {
alias = "secondary"
region = var.secondary_region
profile = "gameserver-infra"
}
Note the backend “s3” block. State for a multi-region game server fleet should never live on a laptop. Create the S3 bucket and the DynamoDB lock table before running terraform init, or Terraform will fail on the very first command. If you also run security scanning on your Terraform plans, our walkthrough on setting up Checkov for IaC security scanning is a useful companion step to run against this exact configuration before it reaches production.
Step 2: Design the Multi-Region Network Layout
A dedicated game server fleet needs a VPC per region, each with public subnets for the game server instances (they need a public IP or an Elastic IP so players can reach them directly over UDP) and, optionally, private subnets for backend services like matchmaking or player data stores. By default, AWS allows 5 VPCs per region and 200 subnets per VPC, according to AWS’s published VPC quota documentation, both of which are an increasable soft limit but comfortably large enough for a two- or three-region launch without filing a quota increase request.
Define your regions as variables so the same module can be instantiated more than once:
variable "primary_region" {
type = string
default = "us-east-1"
}
variable "secondary_region" {
type = string
default = "eu-west-1"
}
variable "game_udp_port_range" {
type = object({ from = number, to = number })
default = { from = 7777, to = 7784 }
}
variable "instance_type" {
type = string
default = "c6i.large"
}
variable "min_size" {
type = number
default = 2
}
variable "max_size" {
type = number
default = 20
}
The UDP port range matters more than most teams expect. Most dedicated server binaries, from Source-engine-based shooters to voxel survival games, bind one process per port within a defined range so multiple server instances can share a host. Settling on this range now (and documenting it) avoids a painful security group rewrite later. If your game instead relies on session relay rather than direct connect, you may want to review our comparison of WebSocket versus WebRTC for multiplayer games before locking in a pure UDP direct-connect architecture.
Step 3: Write the VPC Module
Rather than hand-rolling VPC resources from scratch, most teams in 2026 reach for the community-maintained terraform-aws-modules/vpc/aws module, which handles subnet math, route tables, and internet gateways correctly on the first try. Here is a minimal invocation:
module "vpc_primary" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "gameserver-primary"
cidr = "10.10.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
public_subnets = ["10.10.1.0/24", "10.10.2.0/24", "10.10.3.0/24"]
private_subnets = ["10.10.11.0/24", "10.10.12.0/24", "10.10.13.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
tags = {
Project = "gameserver-fleet"
Environment = "production"
}
}
Duplicate this block for the secondary region, swapping the provider alias and CIDR range so the two VPCs never overlap (a detail that matters if you ever peer them or run a VPN between regions for cross-region admin access). Keep single_nat_gateway = true for cost control during development; switch to one NAT gateway per AZ only once you need the extra availability for production backend traffic.
Step 4: Scope Security Groups to the Exact Ports Your Server Needs
This is the step most tutorials get dangerously loose on. A game server security group should open exactly the UDP range the binary uses, plus SSH or SSM access restricted to your office or bastion CIDR, and nothing else. AWS’s default quota allows 60 inbound and 60 outbound rules per security group, enforced separately for IPv4 and IPv6 traffic, and up to 5 security groups per network interface by default (adjustable to 16), per AWS’s current VPC quota page. You will not come close to those ceilings for a game server group, which is itself a signal that a security group with 40 rules in it probably has scope creep worth auditing.
resource "aws_security_group" "gameserver" {
name = "gameserver-sg"
description = "Dedicated game server UDP traffic and admin access"
vpc_id = module.vpc_primary.vpc_id
ingress {
description = "Game server UDP port range"
from_port = var.game_udp_port_range.from
to_port = var.game_udp_port_range.to
protocol = "udp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "SSM Session Manager (no SSH port needed)"
from_port = 0
to_port = 0
protocol = "-1"
security_groups = [aws_security_group.bastion.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "gameserver-sg"
}
}
Prefer AWS Systems Manager Session Manager over opening port 22 entirely. It removes an entire class of brute-force SSH attempts against your fleet and does not require managing SSH key distribution across dozens or hundreds of ephemeral instances. If you need a deeper look at locking down the data layer this fleet will eventually talk to, our guide on setting up DynamoDB Global Tables for game saves covers a related IAM-scoping pattern you can reuse for player data.
Step 5: Build the Launch Template
The launch template defines exactly what each game server instance looks like when it boots: AMI, instance type, security groups, and a user-data script that pulls and starts the game server binary. For CPU-bound dedicated servers without a GPU requirement, the c6i and Graviton-based c7g families are the common picks in 2026 because of their price-to-core ratio; reserve g5-family instances only if your server does in-process rendering or AI inference rather than pure game-logic simulation.
resource "aws_launch_template" "gameserver" {
name_prefix = "gameserver-"
image_id = data.aws_ami.al2023.id
instance_type = var.instance_type
network_interfaces {
associate_public_ip_address = true
security_groups = [aws_security_group.gameserver.id]
}
user_data = base64encode(<<-EOF
#!/bin/bash
set -euxo pipefail
dnf install -y amazon-ssm-agent
systemctl enable --now amazon-ssm-agent
mkdir -p /opt/gameserver
aws s3 cp s3://gameserver-builds/latest/server.tar.gz /opt/gameserver/
tar -xzf /opt/gameserver/server.tar.gz -C /opt/gameserver
/opt/gameserver/start.sh --port-range ${var.game_udp_port_range.from}-${var.game_udp_port_range.to}
EOF
)
tag_specifications {
resource_type = "instance"
tags = {
Name = "gameserver-fleet"
}
}
}
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
Pulling the binary from S3 at boot time rather than baking it into a custom AMI keeps your AMI pipeline simple, at the cost of a slightly slower cold start. For fleets where cold-start latency during a scale-out event matters, such as a sudden surge of players at a launch event, bake the binary into a custom AMI with Packer instead and skip the S3 download step entirely.
Step 6: Configure the Auto Scaling Group
The Auto Scaling Group is what turns a single launch template into a fleet that grows and shrinks with player demand. AWS does not charge an extra fee for Auto Scaling itself; you only pay for the underlying EC2, EBS, and data transfer resources it provisions, per AWS's Auto Scaling pricing documentation. That makes scaling policy tuning closer to free experimentation than a cost risk.
resource "aws_autoscaling_group" "gameserver" {
name = "gameserver-asg"
min_size = var.min_size
max_size = var.max_size
desired_capacity = var.min_size
vpc_zone_identifier = module.vpc_primary.public_subnets
health_check_type = "EC2"
launch_template {
id = aws_launch_template.gameserver.id
version = "$Latest"
}
instance_refresh {
strategy = "Rolling"
preferences {
min_healthy_percentage = 75
}
}
tag {
key = "Name"
value = "gameserver-fleet"
propagate_at_launch = true
}
}
resource "aws_autoscaling_policy" "scale_out" {
name = "gameserver-scale-out"
autoscaling_group_name = aws_autoscaling_group.gameserver.name
policy_type = "TargetTrackingScaling"
target_tracking_configuration {
predefined_metric_specification {
predefined_metric_type = "ASGAverageCPUUtilization"
}
target_value = 60.0
}
}
CPU utilization is a reasonable starting metric, but it is a poor proxy for actual player load on most game servers, since a near-idle server can still peg a core on tick processing. A more accurate approach publishes a custom CloudWatch metric, such as active player count or sessions-per-instance, from the game server process itself and scales off that instead. Treat the CPU-based policy above as a safe default to get the fleet running, then replace it once you have real telemetry.
Step 7: Route Players to the Nearest Region With Global Accelerator
With two regional fleets running, you need a way to send each player to whichever region gives them the lowest latency, and to fail traffic over automatically if a region has an outage. AWS Global Accelerator does exactly this by anycasting two static IP addresses across the AWS global network edge and routing UDP or TCP traffic to the nearest healthy endpoint group.
resource "aws_globalaccelerator_accelerator" "gameserver" {
name = "gameserver-accelerator"
ip_address_type = "IPV4"
enabled = true
}
resource "aws_globalaccelerator_listener" "udp" {
accelerator_arn = aws_globalaccelerator_accelerator.gameserver.id
protocol = "UDP"
port_range {
from_port = var.game_udp_port_range.from
to_port = var.game_udp_port_range.to
}
}
resource "aws_globalaccelerator_endpoint_group" "primary" {
listener_arn = aws_globalaccelerator_listener.udp.id
endpoint_group_region = var.primary_region
endpoint_configuration {
endpoint_id = aws_autoscaling_group.gameserver.arn
weight = 100
}
}
Duplicate the endpoint group resource for the secondary region's Auto Scaling Group. Global Accelerator bills per accelerator-hour plus a data transfer premium on top of standard EC2 data transfer rates, so for a game still in development or soft launch, confirm current pricing against AWS's Global Accelerator pricing page before committing to it as your permanent routing layer. Smaller titles sometimes start with Route 53 latency-based routing instead and graduate to Global Accelerator once concurrent player counts justify the fixed hourly cost.
Step 8: Plan, Review, and Apply
Never run terraform apply directly against a configuration you have not planned and read. Treat terraform plan output the same way you would treat a pull request diff.
terraform init
terraform validate
terraform plan -out=gameserver.tfplan
terraform apply gameserver.tfplan
Expected output from a clean first apply looks roughly like this:
Plan: 24 to add, 0 to change, 0 to destroy.
module.vpc_primary.aws_vpc.this[0]: Creating...
module.vpc_primary.aws_vpc.this[0]: Creation complete after 2s
aws_security_group.gameserver: Creating...
aws_launch_template.gameserver: Creating...
aws_autoscaling_group.gameserver: Creating...
aws_autoscaling_group.gameserver: Creation complete after 34s
aws_globalaccelerator_accelerator.gameserver: Creating...
aws_globalaccelerator_accelerator.gameserver: Still creating... [3m10s elapsed]
aws_globalaccelerator_accelerator.gameserver: Creation complete after 4m2s
Apply complete! Resources: 24 added, 0 changed, 0 destroyed.
Outputs:
accelerator_dns_name = "a1b2c3d4e5f6g7h8.awsglobalaccelerator.com"
accelerator_static_ips = [
"75.2.60.x",
"99.83.89.x",
]
Global Accelerator is consistently the slowest resource in this stack to provision, often taking three to five minutes against sub-second times for most EC2 and VPC resources, because AWS has to propagate the anycast IPs across its edge network. Do not assume a stuck-looking apply has failed; let it run.
Step 9: Verify the Fleet Is Reachable
Confirm instances launched and passed health checks, then test actual UDP reachability against the Global Accelerator static IPs rather than the instance public IPs directly, since that is the path real players will take.
aws autoscaling describe-auto-scaling-groups \
--auto-scaling-group-names gameserver-asg \
--query "AutoScalingGroups[0].Instances[].{ID:InstanceId,Health:HealthStatus,State:LifecycleState}" \
--profile gameserver-infra
# nc -u -z -v 75.2.60.x 7777
# Connection to 75.2.60.x 7777 port [udp] succeeded!
If the health check shows instances stuck in Pending for more than a few minutes, jump straight to the troubleshooting section below rather than re-applying the configuration, since re-applying rarely fixes a boot-time user-data failure and just costs you another launch cycle.
Step 10: Add Cost Guardrails With Spot and Savings Plans
On-demand EC2 pricing is billed per second with a 60-second minimum and requires no upfront commitment, which is ideal while you are iterating on this Terraform configuration, according to AWS's current EC2 on-demand pricing page. Once your fleet shape stabilizes, Compute Savings Plans can cut costs by up to 72% compared to on-demand pricing, and that discount automatically carries over even if you later shift instance families, say from a c6i to a newer c7i generation, per AWS's published Savings Plans pricing guidance.
For non-latency-critical background capacity, such as a region you keep warm but rarely route live traffic to, mixing in Spot capacity through a mixed-instances policy on the same Auto Scaling Group can meaningfully cut idle-capacity cost. We cover the mechanics of that approach in depth in our guide to cutting game server cloud bills with AWS Spot Fleets, which pairs well with the Auto Scaling Group defined in Step 6 above.
Step 11: Add a Second Region End to End
With the pattern proven in one region, adding a second is mostly a copy-and-parameterize exercise. Wrap Steps 3 through 6 into a reusable module, then call it twice with different provider aliases and CIDR ranges:
module "fleet_primary" {
source = "./modules/gameserver-fleet"
providers = { aws = aws }
region = var.primary_region
vpc_cidr = "10.10.0.0/16"
instance_type = var.instance_type
}
module "fleet_secondary" {
source = "./modules/gameserver-fleet"
providers = { aws = aws.secondary }
region = var.secondary_region
vpc_cidr = "10.20.0.0/16"
instance_type = var.instance_type
}
Keep the module's internal resource names identical across regions. It is tempting to name the secondary region's security group something slightly different "for clarity," but that divergence is exactly what makes a future diff between regions hard to audit. Consistency here is more valuable than cosmetic clarity.
Step 12: Wire Up State Locking and Team Collaboration
Once more than one engineer touches this configuration, state locking stops being optional. The S3 backend with a DynamoDB lock table defined in Step 1 prevents two people from running terraform apply at the same time and corrupting state, but you also need a plan review workflow. Most teams either run Terraform through a CI pipeline that posts the plan output as a pull request comment, or adopt HCP Terraform, HashiCorp's managed platform formerly known as Terraform Cloud, for hosted state, policy checks, and run approvals. HCP Terraform's free tier covers small teams; paid tiers add Sentinel or OPA policy enforcement and SSO, both worth it once your game server fleet configuration is something a production incident can trace back to.
Step 13: Tear Down Safely
When you need to destroy a test environment, never run a bare terraform destroy against a configuration with multiple regions without reviewing the plan first.
terraform plan -destroy -out=destroy.tfplan
terraform apply destroy.tfplan
Global Accelerator resources in particular can take several minutes to fully deprovision, and a second destroy run while the first is still finishing will throw dependency errors rather than actually speeding anything up. Let one destroy operation finish completely before troubleshooting further.
Common Pitfalls When Terraforming Game Server Infrastructure
- Opening 0.0.0.0/0 on every port "to get it working," then forgetting to scope it down. This is the single most common security group mistake in game infrastructure, and it is exactly the kind of drift a Checkov scan will flag before it ships.
- Treating CPU utilization as a proxy for player load. A dedicated server can be lightly loaded on players but heavily loaded on tick processing, or vice versa, which makes CPU-based scaling policies a rough starting point rather than a long-term solution.
- Hardcoding AMI IDs instead of using a data source. An AMI ID is region-specific and goes stale the moment a new base image ships; a data "aws_ami" filter block avoids a config that silently launches a six-month-old image.
- Skipping state locking because "it's just me for now." Teams grow faster than Terraform configurations get updated to reflect that growth, and state corruption recovery is far more painful than setting up a DynamoDB lock table on day one.
- Letting the Auto Scaling Group max size default to something arbitrary. A max_size left at a leftover test value either throttles a real launch-day surge or, worse, allows a runaway scaling loop to rack up a surprising bill.
- Forgetting that Global Accelerator provisioning is slow. Engineers unfamiliar with the resource frequently assume a multi-minute apply has hung and kill the process, which can leave orphaned partial resources in AWS that Terraform state then disagrees with.
- Not separating state files per environment. Running dev, staging, and production game server fleets out of one shared state file means a single bad apply can take down everything at once; use separate state keys or workspaces per environment.
- Baking secrets into user-data scripts. User-data is visible to anyone with DescribeInstanceAttribute permissions on the instance; pull credentials from Secrets Manager or SSM Parameter Store at boot time instead.
Troubleshooting Guide
| Symptom | Likely Cause | Fix |
|---|---|---|
| Instances stuck in Pending in the ASG | User-data script failed silently | SSH or SSM into a stopped instance and check /var/log/cloud-init-output.log |
| Error: no matching AMI found | AMI filter pattern too narrow or wrong region | Loosen the values filter and confirm the provider region matches where the AMI actually exists |
| Players report they cannot connect over UDP | Security group only allows TCP, or the port range mismatches the binary's actual bind range | Confirm the ingress rule protocol is udp and the port range matches your server start script exactly |
| Error acquiring the state lock | A previous apply crashed without releasing the DynamoDB lock | Run terraform force-unlock LOCK_ID only after confirming no other apply is actually running |
| Global Accelerator apply hangs for 5+ minutes | Normal provisioning behavior, not a failure | Let it finish; check the AWS console Global Accelerator page for real-time status instead of killing the terminal |
| Auto Scaling Group won't scale past a certain count | Hit an account-level EC2 vCPU service quota | Check Service Quotas console for the relevant instance family vCPU limit and request an increase |
| Provider errors after upgrading the AWS provider version | Breaking change between provider major/minor versions | Read the provider's upgrade guide before bumping the version constraint, and run terraform plan before apply |
| Instances launch but immediately terminate | Health check failing due to a crashed game server process | Switch health_check_type temporarily to EC2 only while debugging the binary, then re-enable ELB health checks once stable |
| Cost spike with no obvious cause | Max size set too high combined with an aggressive scale-out policy | Lower max_size temporarily and add CloudWatch billing alarms scoped to the ASG's tagged resources |
Advanced Tips for Production Game Server Fleets
Once the basic fleet is running reliably, a few refinements separate a hobby project from something that survives a real launch. First, move from a single launch template to a mixed-instances policy that blends on-demand and Spot capacity, weighting Spot toward your least latency-sensitive region. Second, replace the CPU-based scaling policy with a custom CloudWatch metric published directly from your game server process, ideally something like active-sessions-per-instance, since that tracks actual player load rather than a loose proxy for it.
Third, consider splitting your Terraform configuration into separate state files for networking, compute, and routing layers once the project grows past a single team. A shared VPC module that rarely changes does not need to be re-planned every time someone tweaks an Auto Scaling policy, and separating concerns this way shrinks blast radius on any single apply. Finally, if your title needs session persistence or player state that must survive an instance termination, pair this fleet with a managed data layer; our walkthrough on building a serverless game leaderboard with AWS Lambda and DynamoDB shows a pattern that composes cleanly with the Terraform-managed compute layer built here, since the leaderboard backend can live in its own state file entirely decoupled from the game server fleet itself.
For teams running both a Terraform-managed EC2 fleet and a Kubernetes-based environment for backend services, the terraform-aws-modules/eks/aws module, currently at version 21.29.0, is the standard way to provision EKS clusters with the same IaC discipline described throughout this tutorial. Dedicated server game logic generally stays on raw EC2 for direct UDP control, while stateless backend services like matchmaking or inventory APIs often move to the Kubernetes side.
Complete Working Project Structure
Here is how the full project should be organized once all the pieces from this tutorial are assembled:
gameserver-infra/
âââ versions.tf
âââ variables.tf
âââ outputs.tf
âââ providers.tf
âââ modules/
â âââ gameserver-fleet/
â âââ vpc.tf
â âââ security_groups.tf
â âââ launch_template.tf
â âââ asg.tf
â âââ outputs.tf
âââ global_accelerator.tf
âââ environments/
â âââ dev.tfvars
â âââ staging.tfvars
â âââ production.tfvars
âââ README.md
Apply per environment with an explicit var file so dev experiments never accidentally touch production state:
terraform plan -var-file="environments/production.tfvars" -out=prod.tfplan
terraform apply prod.tfplan
Monitoring the Fleet With CloudWatch and Custom Metrics
A Terraform-provisioned fleet is only as reliable as the visibility you have into it once it is running. At minimum, wire up CloudWatch alarms on the Auto Scaling Group's instance count, the target tracking policy's actual-versus-target value, and a billing alarm scoped to the resource tags applied in Step 5. None of this requires leaving Terraform; CloudWatch alarms are just another resource type in the same configuration.
resource "aws_cloudwatch_metric_alarm" "low_instance_count" {
alarm_name = "gameserver-low-instance-count"
comparison_operator = "LessThanThreshold"
evaluation_periods = 2
metric_name = "GroupInServiceInstances"
namespace = "AWS/AutoScaling"
period = 60
statistic = "Average"
threshold = var.min_size
alarm_actions = [aws_sns_topic.ops_alerts.arn]
dimensions = {
AutoScalingGroupName = aws_autoscaling_group.gameserver.name
}
}
resource "aws_sns_topic" "ops_alerts" {
name = "gameserver-ops-alerts"
}
For the custom player-load metric referenced in Step 6, the game server process needs to push a numeric value to CloudWatch on a regular interval, either directly through the CloudWatch PutMetricData API or through a sidecar agent that scrapes a local health endpoint on the server binary. Once that custom metric exists in CloudWatch, a second target tracking policy can reference it instead of, or alongside, the default CPU-based policy, giving you a scaling signal that actually reflects how many players are connected rather than how hard the CPU happens to be working at that moment.
Teams running a mixed Kubernetes and EC2 environment, or who simply prefer an open-source observability stack over CloudWatch's native console, often layer Prometheus and Grafana on top of this same infrastructure, scraping the same custom metrics endpoint the CloudWatch agent would otherwise poll. Either path works; the important part is that the scaling policy from Step 6 is driven by a metric that actually represents player load, not an idle proxy for it.
Cost Comparison: On-Demand vs. Savings Plans vs. Spot
Pricing changes with region, instance family, and commitment term, so always confirm current numbers against the AWS Price List API before budgeting. The table below reflects the general shape of the discount tiers rather than exact dollar figures, which fluctuate.
| Purchase Option | Commitment | Typical Discount vs. On-Demand | Best For |
|---|---|---|---|
| On-Demand | None | Baseline (0%) | Development, testing, unpredictable launch-day spikes |
| Compute Savings Plans | 1 or 3 years | Up to 72% | Steady-state baseline capacity that rarely changes instance family |
| Spot Instances | None, interruptible | Typically 50-70% (varies by instance pool) | Non-latency-critical background regions or burst capacity |
| Reserved Instances | 1 or 3 years | Comparable to Savings Plans | Teams that prefer capacity reservation guarantees over flexibility |
Savings figures come directly from AWS's published Savings Plans pricing guidance, which states Compute Savings Plans can save up to 72% compared with on-demand pricing, with the discount carrying over automatically even if you move workloads between instance families, such as from a c6i to a newer c7i generation, as long as you stay within the same instance category.
Frequently Asked Questions
Is Terraform better than AWS CloudFormation for game server infrastructure?
Both are viable, and AWS's own Games Industry Lens documentation lists them side by side as acceptable IaC options. Terraform's advantage is multi-cloud portability and a larger ecosystem of community modules; CloudFormation's advantage is tighter native integration with AWS-specific features and no separate state file to manage, since AWS manages the state for you.
Do I need Kubernetes and Agones instead of raw EC2 for a dedicated game server fleet?
Not necessarily. Agones adds genuine value once you need fine-grained game-server lifecycle management across many concurrent match sessions per node, but for a simpler one-process-per-instance model, a Terraform-managed Auto Scaling Group is considerably less operational overhead. Teams already running Kubernetes for other services tend to adopt Agones; teams without existing Kubernetes investment often find raw EC2 faster to ship.
What EC2 instance type should I use for a dedicated game server?
For CPU-bound simulation without GPU rendering, the compute-optimized c6i family (or Graviton-based c7g for better price-to-performance if your binary supports arm64) is the standard starting point. Only move to a GPU-backed family like g5 if your server process does in-process rendering, ray-traced lighting simulation, or on-server AI inference.
How much does AWS Global Accelerator cost for a small game server fleet?
Global Accelerator bills a fixed hourly fee per accelerator plus a data transfer premium above standard EC2 transfer rates. Because pricing is subject to change and varies by data transfer volume, always check AWS's current Global Accelerator pricing page before committing a budget, and consider starting with Route 53 latency-based routing if your concurrent player count does not yet justify the fixed hourly cost.
Can I use Terraform to manage both my game server fleet and my matchmaking backend?
Yes, and many teams do, but it is worth splitting them into separate Terraform state files or workspaces rather than one monolithic configuration. The compute layer for game servers changes on a different cadence than a matchmaking or player-data backend, and keeping them in separate state reduces the blast radius of any single apply.
What happens to active player sessions during a Terraform-triggered instance refresh?
An Auto Scaling Group instance refresh with a rolling strategy replaces instances gradually rather than all at once, controlled by the min_healthy_percentage setting, but it does not gracefully drain active game sessions on its own. For a live title, pair the instance refresh with a custom lifecycle hook that waits for a game server to report zero active players before marking itself for termination, rather than relying on the default rolling behavior alone.
Is HCP Terraform (Terraform Cloud) necessary for a small team?
Not necessarily for a solo developer or very small team, where an S3-and-DynamoDB backend as shown in Step 1 is sufficient. HCP Terraform becomes more valuable once you need policy enforcement, SSO-controlled run approvals, or a hosted run history that does not depend on any single engineer's CI pipeline configuration.
Why did my security group only allow 60 rules when I needed more?
AWS's default quota is 60 inbound and 60 outbound rules per security group, enforced separately for IPv4 and IPv6, per AWS's current VPC quota documentation. If your design genuinely needs more than that in a single group, it is usually a sign the group's scope is too broad; splitting responsibilities across multiple, more narrowly scoped security groups is almost always a better fix than requesting a quota increase.





