SkyHub’s production network is three overlapping IP domains stitched together by a single EC2 host. Nothing about the platform makes sense until you can tell those three domains apart and know which one a given address lives in:

AWS VPC

172.31.0.0/16 — the Fargate cluster and its Cloud Map services in eu-central-1. Private, single-AZ.

Infra / on-prem

10.69.0.0/16 — the WireGuard “core” plane: the jumphost, the on-prem office server (nexus0 at <office-docker-host>) and SigNoz.

Users & drones

10.70.0.0/16 (dashboard users) and 10.71.0.0/16 (drones) — per-entity WireGuard tunnels handed out by the User VPN service.
All four networks (VPC + three WireGuard planes) terminate on one t4g.nano EC2 instance — the WireGuard/jumphost/nginx host at public EIP <prod-ingress-ip> (prod.skyhub.ai). It is simultaneously the VPN server, the sole public TLS ingress, the Docker-API proxy, and the rosbridge bridge. See the single-point-of-failure warning at the bottom of this page.

The full picture

The AWS-side private IPs above are the real Cloud Map A-records captured from prod; they change on task replacement, so always resolve by DNS name, never by hard-coded IP.

WireGuard address plan

The per-user/per-drone tunnels are managed by the User VPN service (a container running on the jumphost). It runs three independent WireGuard planes on one host, each a separate interface, UDP port and /16. Ground truth is skyhub_user_vpn/docker-compose.yml and skyhub_user_vpn/libs/config/settings.py:
PlaneCIDRInterfaceUDP portWho lives here
Core / infra10.69.0.0/16wg051822Jumphost, nexus0 office server (<office-docker-host>), SigNoz
Users10.70.0.0/16users051823Dashboard operators (one address per user)
Drones10.71.0.0/16drones051824Physical drones (one address per drone)
The base WireGuard server on the jumphost (provisioned in Terraform, modules/wireguard/wireguard.tf) listens on 51820/udp and owns 10.69.0.1/16; a static peer list defines the team members and “spare” keys on the 10.69 plane. The drones0/users0 planes on 51823/51824 are the ones that grow as you provision fleet.
The office Docker host nexus0 is addressed as <office-docker-host>, but in the Terraform peer list that address is literally labelled spare-key-1 — a real host reuses a “spare” slot. Keep this in mind when auditing peers (modules/wireguard/wireguard.tf:478).

Per-user isolation

Membership in the 10.71 plane does not grant a drone universal reachability. The User VPN service reads the shared Postgres user_drone_access table and writes iptables rules so a user’s 10.70.x address can only reach the 10.71.x drones they actually own (skyhub_user_vpn/sql_4_iptables.sql). Peer configs (access.conf) are generated per entity and distributed via the S3 bucket skyhub-prod-user-vpn. The Gateway never provisions tunnels itself — it only presigns those configs and queries tunnel status. See User VPN & Network Isolation for the full model.

The jumphost: single public ingress

Every externally reachable endpoint is an nginx server {} block on the jumphost that TLS-terminates and reverse-proxies into the VPC or over WireGuard. The routing table below is skyhub_terraform/nginx/default (also generated at boot from post_install_1.tftpl):
ListenTLSProxies toPurpose
80 / 443yesskyhub-prod-ui-bucket S3 websiteDashboard SPA
5000 /socket.io/yesgateway.skyhub-prod.internal:5000Socket.IO telemetry (WebSocket upgrade)
5000 /yesgateway.skyhub-prod.internal:5000REST API (auth, drones, missions, billing…)
7070yesws_proxy.skyhub-prod.internal:7070Gamepad / manual-control WebSocket
8188yesjanus.skyhub-prod.internal:8188Janus WebRTC signalling
2375no<office-docker-host>:2375Dev-only Docker Engine API proxy (server_name dev.skyhub.ai) — not the prod SITL path (prod SITL uses direct SSH to nexus0)
9090nohttp://$http_x_drone_ip:$http_x_drone_portDynamic rosbridge proxy (header-routed)
4317no<office-docker-host>:4318OTLP traces/logs to on-prem SigNoz
nginx resolves the internal Cloud Map names through the AWS VPC resolver at 172.31.0.2 (resolver 172.31.0.2 valid=10s). UDP media for WebRTC is relayed separately; the WireGuard SG opens udp 10000-21000, within which Janus uses the RTP range 10000-10099 (rtp_port_range, skyhub_janus/templates/janus.jcfg:178). That template sets nat_1_1_mapping = "localhost" with keep_private_host = "true" (lines 218-219), so Janus does not rewrite ICE candidates to the public EIP.
The :9090 block proxies to whatever host and port the client sends in the x-drone-ip / x-drone-port request headers, with no validation — an SSRF-style open proxy. It is safe only because it sits behind the VPN/security group. Never expose port 9090 publicly, and preserve this constraint through any ingress refactor.

