Private parent repo + submodule
This is the pattern we recommend if you're running the stack for real: a
private parent repo you own, with this repo vendored as a git submodule,
plus all the per-deployment secrets sitting alongside. Nothing sensitive ever
lives in a public fork, and pulling upstream changes is one make update
away.
If you're just kicking the tyres, the plain setup guide is fine. Come back to this page when you're ready to commit your config to source control.
Why this layout
terraform.tfvars (with your hosted zone, and optionally Discord
credentials) and terraform.tfstate (raw infrastructure state including IAM
role names and the DNS zone ID) are yours. Neither belongs in this
public repo. A submodule keeps upstream code cleanly separated from your
deployment-specific configuration without forking.
The parent's wrapper Makefile copies terraform.tfvars into the submodule on
every plan/apply, then drives Terraform/dev work directly — npm install,
Lambda builds, the Terraform S3 state backend bootstrap, and
terraform init/plan/apply/dev servers are all inlined in the
Makefile's own recipes. Nothing shells out to a script or another Makefile
inside the submodule. The submodule checkout stays clean — only files
inside its own .gitignore get touched.
Reference layout
your-private-games/ # private repo you own
├── .gitmodules
├── .gitignore # ignores .make/, *.tfstate
├── Makefile # self-contained wrapper — see "What the wrapper does" below
├── terraform.tfvars # YOUR copy; checked in (private repo)
├── .make/ # stamp dir (cached tf-project/tf-region, tfstate.json)
└── Hyveon/ # submodule → CoderCoco/Hyveon
That's the whole shape. No config/ directory, no symlinks, no
docker-compose.override.yml. Everything driven from one self-contained
Makefile.
Quick start (interactive scaffolder)
The public repo ships an interactive TypeScript script that writes three of
the files above for you — Makefile, terraform.tfvars, and .gitignore
(.gitmodules is created by git submodule add in step 2 below, and
.make/ is written later by make setup, not by this script). It only
needs Node.js 24+, which you already need for the rest of the project.
# 1. Create a private repo on GitHub, then clone it.
git clone git@github.com:you/your-private-games.git
cd your-private-games
# 2. Add the submodule.
git submodule add https://github.com/CoderCoco/Hyveon.git
# 3. Install all deps and run the scaffolder.
(cd Hyveon && npm install)
(cd Hyveon && npm run scripts:init-parent)
The script prompts for project name, AWS region, hosted zone, and
(optionally) Discord credentials, then writes Makefile,
terraform.tfvars, and .gitignore. Existing files are skipped
unless you pass --force.
init-parent.ts dispatches on subcommands, with bootstrap (the flow
above) implied when none is given:
init-parent.ts [--force] [--s3-tfvars] [--yes] Interactive bootstrap (default)
init-parent.ts migrate --to-s3 | --to-local [--yes] Migrate an existing parent repo's tfvars backend
--s3-tfvars pre-answers the "bootstrap an S3-backed tfvars store now?"
prompt with yes and writes .hyveon/tfvars-bucket up front, so make setup
provisions the S3 backend on its first run instead of asking interactively.
--yes skips confirmation prompts generally (see scripts/README.md
for the exact per-subcommand semantics). Already have a scaffolded parent
repo and want to switch its tfvars backend after the fact instead? See
Migrating an existing parent repo's tfvars backend
below.
After it finishes:
$EDITOR terraform.tfvars # add at least one entry under game_servers
make setup # init submodule, install deps, bootstrap the S3 backend, terraform init
make plan
make apply
What the wrapper Makefile does
The generated wrapper is fully self-contained — every step is inlined directly in its recipes; it never shells out to a script or another Makefile inside the submodule. Eight targets, no surprises:
| Target | What it does |
|---|---|
make setup | One-time bootstrap. Runs git submodule update --init --recursive, npm install and npm run app:build:lambdas in the submodule, derives project_name/aws_region from terraform.tfvars, creates the Terraform state S3 bucket + DynamoDB lock table if they don't already exist (the same aws s3api/aws dynamodb calls a standalone bootstrap script would make — this Makefile inlines that logic rather than shelling out to one), optionally bootstraps a versioned S3 bucket for terraform.tfvars itself via the submodule's terraform/bootstrap/ module, then runs terraform init with the matching -backend-config flags. If it just bootstrapped a versioned S3 tfvars backend, setup also pulls terraform.tfvars down afterwards; on a first bootstrap against an empty bucket the pull can't find anything yet, so it prints a warning suggesting make tfvars-push to seed the bucket instead of failing make setup. |
make plan | Copies terraform.tfvars into Hyveon/terraform/terraform.tfvars, rebuilds the Lambda bundles, then runs terraform plan directly. When an S3 tfvars backend is detected, it auto-pulls the latest tfvars first so a stale local copy can't silently drive the plan; set NO_PULL=1 to skip the pull for one invocation. |
make apply | Same as plan, but runs terraform apply and prints a post-deploy checklist with the Discord interactions URL when it finishes. When an S3 tfvars backend is detected, it first asserts the local tfvars are still in sync with S3 (via the tfvars-sync CLI's check subcommand), refusing to apply against drifted vars; set FORCE_APPLY=1 to skip the check for one invocation. |
make update | Bumps the submodule to the tip of main (git submodule update --remote --merge), then unconditionally re-runs terraform init — cheap and idempotent, so there's no drift-detection stamp deciding whether to rerun it. Reminds you to commit the new submodule pointer. |
make dev | Pulls live tfstate into .make/tfstate.json (so ConfigService can read it via TF_STATE_PATH), wipes stale TS build info under the submodule's app/packages/*/, then runs npm run app:dev directly in the submodule with TF_STATE_PATH set. |
make tfvars-pull | Pulls terraform.tfvars from the configured S3 backend (requires one to be detected — see below). Refuses to run if the local file has uncommitted git changes, so a pull can never silently discard edits you haven't committed. |
make tfvars-push | Pushes the local terraform.tfvars to the configured S3 backend (requires one to be detected). |
make tfvars-diff | Prints a unified diff between the local and remote terraform.tfvars (requires a backend to be detected). |
The tfvars copy is always fresh on plan/apply — the recipe cps
unconditionally, not just when the file is older than the destination. This
prevents stale variables from sneaking into a deploy when you've edited the
parent's terraform.tfvars between runs.
S3 tfvars backend detection
See S3 tfvars storage for the full guide to bootstrapping, day-to-day syncing, migrating an existing parent repo onto (or off) this backend, and troubleshooting sync conflicts. The rest of this section covers how the generated Makefile detects which backend is active.
The three tfvars-* targets, and the automatic gating baked into
setup/plan/apply above, all key off the same TFVARS_BACKEND
resolution in the generated Makefile:
HYVEON_TFVARS_BACKEND=s3forces S3 mode, even if the marker file below is missing.HYVEON_TFVARS_BACKEND=localforces local-file mode, even if a marker file is present.- Otherwise: S3 if either marker file exists — the parent-root
.hyveon/tfvars-bucket(written bybootstrap --s3-tfvarsormigrate --to-s3) or the submodule-localHyveon/.hyveon/tfvars-bucket(written bymake setup's own S3 tfvars-bootstrap step) — local otherwise. A legacy.gsd/tfvars-bucketmarker (pre-rename) is also accepted at each of those two locations, with a one-time warning, if the.hyveonpath isn't present — runmv .gsd .hyveonto migrate and silence the warning.
HYVEON_TFVARS_BUCKET, if set, wins over both marker files' contents when the
wrapper needs to display or pass along the bucket name. Otherwise it reads
the parent-root marker first, falling back to the submodule-local marker if
the parent-root one doesn't exist — see Parent-root marker takes
precedence below for why.
make tfvars-pull, make tfvars-push, and make tfvars-diff fail fast
with a pointer to HYVEON_TFVARS_BACKEND/make setup when no backend is
detected — they're operator-driven, so they never silently no-op.
The gates inside setup/plan/apply behave differently: in local
mode (no marker file and HYVEON_TFVARS_BACKEND isn't s3) they're silent
no-ops, so make setup, make plan, and make apply behave exactly as
they did before S3 tfvars sync existed. Nothing changes for a single-file,
no-remote-backend deployment.
Migrating an existing parent repo's tfvars backend
Started out on a local terraform.tfvars (or bootstrapped an S3 backend and
want to drop it) and want to switch after the fact, without re-running the
whole interactive scaffolder? init-parent.ts migrate rewires an
already-scaffolded parent repo (one where bootstrap has already run and a
Makefile exists) in place. Run it the same way as bootstrap, but from an
existing parent repo checkout:
npx --prefix Hyveon/scripts tsx Hyveon/scripts/init-parent.ts migrate --to-s3
# or
npx --prefix Hyveon/scripts tsx Hyveon/scripts/init-parent.ts migrate --to-local
Exactly one of --to-s3 / --to-local is required. Both directions prompt
for confirmation before touching anything — pass --yes to skip the prompt
(e.g. for scripting/CI).
migrate --to-s3 — moves a local-only parent repo onto a versioned S3
backend:
- Reads
project_nameout of the parent repo's existingterraform.tfvarsand derives the bucket name${project_name}-tfvars— the same namingbootstrap --s3-tfvarsuses. - Writes the
.hyveon/tfvars-bucketmarker at the parent repo root. - Rewrites the
Makefilewith the S3-aware targets (identical output to a freshbootstraprender — the Makefile is always S3-aware; only the marker's presence flipsTFVARS_BACKENDtos3). - Runs
make setupwithHYVEON_TFVARS_BACKEND=s3soterraform/bootstrap/provisions the bucket.
terraform.tfvars itself is never read for anything beyond project_name,
nor written by the migration — pull the now-remote copy down explicitly with
make tfvars-pull if you want confirmation it round-tripped through S3 (or
push it up with make tfvars-push if the bucket comes back empty).
migrate --to-local — the reverse: drops an S3 backend and reverts to
reading terraform.tfvars straight off disk:
- Resolves the target bucket the same way the generated Makefile's
TFVARS_BUCKETdoes —HYVEON_TFVARS_BUCKETenv var wins if set, otherwise the parent-root.hyveon/tfvars-bucketmarker, otherwise the submodule-local one written bymake setup's own S3 tfvars-bootstrap step, otherwise the legacy.gsd/tfvars-bucketpath at either location. Exits1with no changes if none resolve (already local, nothing to migrate). - Pulls
terraform.tfvarsdown from S3 first if it's missing locally — a parent repo that migrated to S3 a while ago may have no local copy at all. - Diffs the local file against the remote object byte-for-byte (the same
comparison
make tfvars-diff/tfvars-sync.ts diffuses) and aborts, leaving every file untouched, if they've drifted — reconcile withmake tfvars-pullormake tfvars-pushfirst, then re-run the migration. Ifs3://<bucket>/terraform.tfvarsdoesn't exist at all (the bucket was created but never seeded), this comparison is skipped entirely — there's nothing remote to reconcile against, so migration proceeds straight to deleting the markers in step 4. - On a clean match (or when the remote object was never seeded), deletes
both the parent-root and submodule-local
.hyveon/tfvars-bucketmarkers and theterraform.tfvars.locksidecar.terraform.tfvarsitself is left in place — it's already the correct source of truth for local mode once the markers are gone.
migrate --to-local never deletes the S3 bucket — it only removes the
markers that make the parent repo look at it. Tear the bucket down yourself
once you're done with it:
terraform -chdir=Hyveon/terraform/bootstrap destroy
Parent-root marker takes precedence
Both migration directions, the generated Makefile's TFVARS_BACKEND/
TFVARS_BUCKET resolution, and make setup's post-bootstrap pull all agree
on the same precedence when both marker files could theoretically exist: the
parent-root marker (<parent>/.hyveon/tfvars-bucket, written by
bootstrap --s3-tfvars or migrate --to-s3 before setup ever runs)
always wins over the submodule-local marker
(<parent>/Hyveon/.hyveon/tfvars-bucket, written by setup's own S3
tfvars-bootstrap step). An explicit parent-root marker reflects a deliberate
operator choice, so it takes priority even before setup gets a chance to
write its own submodule-local marker. HYVEON_TFVARS_BACKEND=s3|local
overrides both markers entirely when set. The legacy .gsd/tfvars-bucket
path follows the same parent-root-before-submodule-local precedence at each
location, checked only after its .hyveon counterpart is confirmed absent.
Submodule update re-inits Terraform unconditionally
make update bumps the submodule to the tip of main
(git submodule update --remote --merge), then unconditionally re-runs
terraform init using the tf-project/tf-region stamps make setup
wrote (it exits early with "Run 'make setup' first." if those are missing)
— there's no version-comparison check against anything. terraform init is
cheap and idempotent (it no-ops when the backend config and providers are
already current), so there's no need to detect whether the bootstrap logic
itself changed upstream before deciding whether to rerun anything. If
upstream tweaked a Terraform provider version or added a new resource, the
next terraform init picks it up automatically; if the Makefile's own
bootstrap logic (Lambda build steps, backend bucket naming, …) changed
upstream, re-run init-parent.ts migrate --to-s3 (or re-scaffold with
--force) to pick up the new generated Makefile.
tfstate lives in S3 by default
make setup provisions an S3 bucket ({project_name}-tf-state) and a
DynamoDB lock table ({project_name}-tf-locks) on first run, then
terraform inits with the S3 backend pointing at them. The parent repo
never holds terraform.tfstate on disk — the wrapper's make dev pulls a
fresh copy into .make/tfstate.json for the management app to read.
If you don't want a remote backend (single-operator, throwaway deployment),
edit the generated Makefile's setup recipe to skip the S3 bucket/DynamoDB
table creation and run terraform init without -backend-config flags
instead. We don't recommend this for anything you care about.
Discord credentials: pick a home
There are three reasonable places for the Application ID, Bot Token, and Public Key. Trade-offs:
| Location | Pros | Cons |
|---|---|---|
| tfvars in the private parent | One source of truth; terraform apply seeds them. Rotation via terraform taint. | They're on disk wherever the parent repo is cloned. |
Environment at apply time (TF_VAR_discord_bot_token=…) | Never on disk. | Every operator needs the token in their shell to apply. |
| Dashboard only | Token only exists in Secrets Manager. | You have to paste it once per fresh environment, and the DDB CONFIG#discord row is seeded manually. |
The tfvars route is what most people pick, and it's what init-parent.ts
will offer to seed. Your parent repo is private, so it ends up covered by
the same "private-repo trust boundary" as everything else.
Rotation after the first apply (tfvars route):
terraform taint aws_secretsmanager_secret_version.discord_bot_token
# or: aws_secretsmanager_secret_version.discord_public_key
# or: aws_dynamodb_table_item.discord_config_seed
make apply
Keeping up with upstream
make update
git add Hyveon
git commit -m "chore: bump Hyveon to $(git -C Hyveon rev-parse --short HEAD)"
make plan # eyeball the diff
make apply # if it looks right
make update always reminds you about the commit step at the end. Since
update unconditionally re-runs terraform init, any new Terraform
provider version or backend config upstream shipped is picked up by the time
make plan starts.
Things that tend to need attention after a bump:
- New or renamed Terraform variables → add them to your
terraform.tfvars. Compare againstHyveon/terraform/terraform.tfvars.example. - New environment variables on the Lambdas → typically Terraform handles this automatically, but verify in the plan output.
- Changes to the four slash-command descriptors → re-click Register commands in the dashboard so Discord picks them up per guild.
CI in the parent repo (optional)
A useful GitHub Actions pattern if you want automated drift detection:
# .github/workflows/plan.yml in the parent repo
name: terraform plan
on:
pull_request:
schedule:
- cron: "0 9 * * 1" # Monday 09:00 UTC drift check
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- uses: actions/setup-node@v4
with:
node-version: 24
- run: make setup
- run: make plan
Use OIDC → an IAM role for aws-actions/configure-aws-credentials rather
than stashing long-lived keys. The role's policy is the same
HyveonDeployAll inline policy from the setup guide.
What NOT to do
- Don't fork the public repo and edit it. The submodule pattern gives
you every pinning benefit of a fork without the merge conflicts. If you
need a real code change, contribute upstream and
make updateto bump to the new commit. - Don't commit
terraform.tfvarsinside the submodule. Even a private parent doesn't save you if someone later runsgit submodule foreach git push origin HEAD. Keep your tfvars in the parent — the wrapper copies it into the submodule at apply time, and the submodule's own.gitignoreexcludesterraform/*.tfvars. - Don't run plan/apply directly in the submodule. Always go through
the parent's
make plan/make applyso the freshly-editedterraform.tfvarsis copied in first. A plan against stale vars is a plan against the wrong universe.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
make plan says "No such file or directory" pointing at Hyveon/ | Submodule wasn't initialised | make setup (or git submodule update --init --recursive). |
make apply runs against an old terraform.tfvars | You edited the parent's tfvars but ran terraform inside the submodule directly | Always run make apply from the parent — the copy-tfvars recipe forces a fresh copy. |
make update silently pulls main and breaks apply | Upstream changed something incompatible | update only re-inits Terraform, it doesn't rebuild anything else — run make plan and read the diff. Pin to a SHA in .gitmodules if you want stricter control. |
| After bumping upstream, Discord commands have wrong arguments | Descriptors in @hyveon/shared/commands.ts changed | Click Register commands for each guild in the dashboard. |
make dev complains it can't read tfstate | First-time run before make apply | Ignore — the recipe writes null and the app degrades gracefully until the first apply succeeds. |
| CI plan shows a Lambda recreating every run | Bundle hash changes between builds | Run npm ci (not npm install) in CI in the submodule to pin dependencies — the generated Makefile's setup recipe uses npm install, which is right for local dev but not deterministic in CI. |