The SkyHub platform’s AWS infrastructure lives in the skyhub_terraform repo as ~16 reusable modules under modules/, composed per-environment by a thin root module under environments/<env>/. Each environment is a self-contained Terraform working directory with its own S3 remote-state key, its own provider/backend block, and its own var-file. You never run Terraform from the repo root — you cd into an environment directory and run init/plan/apply there.
This page is the operational entry point for running Terraform. For what the resulting resources actually are, see AWS Infrastructure Overview, ECS Fargate Services, and VPC, WireGuard Jumphost & nginx Routing. For the gateway’s runtime env vars, see Gateway Environment Variables and AWS Production Configuration.

Repository layout

skyhub_terraform/
environments/
  dev/          # dev root module — backend key skyhub-dev/terraform.tfstate
    provider_config.tf   # terraform{} block: backend + providers + locals
    inputs.tf            # variable declarations (many with dev defaults)
    main.tf              # module composition (the root module)
    vpc_configuration.tf # module.vpc (172.31.0.0/16, single AZ)
    outputs.tf
    wireguard/           # keys/ + scripts/ (WireGuard bootstrap templates)
  prod/         # prod root module — backend key skyhub-prod/terraform.tfstate
    provider_config.tf
    inputs.tf
    main.tf
    settings.tfvars      # ⚠ committed, contains GitHub PAT + Slack webhook
    vpc_configuration.tf
    outputs.tf
    wireguard/
modules/        # ~16 shared modules (ecs, api, database, wireguard, janus, …)
README.md       # the init/plan/apply workflow
Both root modules point their module blocks at the same shared code via source = "../../modules/<name>" (see environments/prod/main.tf:14), so a change to a module affects every environment on its next apply. The environment directory only supplies inputs and wiring — which modules to instantiate, and what values to feed them.

The S3 remote state backend

State is stored remotely in one shared S3 bucket, keyed per environment. The backend is declared inside the terraform{} block of each environment’s provider_config.tf:
environments/prod/provider_config.tf
terraform {
  required_version = ">= 0.12.26"

  required_providers {
    aws    = "5.55.0"
    docker = { source = "kreuzwerker/docker", version = "3.0.2" }
  }

  backend "s3" {
    region = "eu-central-1"
    bucket = "skyhub-terraform-environment-states"
    key    = "skyhub-prod/terraform.tfstate"
  }
}
EnvironmentState bucketState keyRegion
devskyhub-terraform-environment-statesskyhub-dev/terraform.tfstateeu-central-1
prodskyhub-terraform-environment-statesskyhub-prod/terraform.tfstateeu-central-1
The bucket lives in AWS account <aws-account-id> (eu-central-1). Provider versions are pinned identically across environments — aws = 5.55.0 and kreuzwerker/docker = 3.0.2 — and locked in each env’s .terraform.lock.hcl.
No state locking. The backend "s3" block declares only region, bucket, and key — there is no dynamodb_table and no use_lockfile. Concurrent apply runs against the same environment can corrupt state. Coordinate manually: only one person should hold an apply at a time. Local *.tfstate files and *.tfvars.json are gitignored (skyhub_terraform/.gitignore), so state never lands in git — but settings.tfvars does (see below).

settings.tfvars and variable inputs

Each environment declares its variables in inputs.tf and supplies values through a --var-file. The prod environment ships a committed settings.tfvars:
environments/prod/settings.tfvars
client_name="skyhub"
client_description="Skyhub"
environment="prod"
environment_description="Skyhub Production Cloud"
aws_region="eu-central-1"
github_token="<redacted-github-pat>"
slack_webhook="<redacted-slack-webhook>"
environment and client_name drive the resources_tag = "${var.client_name}-${var.environment}" local (e.g. skyhub-prod), which prefixes the name of nearly every AWS resource and forms the Cloud Map DNS namespace skyhub-prod.internal. aws_region sets the provider region. github_token and slack_webhook are consumed by the CodeBuild/alarms modules.
Committed secrets caveat. environments/prod/settings.tfvars is checked into git — the repo’s root .gitignore deliberately comments out the *.tfvars ignore rule (only *.tfvars.json is ignored), and git ls-files confirms the file is tracked. It contains a live GitHub PAT and a Slack incoming-webhook URL in plaintext. These are not the only committed secrets in the repo (the dev inputs.tf also defaults jwt_secret_key, mail_password, and an OTLP signoz-access-token; modules/api/api.tf hardcodes others). Treat all of them as compromised: rotate and migrate to SSM Parameter Store / Secrets Manager. When editing docs or configs, document key names only and never paste real values.
The dev environment has no committed settings.tfvars — you must create one before running (the README shows the template). Dev’s inputs.tf is far larger than prod’s and carries dev-friendly defaults for the whole gateway config surface (jwt_secret_key, mail_*, otel_*, remote_docker_*, sitl_*, Janus/WHIP tuning), so a dev plan needs only the five client/environment/region vars in a hand-written settings.tfvars.

The init / plan / apply workflow

Run every command from inside the environment directory so Terraform picks up that env’s backend key and .tf files.
1

cd into the environment

cd skyhub_terraform/environments/prod   # or environments/dev
2

Configure AWS credentials

Terraform uses your AWS CLI credentials (account <aws-account-id>, region eu-central-1). Ensure they are exported / in your profile before init — the S3 backend and the aws_ecr_authorization_token data source both need them.
3

terraform init

Downloads the pinned providers and modules/, and configures the S3 backend for this environment.
terraform init
For dev, first create settings.tfvars (prod already has one committed):
client_name="skyhub"
client_description="Skyhub"
environment="dev"
environment_description="Skyhub Development Cloud"
aws_region="eu-central-1"
4

terraform plan

Preview the changes for this environment only.
terraform plan --var-file=settings.tfvars
5

