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:
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 (DEFAULT_SERVICES in installer/sc.sh) — 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:
The two halves keep separate delay variables because they were two containers with different values. Ordering inside the merged container is supervisord’s
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:
Each program’s
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
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):The agent half (source tree
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
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
This port map is load-bearing:
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.

