project_name migration runbook
terraform/variables.tf's project_name (mirrored in terraform/aws/variables.tf)
is interpolated into nearly every AWS resource name, IAM identifier, log group
name, tag value, and Discord Secrets Manager ARN in the stack. Changing it —
including the one-time rebrand from the old game-servers default to
hyveon tracked in #213 —
is not a text replacement you can apply blind. This page is the
operational checklist to follow any time project_name changes on an
already-provisioned stack.
If you're bootstrapping a brand-new stack that has never been applied, none
of this applies — just set project_name in terraform.tfvars before the
first terraform apply and skip this page.
Why this needs a dedicated pass
Renaming project_name forces AWS to:
- Re-create resources whose name isn't in-place editable — CloudWatch log
groups, Secrets Manager secrets, and potentially ECS task definitions and
IAM roles all embed
${var.project_name}in theirname/Nameargument, which Terraform can only satisfy by destroy-then-create. - Re-tag every resource, since the
tagsvariable'sProjectvalue (terraform/variables.tf) flows through the AWS provider'sdefault_tags { tags = var.tags }block interraform/main.tfonto everything the stack manages. - Show a visible break in Cost Explorer for the
Projecttag — AWS Billing activates cost allocation tags by key, not by(key, value)pair, so an already-activatedProjectkey picks up the new value automatically (after AWS's usual ingestion lag) with no re-activation step required. What doesn't carry over is the cost data: historical spend recorded under the old value doesn't merge with the new value's data, so any Cost Explorer report grouped byProjectshows a visible break on the day of the migration.
Work through the checklist below in order.
project_nameThe Terraform backend itself — the S3 state bucket ({project_name}-tf-state)
and the DynamoDB lock table ({project_name}-tf-locks) described in
the setup guide — is named off project_name, the same variable
you're about to change. If you already have a live, applied stack and you
change project_name before touching the backend, the resulting
bucket/table names computed from the new value are different from, and
nonexistent relative to, the ones your existing state lives in.
A bare terraform init against a bucket/table that doesn't exist yet will
error out loudly rather than silently standing up an empty backend — the
S3 backend never auto-creates its bucket or lock table. The silent-orphan
risk instead comes from the desktop app's bootstrap step (the setup
wizard, or Settings → Reconfigure on an already-set-up install): it derives
{project_name}-tf-state/{project_name}-tf-locks from whatever
project_name is currently set to and idempotently creates them if missing,
then runs terraform init against that new backend. Re-running that
bootstrap step after changing project_name on an already-provisioned stack
will therefore happily create a brand-new empty bucket/table pair and point
Terraform at it — your real infrastructure's state file is left behind,
untouched and invisible to future plan/apply runs (effectively orphaned,
not migrated).
Before re-running the app's bootstrap step (or terraform plan/apply
directly) with the new project_name, pick one:
- (a) Keep the existing backend. Pin
project_name = "game-servers"(the old default, prior to thehyveonrebrand tracked in #213) — or whatever value your stack was actually applied with — only for the backend, e.g. keep passing-backend-config="bucket=game-servers-tf-state" -backend-config="dynamodb_table=game-servers-tf-locks"(or whatever your current bucket/table names are) toterraform initwhile still letting the newproject_namevalue flow through everything else viaterraform.tfvars. This is the lower-risk default: the state file doesn't move, only the resources it manages get renamed/retagged per the checklist below. - (b) Deliberately migrate the backend. If you want the bucket/table
names themselves to match the new
project_name, create the new S3 bucket + DynamoDB table first (don't let a re-run of the app's bootstrap step silently create them against empty state), back up the existing state object in S3, then runterraform init -migrate-statepointing at the new backend config — let Terraform copy the state over itself rather than manually copying the state object or the lock table row; Terraform manages locking during the migration, and a stray copied lock row can leave the new backend stuck locked. Verifyterraform planshows no unexpected creates for already-existing infrastructure before applying — a full-stack "everything will be created" plan means you're looking at an empty backend, not a migration.
Do not skip this — it is the single most common way a project_name change
turns into an accidental duplicate/orphaned stack.
project_nameSeparately from the backend above, terraform/main.tf's
data "aws_s3_bucket" "tfvars" resolves an unconditional, required data
source:
data "aws_s3_bucket" "tfvars" {
bucket = coalesce(var.tfvars_bucket_name, "${var.project_name}-tfvars")
}
tfvars_bucket_name defaults to null, so unless you set it explicitly the
lookup falls back to "${var.project_name}-tfvars" — the new project
name's bucket, not the one terraform/bootstrap/ actually created your
tfvars store under. Because this is a data source (not a resource), Terraform
can't paper over a missing bucket the way apply can for a resource: step
1's terraform plan fails outright with a "couldn't find resource"/no
such bucket error the moment project_name changes and no matching bucket
exists yet.
Worse, if you re-run the app's bootstrap step again after changing
project_name, it derives the tfvars bucket name the same way (off the
new project_name) and will happily bootstrap a brand-new
{new_project_name}-tfvars bucket. If you're instead bootstrapping manually
via terraform/bootstrap/ (see the S3 tfvars storage guide),
the same risk applies — it derives the bucket name off whatever
project_name is current at the time. Don't assume the "real" tfvars
content silently stayed behind in the old bucket — always verify which
bucket you're actually pointed at (aws s3 cp s3://<bucket>/terraform.tfvars -
or aws s3api head-object) before trusting a freshly-bootstrapped bucket for
any terraform plan/apply.
Before re-running the app's bootstrap step (or terraform plan/apply
directly) with the new project_name, pick one here too:
- (a) Keep the existing tfvars bucket. Set
tfvars_bucket_name = "<old-project-name>-tfvars"interraform.tfvars(see the commented-out example interraform.tfvars.example) so the data source keeps resolving to the bucketterraform/bootstrap/already created, regardless of whatproject_namebecomes. Leave.hyveon/tfvars-bucket(if present) pointing at that same bucket name — don't let a re-run of the app's bootstrap step rewrite it. - (b) Migrate the tfvars bucket too. Create the new
{new_project_name}-tfvarsbucket (viaterraform/bootstrap/, or let the app's bootstrap step do it), then copy the existingterraform.tfvarsobject across —aws s3 cp s3://<old-project-name>-tfvars/terraform.tfvars s3://<new-project-name>-tfvars/terraform.tfvars— and update.hyveon/tfvars-bucketto the new bucket name before runningterraform plan. Confirm the copied object round-trips (scripts/tfvars-sync.ts diff, or a manualaws s3 cp ... -and eyeball it) before treating the migration as done.
Checklist
1. Review the terraform plan output line by line
Run terraform plan (or make plan if you're on the
submodule/S3 tfvars workflow) after changing
project_name in terraform.tfvars, and read the full plan before applying
anything:
cd terraform
terraform plan
Enumerate every resource in the plan into one of two buckets:
- Replace (destroy + create) — anything whose
nameembedsproject_name: the Lambda CloudWatch log groups (/aws/lambda/${project_name}-watchdog, etc.), the two Discord Secrets Manager secrets (${project_name}/discord/bot-token,${project_name}/discord/public-key), the DynamoDB table (${project_name}-discord), IAM roles/policies named afterproject_name, the ECS cluster (${project_name}-cluster), the Lambda functions themselves, andaws_efs_file_system.saves(terraform/aws/main.tf) — itscreation_tokenis"${var.project_name}-saves", which is aForceNewargument on the EFS filesystem, so it drags the mount targets (aws_efs_mount_target.saves) and every per-game access point (aws_efs_access_point.game) along with it as dependent replacements. See the danger box right after this list — this is the most destructive replacement in the plan, not the DynamoDB table. - Update in place (tags only) — resources whose name doesn't reference
project_namebut that pick up the newProjecttag value viadefault_tags. This includes the game-server CloudWatch log groups (/ecs/${each.key}-server) — their name is keyed off the game, notproject_name, so they're only retagged, not replaced — and the VPC (aws_vpc.main), whose onlyproject_namereference is itsNametag.
Do not apply until you've confirmed the replace-set matches what you expect —
an unexpected replacement (e.g. the VPC) is a sign project_name is
referenced somewhere you didn't account for.
aws_efs_file_system.saves) is destroyed and recreated empty — this permanently deletes all game save dataUnlike the DynamoDB table (step 4, below), which is at least repopulatable
from tfvars or a config you wrote down first, aws_efs_file_system.saves has
no equivalent recovery path. Its creation_token is
"${var.project_name}-saves" — a ForceNew argument — so any project_name
change forces Terraform to destroy the filesystem and create a brand-new,
empty one under the new creation token. Every per-game save directory
(aws_efs_access_point.game) and the mount target(s) (aws_efs_mount_target.saves)
are destroyed along with it. There is no backup, no snapshot, and no
migration path built into this stack — once terraform apply completes,
every game's save data on the old filesystem is gone.
Before applying a project_name change on a stack with real save data on it,
do one of:
- Back up the EFS data first. Use AWS Backup
to take an on-demand backup of the filesystem, or run an
AWS DataSync
task (or a plain
rsync/tarfrom an EC2 instance or Fargate task with the EFS mounted) to copy the save directories out to S3 or another filesystem you control, before runningterraform apply. Restore into the new filesystem's access points afterwards. - Avoid the replacement entirely. Either keep
project_namepinned for this resource (e.g. temporarily hardcode the old creation token instead of interpolatingvar.project_name, apply, then plan the rename separately with a deliberateterraform state mv/ import once you've verified the token can be changed in place — it can't, today, so this is a bigger change than this runbook covers), or accept the destroy and treat it as a fresh, empty save volume post-migration. - At minimum, confirm with whoever operates the stack that save-data loss is acceptable before applying — don't assume it is because the DynamoDB danger box (step 4) was handled.
2. Coordinate downtime for in-flight task definitions
If any game-server ECS tasks are currently RUNNING, the ECS cluster
(${project_name}-cluster) and the project_name-named task execution IAM
role (${project_name}-task-execution) that those tasks depend on will be
replaced by the apply. Stop running tasks (or schedule the apply for a
window when no one is playing) before applying — a running task can lose its
CloudWatch logs and cluster connection because the ECS cluster and task
execution role are destroyed and recreated under the new name, and the
watchdog Lambda's idle-tag bookkeeping (see step 5) resets when the task
itself is replaced.
3. Apply, then confirm the new Project value shows up in Cost Explorer
After terraform apply completes successfully:
terraform apply
Cost allocation tag activation is per tag key, not per value — if
Project is already an activated cost allocation tag (check AWS Billing →
Cost allocation tags), the new value (e.g. Project = hyveon) appears
there automatically once AWS ingests the retagged resources; there's no
re-activation step to perform. Only activate Project from that page if
it isn't already listed as active. Either way, allow for AWS's usual
ingestion lag before the new value shows up, and don't expect historical
cost data to merge across values — spend recorded under the old value
(e.g. Project = game-servers-poc) stays separate from the new value's
data, so any Cost Explorer report grouped by the Project tag shows a
visible break on the day of the migration.
4. Verify the Discord Secrets Manager ARNs came back online
The two Discord secrets are recreated under new ARNs (the name segment
changes from {old_project_name}/discord/... to
{new_project_name}/discord/...). The interactions Lambda reads
DISCORD_PUBLIC_KEY_SECRET_ARN from its environment at cold start (see
terraform/aws/interactions.tf) to verify Discord's Ed25519 signatures. The
followup Lambda doesn't read either secret ARN from its environment — it has
no need to, since it never touches the bot token or public key directly.
The bot token is instead resolved by the desktop app: ConfigService reads
discord_bot_token_secret_arn / discord_public_key_secret_arn out of
terraform.tfstate, and DiscordConfigService uses those ARNs to read/write
the secrets via Secrets Manager on demand. Confirm both paths picked up the
new ARNs:
aws secretsmanager describe-secret --secret-id "<new-project-name>/discord/bot-token"
aws secretsmanager describe-secret --secret-id "<new-project-name>/discord/public-key"
- Re-enter the bot token and public key through the desktop app's
Credentials tab (or reseed via
discord_bot_token/discord_public_keyinterraform.tfvarsbefore applying) — the new secrets are created with a"placeholder"value on first apply and won't have real credentials until you set them. - Invoke
/server-statusin Discord once credentials are re-entered to confirm the interactions Lambda can verify signatures against the new public-key secret. To confirm the desktop app itself picked up the new secrets, check the Discord Credentials tab and confirmbotTokenSet/publicKeySetboth show as set.
${project_name}-discord) is wiped, not migratedStep 1's replace-bucket also includes the DynamoDB table
${project_name}-discord. Unlike the two secrets — which are recreated with
a placeholder value you then refill — the table is destroyed and recreated
empty under its new name. That table is the only copy of:
- The
DiscordConfigrow: the guild allowlist, admin users/roles, and every per-game user/role permission entry read bycanRun()(@hyveon/shared/canRun). - Any pending-interaction rows written by
@hyveon/lambda-followupfor in-flight/server-startcommands (15-minute TTL — likely already expired by the time you're mid-migration, but don't count on it).
Until this row is repopulated, the interactions Lambda's guild-allowlist check has nothing to allow — every guild is rejected, including the ones that worked before the migration. There is no automatic carry-over from the old table to the new one.
Before applying, do one of:
- Record the config first. Read the guild allowlist / admin / per-game
permission fields off the desktop app's Discord page and write down every
value before running
terraform apply. After the apply, re-enter the same values through the desktop app's Discord page — this repopulates the row via the normal save path. - Reseed via tfvars — partial only.
aws_dynamodb_table_item.discord_config_seedonly writesclientIdinto theCONFIG#discordrow (fromdiscord_application_id);allowedGuilds,admins, andgamePermissionson that row are always seeded empty. The separateaws_dynamodb_table_item.discord_base_configresource does repopulatebase_allowed_guilds/base_admin_user_ids/base_admin_role_idsfrom tfvars into theBASE#discordrow, which the app merges with the config row. Per-game permissions (gamePermissions) and any pending-interaction rows never round-trip through tfvars — they're always empty after a migration and must be re-entered manually through the desktop app's Discord page.
Either way, verify the allowlist is live again (the desktop app's Discord
page, or /server-status from an allowed guild) before considering step 4
done.
5. Confirm the watchdog Lambda still finds tasks by tag
@hyveon/lambda-watchdog locates and tags running ECS tasks directly — it
doesn't look them up by project_name, but its idle-check counter is stored
as a tag on each task, and its IAM role/log group names embed
project_name. After the apply:
- Check
/aws/lambda/${project_name}-watchdog(the newproject_name-qualified log group) for a successful invocation on its next scheduled run (watchdog_interval_minutes). - Start a game server and confirm the watchdog's idle-check tag appears on
the running task (
aws ecs describe-tasks --tasks <task-arn> --cluster <cluster> --include TAGS --query 'tasks[].tags') after one interval, and that the counter increments/resets as expected rather than erroring on a missing role/permission.
6. Sanity-check Cost Explorer the next day
Cost Explorer data has ingestion lag — the newly-activated Project = <new-value> tag won't show usage from before the activation, and same-day
data is frequently incomplete. Come back the day after the apply and confirm:
- The new
Projecttag value appears as a filter/group-by option in Cost Explorer. - Costs for the migrated resources are attributing to the new tag value going forward.
- The old tag value's historical data is still intact (it should be — migrating the tag value doesn't delete the old activation or its data).
Related documentation updates
A project_name migration usually also requires touching these files in the
same change — see the "Checklist for Terraform variable changes" section in
the repo's CLAUDE.md for the general case:
CLAUDE.md'sProject=<value>reference in the cost-allocation comment.terraform/terraform.tfvars.example'sproject_nameexample value.docs/docs/components/terraform.md's variables table, if the default changed.docs/docs/setup.md, anywhere it references the IAM ARN prefixes or defaultproject_namevalue (e.g. thehyveon-*IAM resource patterns, the{project_name}-tfvars/{project_name}-tf-statebucket names).