The entire SkyHub cloud footprint lives in one AWS account (<aws-account-id>) in one region (eu-central-1, Frankfurt), and is defined end-to-end as Terraform in the skyhub_terraform repo. There is no console-clicked infrastructure of record: every VPC, subnet, ECS service, S3 bucket, ECR repo, CodeBuild pipeline, and the single WireGuard EC2 instance is a Terraform resource. Two things make the topology unusual and worth internalising before you touch anything:
  • Almost every “managed” service is actually a self-hosted container on ECS Fargate — including PostgreSQL and Redis. There is no RDS and no ElastiCache in the account.
  • A single EC2 instance (the WireGuard “jumphost”) is the only public ingress for the UI, the API, video signaling, drone rosbridge, the remote Docker API, and OTLP telemetry. There is no ALB. That instance is both the architectural keystone and the primary single point of failure.
Account <aws-account-id> · Region eu-central-1 · Cloud Map namespace skyhub-prod.internal · State bucket skyhub-terraform-environment-states. These are infrastructure facts (not secrets) and appear throughout the deployment docs.

The Terraform repo

skyhub_terraform is a classic environments + reusable modules layout. The per-environment root modules under environments/ compose the same ~16 modules from modules/, passing environment-specific inputs. Remote state is stored per environment in one S3 bucket.
skyhub_terraform (layout)
skyhub_terraform/
├── environments/
│   ├── dev/                     # root module — state key skyhub-dev/terraform.tfstate
│   │   ├── main.tf              # instantiates the modules, wires outputs
│   │   ├── provider_config.tf   # terraform{} block: S3 backend + aws/docker providers + locals
│   │   ├── vpc_configuration.tf # terraform-aws-modules/vpc v5.15.0
│   │   └── settings.tfvars      # client/env/region (+ checked-in GitHub PAT & Slack webhook)
│   └── prod/                    # root module — state key skyhub-prod/terraform.tfstate
├── modules/                     # ~16 reusable modules (see table below)
├── nginx/default               # reference copy of the jumphost nginx routing table
└── README.md                   # init / plan / apply workflow
Both environments point their Terraform S3 backend at the same bucket with a different key (environments/prod/provider_config.tf:14):
environments/prod/provider_config.tf
backend "s3" {
  region = "eu-central-1"
  bucket = "skyhub-terraform-environment-states"
  key    = "skyhub-prod/terraform.tfstate"
}
The naming prefix for every resource is local.resources_tag = "${var.client_name}-${var.environment}" — i.e. skyhub-prod / skyhub-dev — so an ECS cluster becomes skyhub-prod-cluster, an ECR repo skyhub-prod-api-image, and so on.
The exact terraform init / plan / apply workflow, the state backend (note: no DynamoDB lock table is configured), and the dev-vs-prod deploy triggers are covered in depth on Environments, State & Terraform Workflow.

The modules

