Skip to content

Commit 069c245

Browse files
CoderCococlaude
andauthored
feat(terraform): support multiple EFS volumes per game server (#37)
## Summary Refactored the EFS volume configuration to support multiple mount points per game server, replacing the single `efs_path` field with a flexible `volumes` list. Each volume entry now creates its own EFS access point with isolated directory structure. ## Key Changes - **Variable schema update**: Replaced `efs_path: string` with `volumes: list(object({ name, container_path }))` in `game_servers` variable definition - **New local value**: Added `game_volumes` local that flattens game-volume pairs into a keyed map (`${game}-${volume.name}`) for EFS access point creation - **EFS access point refactor**: Changed from one access point per game to one per game-volume pair, with root paths now structured as `/${game}/${volume_name}` instead of `/${game}` - **Task definition volumes**: Converted static `volume` block to `dynamic "volume"` block that iterates over `each.value.volumes`, creating one volume mount per configured volume - **Container mount points**: Updated `mountPoints` from a single static entry to a dynamic list comprehension that maps each volume to its container path - **Documentation updates**: Updated setup guide, example configs, and component docs to reflect the new `volumes` structure and explain the per-volume access point isolation ## Implementation Details - The flattening logic in `game_volumes` ensures each game-volume combination gets a unique key while preserving all necessary metadata (game name, volume name, container path) - EFS access points maintain UID/GID 1000 ownership per volume, with each volume isolated under its own directory hierarchy - The change is backward compatible in behavior—single-volume games simply have one entry in the `volumes` list - All example configurations updated to demonstrate the new structure (Palworld, Satisfactory, FoundryVTT) https://claude.ai/code/session_01SkWgmzBi8EPkSvpf3shwno --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 03da099 commit 069c245

6 files changed

Lines changed: 81 additions & 34 deletions

File tree

docs/docs/components/terraform.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ step 3 of the [setup guide](/setup) for details.
3131
| `aws_region` | `string` | `us-east-1` | AWS region for all resources. |
3232
| `project_name` | `string` | `game-servers` | Prefix for named resources and the Secrets Manager paths. |
3333
| `vpc_cidr` | `string` | `10.0.0.0/16` | Parent CIDR; subnets are /24s within it. |
34-
| `game_servers` | `map(object)` || The single source of truth. Per-game: `image`, `cpu`, `memory`, `ports[]`, `environment[]`, `efs_path`, `https`. |
34+
| `game_servers` | `map(object)` || The single source of truth. Per-game: `image`, `cpu`, `memory`, `ports[]`, `environment[]`, `volumes[]` (`name` + `container_path`), `https`. Each `volumes` entry creates its own EFS access point rooted at `/${game}/${name}`. |
3535
| `hosted_zone_name` | `string` | `codercoco.com` | Existing Route 53 zone looked up as a data source. |
3636
| `acm_certificate_domain` | `string` | `null``*.{hosted_zone_name}` | Wildcard ACM cert for the ALB listener. |
3737
| `dns_ttl` | `number` | `30` | TTL on Route 53 A records the update-dns Lambda writes. Keep low for fast task churn. |

docs/docs/setup.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,17 +197,21 @@ game_servers = {
197197
{ name = "SERVER_NAME", value = "My Palworld Server" },
198198
{ name = "ADMIN_PASSWORD", value = "CHANGE_ME" },
199199
]
200-
efs_path = "/palworld"
201-
https = false
200+
volumes = [
201+
{ name = "saves", container_path = "/palworld" },
202+
]
203+
https = false
202204
}
203205
}
204206
```
205207

206208
Rules worth knowing before you save:
207209

208-
- **`efs_path`** maps to a dedicated EFS access point — each game is isolated
209-
in its own directory with UID/GID 1000 ownership. Game images that run as
210-
a different UID will fail to mount.
210+
- **`volumes`** is a list of EFS mount points for the game. Each entry creates
211+
a dedicated EFS access point rooted at `/${game}/${name}` and mounts it at
212+
`container_path` inside the container. Most games need one entry; add more
213+
if the image expects multiple distinct paths. All access points use UID/GID
214+
1000 ownership — game images that run as a different UID will fail to mount.
211215
- **`https = true`** routes the game through an ALB + ACM + Route 53 ALIAS.
212216
Only set it on games that actually serve HTTP(S); UDP games (most game
213217
servers) must stay `false`. The ALB is only created if at least one game

terraform/main.tf

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,21 @@ resource "aws_route_table_association" "public" {
8787
# Dynamic ingress rules — one per port across all configured game servers.
8888

8989
locals {
90+
# Flattened map of all game-volume pairs, keyed by "${game}-${volume.name}".
91+
# Used to create one EFS access point per volume entry.
92+
game_volumes = {
93+
for pair in flatten([
94+
for game, cfg in var.game_servers : [
95+
for vol in cfg.volumes : {
96+
key = "${game}-${vol.name}"
97+
game = game
98+
name = vol.name
99+
container_path = vol.container_path
100+
}
101+
]
102+
]) : pair.key => pair
103+
}
104+
90105
# Ports for non-HTTPS games — open directly to the internet
91106
direct_game_ports = {
92107
for pair in distinct(flatten([
@@ -224,9 +239,9 @@ resource "aws_efs_mount_target" "saves" {
224239
security_groups = [aws_security_group.efs.id]
225240
}
226241

227-
# One access point per game — isolates each game's save directory
242+
# One access point per game volume — isolates each volume under /${game}/${volume_name}
228243
resource "aws_efs_access_point" "game" {
229-
for_each = var.game_servers
244+
for_each = local.game_volumes
230245
file_system_id = aws_efs_file_system.saves.id
231246

232247
posix_user {
@@ -235,15 +250,15 @@ resource "aws_efs_access_point" "game" {
235250
}
236251

237252
root_directory {
238-
path = "/${each.key}"
253+
path = "/${each.value.game}/${each.value.name}"
239254
creation_info {
240255
owner_uid = 1000
241256
owner_gid = 1000
242257
permissions = "0755"
243258
}
244259
}
245260

246-
tags = { Name = "${each.key}-saves" }
261+
tags = { Name = each.key }
247262
}
248263

249264
# ── CloudWatch Log Groups ─────────────────────────────────────────────────────
@@ -308,14 +323,17 @@ resource "aws_ecs_task_definition" "game" {
308323
memory = each.value.memory
309324
execution_role_arn = aws_iam_role.ecs_task_execution.arn
310325

311-
volume {
312-
name = "${each.key}-saves"
313-
efs_volume_configuration {
314-
file_system_id = aws_efs_file_system.saves.id
315-
transit_encryption = "ENABLED"
316-
authorization_config {
317-
access_point_id = aws_efs_access_point.game[each.key].id
318-
iam = "DISABLED"
326+
dynamic "volume" {
327+
for_each = each.value.volumes
328+
content {
329+
name = "${each.key}-${volume.value.name}"
330+
efs_volume_configuration {
331+
file_system_id = aws_efs_file_system.saves.id
332+
transit_encryption = "ENABLED"
333+
authorization_config {
334+
access_point_id = aws_efs_access_point.game["${each.key}-${volume.value.name}"].id
335+
iam = "DISABLED"
336+
}
319337
}
320338
}
321339
}
@@ -333,9 +351,9 @@ resource "aws_ecs_task_definition" "game" {
333351

334352
environment = each.value.environment
335353

336-
mountPoints = [{
337-
sourceVolume = "${each.key}-saves"
338-
containerPath = each.value.efs_path
354+
mountPoints = [for vol in each.value.volumes : {
355+
sourceVolume = "${each.key}-${vol.name}"
356+
containerPath = vol.container_path
339357
readOnly = false
340358
}]
341359

terraform/outputs.tf

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,11 @@ output "file_manager_security_group_id" {
5959
}
6060

6161
output "efs_access_points" {
62-
description = "Map of game name → EFS access point ID"
63-
value = { for game, ap in aws_efs_access_point.game : game => ap.id }
62+
description = "Map of game name → first volume's EFS access point ID (consumed by FileManagerService)"
63+
value = {
64+
for game, cfg in var.game_servers :
65+
game => aws_efs_access_point.game["${game}-${cfg.volumes[0].name}"].id
66+
}
6467
}
6568

6669
output "alb_dns_name" {

terraform/terraform.tfvars.example

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,12 @@ game_servers = {
6060
{ name = "BACKUP_CRON_EXPRESSION", value = "0 */6 * * *" },
6161
{ name = "DIFFICULTY", value = "Normal" },
6262
]
63-
efs_path = "/palworld"
64-
https = false
63+
# Each entry gets its own EFS access point rooted at /${game}/${name}.
64+
# Add more entries if the image expects multiple mount paths.
65+
volumes = [
66+
{ name = "saves", container_path = "/palworld" },
67+
]
68+
https = false
6569
}
6670

6771
# FoundryVTT — web-based virtual tabletop (requires HTTPS)
@@ -83,7 +87,9 @@ game_servers = {
8387
{ name = "CONTAINER_VERBOSE", value = "true" },
8488
{ name = "FOUNDRY_WORLD", value = "my-world" },
8589
]
86-
efs_path = "/data"
87-
https = true
90+
volumes = [
91+
{ name = "data", container_path = "/data" },
92+
]
93+
https = true
8894
}
8995
}

terraform/variables.tf

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,20 @@ variable "game_servers" {
2828
memory = number # MiB
2929
ports = list(object({ container = number, protocol = string }))
3030
environment = optional(list(object({ name = string, value = string })), [])
31-
efs_path = string # Mount path inside the container
31+
volumes = list(object({ name = string, container_path = string }))
3232
https = optional(bool, false) # If true, traffic is routed through ALB with TLS termination
3333
}))
3434

35+
validation {
36+
condition = alltrue([
37+
for cfg in values(var.game_servers) :
38+
length(cfg.volumes) > 0 && alltrue([
39+
for v in cfg.volumes : length(v.name) > 0 && length(v.container_path) > 0
40+
])
41+
])
42+
error_message = "Each game server must have at least one volume entry with non-empty name and container_path."
43+
}
44+
3545
default = {
3646
palworld = {
3747
image = "thijsvanloef/palworld-server-docker:latest"
@@ -53,8 +63,10 @@ variable "game_servers" {
5363
{ name = "BACKUP_CRON_EXPRESSION", value = "0 */6 * * *" },
5464
{ name = "DIFFICULTY", value = "Normal" },
5565
]
56-
efs_path = "/palworld"
57-
https = false
66+
volumes = [
67+
{ name = "saves", container_path = "/palworld" },
68+
]
69+
https = false
5870
}
5971

6072
satisfactory = {
@@ -71,8 +83,10 @@ variable "game_servers" {
7183
{ name = "PGID", value = "1000" },
7284
{ name = "PUID", value = "1000" },
7385
]
74-
efs_path = "/config"
75-
https = false
86+
volumes = [
87+
{ name = "config", container_path = "/config" },
88+
]
89+
https = false
7690
}
7791

7892
foundryvtt = {
@@ -87,8 +101,10 @@ variable "game_servers" {
87101
{ name = "FOUNDRY_PROXY_PORT", value = "443" },
88102
{ name = "CONTAINER_VERBOSE", value = "true" },
89103
]
90-
efs_path = "/data"
91-
https = true
104+
volumes = [
105+
{ name = "data", container_path = "/data" },
106+
]
107+
https = true
92108
}
93109
}
94110
}

0 commit comments

Comments
 (0)