<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)
environments/prod/provider_config.tf:14):
environments/prod/provider_config.tf
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
Eachmodules/* directory is instantiated once per environment from the root main.tf. Everything except lambda is wired into prod (environments/prod/main.tf).
| Module | Creates | Runtime / Cloud Map name |
|---|---|---|
ecs | Shared Fargate cluster {tag}-cluster (FARGATE + FARGATE_SPOT) + the {tag}.internal Cloud Map namespace | — |
api | Gateway service (skyhub_gateway_service) + ECR repo + redeploy-loop alarms | gateway.{tag}.internal:5000 |
database | Postgres 16.3 container on EFS (not RDS) + AWS Backup plans | database.{tag}.internal:5432 |
our_redis | Redis alpine container (ephemeral, no auth) | redis.{tag}.internal:6379 |
janus | Janus WebRTC SFU (the only x86_64 task) | janus.{tag}.internal:8088/8188 |
whip | simple-whip-server (WebRTC ingest) | whip.{tag}.internal:7080 |
our_ws_proxy | Gamepad/WS proxy (larger: cpu 512 / mem 1024) | ws_proxy.{tag}.internal:7070 |
wireguard | The jumphost EC2 + nginx + VPN (public ingress) | jumphost-public / jumphost-private.{tag}.internal |
frontend | S3 static-site bucket {tag}-ui-bucket + CloudFront + Angular CodeBuild | — |
assets | Private S3 bucket {tag}-assets (user/mission assets) | — |
user_vpn | S3 bucket {tag}-user-vpn + VPN service image (deployed onto the jumphost) | — |
drone | Drone image CI/CD (ECR repos + docker-compose.installer.yml to s3://skyhubcore) | — (drones run on-prem) |
codebuild | Shared CI primitives: GitHub PAT source credential, permissive SG, Slack SSM param | — |
alarms | SNS topic + Node 18 Slack-notifier Lambda | — |
vpc_endpoints | S3 Gateway VPC endpoint (offloads the single NAT gateway) | — |
lambda | SSM 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
Theecs module creates the shared cluster and the private DNS namespace that every other service registers into (modules/ecs/ecs.tf):
modules/ecs/ecs.tf
- Default strategy is 100%
FARGATE_SPOT. Every service runs a single task (desired_count = 1), so a Spot reclamation is visible downtime. Theapimodule 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 (at4g.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.
Where should a new service live?
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.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.Static assets or artifacts
Add an S3 bucket module (
modules/assets / modules/user_vpn are examples). Buckets are named {tag}-<purpose>.Dev vs prod
The two environments share every module; they differ only in inputs and triggers.| Aspect | dev | prod |
|---|---|---|
| State key | skyhub-dev/terraform.tfstate | skyhub-prod/terraform.tfstate |
resources_tag | skyhub-dev | skyhub-prod |
CodeBuild trigger (local.codebuild_webhook_config) | push to develop (^refs/heads/develop$) | version tag (^refs/tags/v_.*$) |
Angular build_configuration | aws-dev | production |
docker_host_location (on-prem Docker host) | <office-host> | <office-docker-host> |
lambda module | instantiated | not instantiated |
Related pages
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.