Two paths to the same on-prem host

nexus0 (<office-docker-host>) is reached two different ways, and a refactor must keep them separate:
1

Docker API — container management

The Gateway manages SITL containers via REMOTE_DOCKER_HOST=ssh://nexus0@<office-docker-host> — a direct SSH-over-WireGuard connection to the office server nexus0 (<office-docker-host>), not a tcp Docker proxy through nginx. src/service/sitl_drone_service.py keys off the ssh:// prefix and drives Docker via the system SSH client. Used only for create/start/stop/destroy. Covered in SITL Drone Lifecycle.
2

rosbridge — telemetry & commands

Drone control/telemetry rides rosbridge WebSockets. In prod JUMPHOST_IP=jumphost-private.skyhub-prod.internal and JUMPHOST_PORT=9090, so every connection targets the jumphost :9090 block and carries x-drone-ip/x-drone-port headers (the SITL host <office-docker-host>:9090+n, or a physical drone’s 10.71.x:9090). See Rosbridge Connection.

Cloud Map internal DNS

Inside the VPC, every backend service is a Fargate task registered in the AWS Cloud Map private DNS namespace skyhub-prod.internal. Services address each other by these stable names — renaming a service breaks nginx, the gateway env, and inter-service calls at once.
Cloud Map namePrivate IPService
gateway.skyhub-prod.internal<vpc-host-ip>Flask API + Socket.IO (:5000)
database.skyhub-prod.internal172.31.12.178Postgres 16.3 on EFS (:5432) — not RDS
redis.skyhub-prod.internal172.31.0.207Redis alpine (:6379) — not ElastiCache
janus.skyhub-prod.internal172.31.10.110Janus WebRTC SFU (:8088/:8188)
whip.skyhub-prod.internal172.31.4.1WHIP ingest (:7080)
ws_proxy.skyhub-prod.internal172.31.2.246Gamepad WS proxy (:7070)
jumphost-public.skyhub-prod.internal<jumphost-private-ip>Jumphost public ENI (EIP <prod-ingress-ip>)
jumphost-private.skyhub-prod.internal<jumphost-private-ip>Jumphost private ENI (Docker/VPN/OTLP target)
The VPC itself is 172.31.0.0/16, a single AZ with one public subnet (<vpc-subnet>/20), one private subnet (172.31.0.0/20) and a single NAT gateway. Details in AWS Infrastructure Overview and VPC, WireGuard Jumphost & nginx Routing.

How the Gateway allocates IPs

When a user or drone is provisioned, IPService (src/service/ip_service.py) hands out the next free host address inside the relevant WireGuard /16. The CIDRs come straight from env (src/application/settings.py:75-77):
src/application/settings.py
USER_NETWORK_CIDR = os.getenv("USER_NETWORK_CIDR", "10.70.0.0/16")
DRONE_NETWORK_CIDR = os.getenv("DRONE_NETWORK_CIDR", "10.71.0.0/16")
SITL_HOST = os.getenv("DOCKER_HOST_IP", "<office-docker-host>")
get_user_ip() and get_drone_ip() (src/service/ip_service.py:22-63) each:
  1. Query all currently taken IPs from Postgres, ordered by INET cast so allocation is deterministic.
  2. Reserve the gateway (.1) and broadcast (.255) addresses of the first subnet only (10.70.0.1/10.70.0.255, 10.71.0.1/10.71.0.255).
  3. Return the first network.hosts() address that is neither taken nor reserved — so the first user lands on 10.70.0.2 and the first drone on 10.71.0.2.
