network_mode: host, and restarts on failure. There is no application-level orchestrator: docker-compose.yml plus COMPOSE_PROFILES decides which containers exist, depends_on and per-service *_STARTDELAY decides roughly when they start, and inside the skyhub container supervisord is the real init system.
This page is the per-service reference: what each service is, which profile enables it, its network/device wiring, the environment variables that matter, and how the boot order is (loosely) sequenced. Deep behavior lives in the sibling pages — Module Systems, MAVLink Routing (mavp2p), Redis Message Bus & WebSocket Interfaces, Guided Velocity Control & Safety Model, Video Streaming, e-ArUco Precision Landing, and RTK NTRIP GPS Corrections. For the top-level picture start at the SkyCore Drone OS Overview.
The three services at a glance
The production topology is defined indocker-compose.yml (project root). Three services, each with exactly one profile:
| Service (compose name) | Profile | Image | Role | network_mode |
|---|---|---|---|---|
skyhub | skyhub | skyhub-app:latest | The whole application: the ROS half (MAVROS + rosbridge :9090 + drone_node modules) and the agent half (manual control, asset upload) in one process tree under supervisord | host |
mavproxy | mavproxy | ECR skyhub-prod-drone-mavp2p-image (built from docker/mavp2p/Dockerfile) | mavp2p — the single MAVLink router between the FC and every consumer | host |
rtk-ntrip | rtk | skyhub-rtk-ntrip:latest, built from ./docker/rtk-ntrip | NTRIP client: injects RTCM corrections into the FC | host |
skyhub is one image with three shapes, selected by SKYHUB_ROLE:vehicle— both halves in one container. This is what an aircraft runs.core— the ROS half only.agent— the agent half only.
skyhub service. The SITL Fargate task still runs two containers from the same image (SKYHUB_ROLE=core and SKYHUB_ROLE=agent), deliberately: the agent container there is essential=false with its own CPU cap, so an ffmpeg OOM in the upload path cannot take MAVROS and rosbridge down mid-flight.COMPOSE_PROFILES in .env (catalog: .env.example:7). The shipped default is:
.env
skycore activate defaults to a narrower list — skyhub,mavproxy (installer/sc.sh:961) — because a provisioned vehicle without RTK credentials has nothing for rtk-ntrip to do. All three services are privileged: true in production; only skyhub pins cpus: 4.
network_mode: host is load-bearing, not incidental. It is why ROS2 DDS discovery works across containers (shared ROS_DOMAIN_ID), why every service reaches mavp2p on 127.0.0.1:145xx, and why ports 9090, 5001, and 322 land directly on the host. All three of those ports are per-vehicle configurable — ROSBRIDGE_PORT, GAMEPAD_WS_PORT, SKYHUB_SSH_PORT — precisely because containers can share a host network namespace and the second one to bind a fixed port fails. A refactor to bridged networking would break DDS discovery and the loopback MAVLink assumptions simultaneously. See the gotchas below.Compose topology
Startup ordering
Ordering is delay-based, not health-gated in production. Two mechanisms combine:depends_on— coarse “start after” edges (no health condition on the prod compose):
skyhub and rtk-ntrip both depends_on mavproxy, which has no dependencies of its own and comes up first.
*_STARTDELAY— each service sleeps N seconds at boot before launching its process:
| Service | Env var | Default | Where it sleeps |
|---|---|---|---|
mavproxy | — | none | starts immediately (root of the chain) |
skyhub (ROS half) | STARTDELAY, from DRONE_STARTDELAY | 5 | docker/core/entrypoint.sh:4 (MAVROS launch) |
skyhub (agent half) | AGENT_STARTDELAY, from WS_PROXY_STARTDELAY | 5 | docker/gamepad/startup.sh |
rtk-ntrip | RTK_STARTDELAY | 10 | docker/rtk-ntrip/entrypoint.sh:5 |
priority; AGENT_STARTDELAY is only the extra wait on top.
Inside skyhub: supervisord
The container runs multiple processes under supervisord (docker/skyhub/supervisord.conf), which is its PID-1-like init. Entry is docker/skyhub/entrypoint.sh → /ros_entrypoint.sh → supervisord. Programs start in ascending priority:
| Program | Priority | Command | Notes |
|---|---|---|---|
sshd | 50 | /usr/sbin/sshd -D | shell access; binds SKYHUB_SSH_PORT (default 322) |
mavros | 100 | docker/core/entrypoint.sh | ros2 launch mavros apm.launch fcu_url:=$FCUURL |
rosbridge | 200 | docker/core/rosbridge_entrypoint.sh | rosbridge_websocket on ROSBRIDGE_PORT (default 9090) |
stream_rates | 300 | docker/core/set_stream_rates.sh | one-shot (autorestart=false); raises MAVLink stream rate to 10 Hz |
agent | 400 | /app/startup.sh | the agent half; startsecs=10, startretries=3 |
drone_node | 999 | python3 /main.py | last; loads modules, subscribes /mavros/state |
autostart is %(ENV_START_*)s, resolved from SKYHUB_ROLE by entrypoint.sh: vehicle starts everything, core starts the ROS half plus sshd, agent starts the agent alone. A role is a set of autostart flags, not a different config file.
The agent’s
startsecs=10 is deliberate. Below it, a process that exits immediately is restarted forever while supervisorctl keeps reporting RUNNING; above it, the same loop reaches FATAL and says so. The container also exposes a supervisord control socket, so supervisorctl status works inside it — a program that never started otherwise leaves no evidence at all.set_stream_rates.sh is what makes telemetry actually flow: it waits for /mavros/state to publish connected: true (up to TIMEOUT=120 s), waits 5 s for MAVROS plugins, then calls /mavros/set_stream_rate {stream_id: 0, message_rate: 10, on_off: true} (set_stream_rates.sh:52). Without it, sensor topics stay empty and the UI shows a connected-but-silent drone.
Per-service reference
skyhub — the application: ROS half + agent half
skyhub — the application: ROS half + agent half
Profile
The agent half (source tree
skyhub · Image skyhub-app:latest (~2.4 GB, replacing two larger images) · privileged: true, cpus: 4, network_mode: host, restart: always · depends_on mavproxy.The ROS half runs MAVROS, rosbridge on port 9090 (the endpoint the SkyHub Gateway Service connects to over WebSocket — see Rosbridge Connection), and drone_node (docker/core/main.py), which loads env-gated modules via ModuleLoader (docker/core/src/core/module_loader.py). Module enable flags and defaults (module_loader.py:16):| Module | Env flag | Default |
|---|---|---|
video_stream | VIDEO_STREAM_ENABLED | on |
aruco_landing | ARUCO_LANDING_ENABLED | off |
battery | BATTERY_INDICATOR_ENABLED | on |
docker/gamepad/, mounted at /app) hosts the /gamepad WebSocket on GAMEPAD_WS_PORT (default 5001, docker/gamepad/main.py:285) for low-latency manual control, plus the arm/disarm asset pipeline (HLS video, photos, ArduPilot .bin logs uploaded to the backend). Its MessageRouter serves the identical command set whether a command arrives over the WebSocket or the Redis {ip}:gamepad_input channel — see Redis Message Bus and the safety model in Guided Velocity Control.Channel namespacing: at boot the agent resolves the drone’s WireGuard wg0 IP — or IP_OVERRIDE if set — and keys all per-drone Redis channels off it (main.py:86). If neither is available it raises RuntimeError and exits.Key env vars: SKYHUB_ROLE (vehicle), FCUURL (udp://127.0.0.1:14550@ → mavp2p), MAVLINK (udpout:127.0.0.1:14777), SKYHUB_SERVER_URL (WHIP ingest), ROS_DOMAIN_ID, VIDEO_STREAM_DRONE_STATE (ARMED|CONNECTED), FORCE_START/FAST_INIT/SKIP_CHECKS, CAMERA_TYPE, REDIS_HOST, API_URL (presigned asset uploads), VIDEO_UPLOAD_TRIGGER/VIDEO_DOWNLOAD_TRIGGER, BOOT_CHARGING_ENABLED, the GUIDED_* velocity limits, the GIMBAL_*/MNT1_*/ZOOM_* blocks, plus the EARUCO_*/ARUCO_* and BATT_SOC1_* blocks.Devices: the ReSpeaker microphone on ALSA Card 1 (/dev/snd/controlC1, /dev/snd/pcmC1D0c, /dev/snd/timer) for the ROS half, and /dev/gpiochip1 (Jetson charging relay) for the agent half, with group_add: ["29", "999"] (audio, gpio).Volumes: /dev, /.janus_room_details (Janus room details for the video module when FAST_INIT), ${LOG_DIR}/skyhub, and /app/videos + /app/logs — kept at those paths so a vehicle’s stored recordings and flight logs are still found where the pre-merge agent left them.Module internals live in Module Systems; the video path is in Video Streaming; ArUco landing in e-ArUco Precision Landing.mavproxy — the mavp2p MAVLink router
mavproxy — the mavp2p MAVLink router
Profile
This port map is load-bearing:
mavproxy · Image ECR skyhub-prod-drone-mavp2p-image · privileged: true, network_mode: host, restart: always · no depends_on (it is the root).mavp2p is the single MAVLink hub. It has no environment config at all — every endpoint is a positional CLI arg in docker-compose.yml:184:docker-compose.yml
| Endpoint | Direction | Consumer |
|---|---|---|
serial:/dev/ttyACM0:115200 | ↔ | ArduPilot flight controller |
udpc:127.0.0.1:14550 | → | skyhub MAVROS (FCUURL) |
udps:0.0.0.0:14777 | ← | skyhub agent half (MAVLINK) |
udps:0.0.0.0:14560 | ← | rtk-ntrip (RTK_MAVLINK_CONNECTION) |
udps:0.0.0.0:14561 | ← | skyhub ArUco direct velocity control |
udps:0.0.0.0:14900 | ↔ | external / GCS (e.g. QGroundControl over VPN) |
FCUURL and MAVLINK must match these numbers or the link silently dies. Full detail is in MAVLink Routing (mavp2p).The image is built from docker/mavp2p/Dockerfile via buildspec/mavp2p.yml. It had run on every vehicle since the beginning with no Dockerfile anywhere — built by hand and pushed. It is now FROM scratch holding one static Go binary (mavp2p v1.2.0, pulled by version and checksum-verified): 7.2 MB. There is no shell in it; debug with docker logs, or run the same arguments under alpine locally.rtk-ntrip — RTK GPS corrections
rtk-ntrip — RTK GPS corrections
Profile
rtk · Image skyhub-rtk-ntrip:latest (208 MB), built from ./docker/rtk-ntrip relative to the compose file · privileged: true, network_mode: host, restart: always · depends_on mavproxy. Entry via docker/rtk-ntrip/entrypoint.sh (honors RTK_STARTDELAY=10, then python3 /app/main.py).Connects to an NTRIP caster, sends periodic GGA (vehicle position from MAVLink), receives RTCM3 corrections, and injects GPS_RTCM_DATA into the FC via mavp2p udpout:127.0.0.1:14560. RTKNTRIPService (docker/rtk-ntrip/main.py) wires a MAVLinkModule + NTRIPModule.Key env vars: NTRIP_HOST (eu.l1l5.skylark.swiftnav.com), NTRIP_PORT (2101), NTRIP_MOUNTPOINT (RTK-MSM5), NTRIP_USERNAME, NTRIP_PASSWORD, RTK_MAVLINK_CONNECTION (udpout:127.0.0.1:14560), GGA_INTERVAL (10).Enabling and disabling services
Toggling a service is purely aCOMPOSE_PROFILES edit — no code change. There are exactly three profiles, and rtk is the only one a stock vehicle might reasonably run without:
.env
docker compose up -d brings up only the enabled containers.
Two other Compose files exist and diverge from the main one — do not treat them as the same topology:
docker-compose.local.yml(SITL/dev overlay): adds asitl-vpnbridge net (10.223.0.0/16), a localredis:7-alpine, and askyhub-sitlArduPilot container; parksmavproxyandrtk-ntriponprofiles: ["disabled"]; dropsprivileged; and injectsIP_OVERRIDE=10.223.1.1/REDIS_HOST=10.223.0.2. It is also the only compose that health-gates startup (condition: service_healthy). See Local Development with SITL.docker/docker-compose.installer.yml: a templated ({{ECR_*}}placeholders) topology that a provisioned vehicle downloads as itsdocker-compose.yml. Two services:skyhub(SKYHUB_ROLE=vehicle,{{ECR_APP_IMAGE}}) andmavproxy({{ECR_MAVP2P_IMAGE}}). Its mavp2p runs on/dev/ttyUSB1:230400and defines only14900/14550/14777(nortk/arucoports), and setsSTARTDELAY=0.