Each modules/* directory is instantiated once per environment from the root main.tf. Everything except lambda is wired into prod (environments/prod/main.tf).
ModuleCreatesRuntime / Cloud Map name
ecsShared Fargate cluster {tag}-cluster (FARGATE + FARGATE_SPOT) + the {tag}.internal Cloud Map namespace
apiGateway service (skyhub_gateway_service) + ECR repo + redeploy-loop alarmsgateway.{tag}.internal:5000
databasePostgres 16.3 container on EFS (not RDS) + AWS Backup plansdatabase.{tag}.internal:5432
our_redisRedis alpine container (ephemeral, no auth)redis.{tag}.internal:6379
janusJanus WebRTC SFU (the only x86_64 task)janus.{tag}.internal:8088/8188
whipsimple-whip-server (WebRTC ingest)whip.{tag}.internal:7080
our_ws_proxyGamepad/WS proxy (larger: cpu 512 / mem 1024)ws_proxy.{tag}.internal:7070
wireguardThe jumphost EC2 + nginx + VPN (public ingress)jumphost-public / jumphost-private.{tag}.internal
frontendS3 static-site bucket {tag}-ui-bucket + CloudFront + Angular CodeBuild
assetsPrivate S3 bucket {tag}-assets (user/mission assets)
user_vpnS3 bucket {tag}-user-vpn + VPN service image (deployed onto the jumphost)
droneDrone image CI/CD (ECR repos + docker-compose.installer.yml to s3://skyhubcore)— (drones run on-prem)
codebuildShared CI primitives: GitHub PAT source credential, permissive SG, Slack SSM param
alarmsSNS topic + Node 18 Slack-notifier Lambda
vpc_endpointsS3 Gateway VPC endpoint (offloads the single NAT gateway)
lambdaSSM params + serverless-deployments bucket — dev root only, not prod
Per-service task definitions (CPU/mem, ARM64 vs x86, ports, env wiring) live on ECS Fargate Services; the CodeBuild pipelines and ECR flow on CI/CD: CodeBuild, ECR & Frontend Deploy; buckets, EFS backups and alarms on Storage, Backups & Alerting.

Production topology

VPC — single AZ, single NAT

The VPC is deliberately minimal (environments/prod/vpc_configuration.tf): CIDR 172.31.0.0/16, one AZ, one public subnet (<vpc-subnet>/20), one private subnet (172.31.0.0/20), and a single NAT gateway. The multi-AZ and dedicated database_subnets lines are present but commented out. This is not a highly-available layout — it is optimised for cost, which is why the S3 Gateway VPC endpoint exists (to keep S3 traffic off the metered NAT gateway).

ECS Fargate cluster + Cloud Map

The ecs module creates the shared cluster and the private DNS namespace that every other service registers into (modules/ecs/ecs.tf):
modules/ecs/ecs.tf
module "ecs" {
  source                  = "terraform-aws-modules/ecs/aws"
  name                    = "${var.resources_tag}-cluster"
  version                 = "3.5.0"
  container_insights      = true
  capacity_providers      = ["FARGATE", "FARGATE_SPOT"]
  default_capacity_provider_strategy = [{ capacity_provider = "FARGATE_SPOT", weight = 100 }]
}

resource "aws_service_discovery_private_dns_namespace" "dns_service" {
  name = "${var.resources_tag}.internal"
  vpc  = var.vpc_id
}
Two consequences to keep in mind:
  • Default strategy is 100% FARGATE_SPOT. Every service runs a single task (desired_count = 1), so a Spot reclamation is visible downtime. The api module even ships CloudWatch alarms specifically to catch redeploy/restart loops.
  • Services address each other by stable Cloud Map hostnames (gateway, janus, whip, ws_proxy, redis, database.{tag}.internal). Those names are baked into the gateway’s env, the nginx config, and inter-service calls — renaming a service or the namespace breaks all three at once.

Jumphost-centric ingress

Rather than an ALB, all public traffic terminates on the WireGuard EC2 instance (a t4g.nano in the public subnet with an Elastic IP, <prod-ingress-ip>), where nginx TLS-terminates and reverse-proxies each port to the right internal Cloud Map service — for example /socket.io/ and every REST path to gateway.skyhub-prod.internal:5000 (nginx/default:22-67). The same box also runs the WireGuard VPN server and a Docker CLI, so it doubles as the bridge to the on-prem office server (<office-docker-host>) where SITL containers run.
The jumphost is a single instance in a single AZ carrying every external entrypoint (UI, API, Socket.IO, gamepad WS, Janus signaling, :2375 Docker API, :9090 rosbridge, :4317 OTLP) plus the VPN. If it dies, essentially all external access is down. Its nginx config is (re)generated at boot by a remote-exec provisioner, not baked into an immutable image. The full port map, WireGuard address plan, and TLS setup are documented on VPC, WireGuard Jumphost & nginx Routing.

Where should a new service live?

1

Backend container that other services call

Add a new modules/<service> module that creates an ECS Fargate service registered as <service>.{tag}.internal, plus its own ECR repo. Follow modules/whip or modules/our_ws_proxy as templates, then instantiate it in both environments/dev/main.tf and environments/prod/main.tf.
2

Needs to be publicly reachable

It also needs an nginx server block on the jumphost. Add the route to modules/wireguard’s post_install template (and the reference nginx/default) and open the port in the jumphost security group. There is no ALB to attach a target group to.
3

Static assets or artifacts

Add an S3 bucket module (modules/assets / modules/user_vpn are examples). Buckets are named {tag}-<purpose>.
4

Just CI/CD for an existing repo

Reuse modules/codebuild primitives (GitHub PAT source credential + Slack webhook) and add a CodeBuild project that builds Dockerfile.ecr, pushes to ECR :latest, and runs aws ecs update-service --force-new-deployment.

Dev vs prod

The two environments share every module; they differ only in inputs and triggers.
Aspectdevprod
State keyskyhub-dev/terraform.tfstateskyhub-prod/terraform.tfstate
resources_tagskyhub-devskyhub-prod
CodeBuild trigger (local.codebuild_webhook_config)push to develop (^refs/heads/develop$)version tag (^refs/tags/v_.*$)
Angular build_configurationaws-devproduction
docker_host_location (on-prem Docker host)<office-host><office-docker-host>
lambda moduleinstantiatednot instantiated
Known drift a future editor must not “fix” blindly. local.frontend_domain is set to dev.skyhub.ai in both environments’ provider_config.tf (line 69/72), even though the user-facing prod domain and the reference nginx/default use prod.skyhub.ai. Separately, secrets are committed in plaintext (GitHub PAT + Slack webhook in settings.tfvars; JWT_SECRET_KEY and the Gmail app password hardcoded in modules/api/api.tf; the WireGuard server private key in modules/wireguard/wireguard.tf), several IAM task-role policies grant Action "*" on Resource "*", and environments/prod/main.tf’s module "our_api" block does not pass many variables that modules/api/inputs.tf declares without defaults. Confirm what the live account actually runs before assuming the repo is deployable as-is.

Environments & State

Root modules, the S3 state backend, and the init/plan/apply workflow.

Networking & Jumphost

The VPC, WireGuard address plan, and the full nginx routing table.

ECS Fargate Services

Per-service task definitions, ARM64 vs x86, ports and env wiring.

CI/CD & Images

CodeBuild pipelines, ECR, and the frontend deploy.

Storage, Backups & Alarms

S3 buckets, EFS-backed Postgres backups, and the SNS/Slack alarm path.

Production Configuration

The gateway’s production env vars, SSM secrets, and config checklist.