SITL drones are a special case: their DB ip is the container name (e.g. SKYHUB_SITL_3), not a real address, so they consume no drone-plane IP. For ordering purposes only, the query casts SITL rows as if they were at SITL_HOST (<office-docker-host>) via a SQL CASE (src/service/ip_service.py:51).
The .1/.255 reservation only covers the first /16 sub-block, not every /24. That is intentional today (WireGuard is flat, not subnetted), but if you ever carve the drone plane into /24s per site, revisit this reservation logic or you will hand out .1/.255 addresses that some routers treat specially.
IPService.is_ip_in_drone_cidr() / is_ip_in_user_cidr() also back membership checks used elsewhere in the Gateway (e.g. distinguishing a real VPN drone from a SITL row).

How the Gateway trusts a drone’s address

Physical drones authenticate to the Gateway purely by their WireGuard source IP — there is no per-drone token. The check_vpn_ip decorator (src/middleware/drone_vpn.py) accepts a request if remote_addr, X-Real-IP or X-Forwarded-For starts with 10.71., then sets request.vpn_ip and lets the route resolve the drone via get_drone_by_ip():
src/middleware/drone_vpn.py
if client_ip.startswith("10.71."):
    vpn_ip = client_ip
if not vpn_ip and x_real_ip.startswith("10.71."):
    vpn_ip = x_real_ip
if not vpn_ip and x_forwarded_for.startswith("10.71."):
    vpn_ip = x_forwarded_for
SITL/local callbacks fall back to an X-Drone-IP header, allowed only for SKYHUB_SITL_* container names (when ENABLE_SITL) or a whitelist of local prefixes (127., 172.17-20., 192.168., 10.223.) when IS_LOCAL_ENVIRONMENT. Anything else returns 400 Access denied: IP not authenticated!. This is the 10.71 drone plane’s whole security value: it is trust-by-network-membership, so it is only sound while the Gateway is unreachable except through the VPN/jumphost. The auth model and its spoofing caveat are covered in Authentication & Security Model and VPN IP Authentication & Jumphost Routing.

Why the whole design hinges on the jumphost

Single point of failure. One t4g.nano EC2 instance is the WireGuard VPN server, the only public TLS ingress (UI, REST, Socket.IO, gamepad, Janus), the Docker-API proxy, the rosbridge bridge, and the OTLP relay — all at once. If it dies, essentially all external access and all drone connectivity drop. The VPC is single-AZ, the NAT gateway is single, and every ECS service runs desired_count=1 on Fargate Spot. Treat this instance as the platform’s keystone in any incident triage.

Gotchas to preserve

  • Resolve by name, not IP. The 172.31.x addresses in this page are current Cloud Map records; they move on task replacement. Only the jumphost EIP (<prod-ingress-ip>) and the on-prem <office-docker-host> are stable.
  • Two “office server” endpoints, one host. Docker API via direct SSH (ssh://nexus0@<office-docker-host>) vs rosbridge via nginx :9090 both land on <office-docker-host> for different jobs. Keep them split.
  • :9090 is an unvalidated header proxy — it must stay behind the VPN (see the SSRF warning above).
  • Env drift: SITL_HOST is derived from DOCKER_HOST_IP; changing the office server address moves both the rosbridge target and the SITL IP-ordering anchor. USER_NETWORK_CIDR/DRONE_NETWORK_CIDR must match the User VPN service’s USER_VPN_RANGE/DRONE_VPN_RANGE or allocation and 10.71. trust silently diverge.

Platform Architecture Overview

Where each component sits and how the control plane fits together.

Real-time Transport Channels

Socket.IO, rosbridge, WebRTC and Redis pub/sub — the wires that ride this topology.

VPC, WireGuard Jumphost & nginx Routing

The Terraform-level view: subnets, NAT, security groups and the generated nginx config.

Gateway Environment Variables

Every JUMPHOST_*, *_NETWORK_CIDR, DOCKER_HOST_IP and VPN knob with defaults.