terraform apply

Provision / update the environment.
terraform apply --var-file=settings.tfvars --auto-approve
On apply, module dependencies force the ECS cluster and Cloud Map namespace up first (depends_on = [module.our_ecs] throughout main.tf), then the services; the WireGuard jumphost’s nginx config is (re)written by a remote-exec provisioner. The frontend module’s CodeBuild ultimately syncs the Angular dist/ into the skyhub-<env>-ui-bucket S3 bucket.
To tear an environment down, the same working directory supports terraform destroy --var-file=settings.tfvars.
The --var-file=settings.tfvars flag is required on every plan/apply/destroy — the file is not auto-loaded (it is not named terraform.tfvars or *.auto.tfvars). Omitting it will prompt for client_name, aws_region, etc., or use whatever stale values you type.

Two distinct “deploy” triggers

There are two independent deploy mechanisms, and it is easy to conflate them:
  1. Infrastructure deploys are manual. terraform apply is run by hand from an environment directory. There is no pipeline that runs Terraform for you — nothing applies infrastructure on a git push.
  2. Application-image deploys are automatic, driven by GitHub webhooks on the per-service repos (gateway, dashboard, janus, whip, ws_proxy, drone). The webhook filter is environment-aware:
environments/prod/main.tf (and dev/provider_config.tf)
locals {
  codebuild_webhook_config = {
    build_type = "BUILD"
    filter_groups = [{
      event_pattern    = "PUSH"
      head_ref_pattern = var.environment == "prod" ? "^refs/tags/v_.*$" : "^refs/heads/develop$"
    }]
  }
}
EnvironmentTriggerGit ref pattern
prodPush a version tag^refs/tags/v_.*$ (e.g. v_1.4.0)
devPush to the develop branch^refs/heads/develop$
When a matching ref is pushed, the service’s CodeBuild project builds and pushes a :latest image to ECR, then forces a new ECS deployment (aws ecs update-service --force-new-deployment). This webhook config is passed into every CI/CD-bearing module (module.our_api, our_janus, our_whip, our_ws_proxy, our_frontend, our_user_vpn) via the shared webhook_config input, so all services in an environment share one trigger convention. (The our_drone module’s CodeBuild webhooks are commented out and not wired to webhook_config, so drone images are not auto-deployed by this trigger.) Full pipeline mechanics live in CI/CD: CodeBuild, ECR & Frontend Deploy.

dev and prod root modules have diverged

The two root modules are not parameter-for-parameter identical, which matters when auditing or promoting a change between them.
modules/api/inputs.tf declares ~50 variables, and roughly two dozen of them — jwt_secret_key, mail_server/port/username/password, aws_access_key_id/secret, otel_exporter_otlp_*, remote_docker_enabled/host, docker_host_ip, user_sitl_max_count, sitl_cpu/memory_limit, janus_url, whip_server_url, sitl_video_stream_drone_state, log_level, socket_ip, socketio_ping_* — have no default. The dev module "our_api" block passes all of them (environments/dev/main.tf:246-268); the prod module "our_api" block does not (environments/prod/main.tf:213-250). As captured on this branch, a prod terraform plan would therefore error demanding those required variables. The prod api.tf also still hardcodes several of these as literal task-def env values. A refactor must reconcile the two paths — see AWS Production Configuration.
  • docker_host_location: prod <office-docker-host>, dev <office-host> (the on-prem/office Docker host the gateway uses for SITL + rosbridge).
  • Frontend build_configuration: prod "production" (plus repo_branch = "development"), dev "aws-dev" — the Angular build profile the frontend CodeBuild uses.
  • module.our_lambdas is instantiated only in dev main.tf, not prod.
  • module.our_alarms is instantiated only in prod main.tf, not dev; dev’s inputs.tf/main.tf are generally more parameterized and current.
  • local.frontend_domain is "dev.skyhub.ai" in both provider_config.tf files — a leftover dev value in prod (the user-facing prod domain is prod.skyhub.ai).

Adding a new environment

1

Create the environment directory

Copy an existing env (dev is the more complete template) to environments/<new-env>/ and keep the four core files: provider_config.tf, inputs.tf, main.tf, vpc_configuration.tf (plus outputs.tf and wireguard/).
2

Set a unique backend state key

In provider_config.tf, change the backend key to skyhub-<new-env>/terraform.tfstate. Keep the same bucket = "skyhub-terraform-environment-states" and region. A duplicate key would clobber another environment’s state.
3

Write settings.tfvars

Set client_name, client_description, environment=<new-env>, environment_description, aws_region. Set environment correctly — it flips the CodeBuild trigger (prod → tags v_*, anything else → develop) and forms resources_tag.
4

Check VPC CIDR / region for collisions

vpc_configuration.tf hardcodes CIDR 172.31.0.0/16, a single AZ, and one NAT gateway. If the new environment shares an account/region and must peer, give it a non-overlapping CIDR.
5

init, plan, apply

cd in, then run the workflow above. terraform init will provision the new state key on first run.

Where things live — quick reference

ConcernLocation
Remote stateS3 skyhub-terraform-environment-states, key skyhub-<env>/terraform.tfstate
Backend + provider pinsenvironments/<env>/provider_config.tf
Committed secrets (prod)environments/prod/settings.tfvars (GitHub PAT, Slack webhook)
Committed secrets (dev defaults)environments/dev/inputs.tf (jwt_secret_key, mail_password, OTLP token)
Module wiring / compositionenvironments/<env>/main.tf
Shared module codemodules/<name>/
App-deploy trigger (prod)git tag v_* → CodeBuild → ECR → ECS
App-deploy trigger (dev)push to develop → CodeBuild → ECR → ECS
Infra deploymanual terraform apply --var-file=settings.tfvars