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 core 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 — Core & Gamepad Module Systems, MAVLink Routing (mavp2p), Redis Message Bus & WebSocket Interfaces, Guided Velocity Control & Safety Model, Video Streaming, Isaac Visual SLAM, YOLO/ArUco Landing, and RTK NTRIP GPS Corrections. For the top-level picture start at the SkyCore Drone OS Overview.
The six services at a glance
The production topology is defined indocker-compose.yml (project root). Six services, each with exactly one profile:
| Service (compose name) | Profile | Image | Role | network_mode |
|---|---|---|---|---|
core | core | core:latest (ECR skyhub-prod-drone-ros2-image) | ROS2: MAVROS + rosbridge :9090 + modules (video, ArUco, battery) | host |
mavproxy | mavproxy | ECR skyhub-prod-drone-mavp2p-image | mavp2p — the single MAVLink router between the FC and every consumer | host |
ws_proxy | gamepad | gamepad:latest (ECR skyhub-prod-drone-ws-image) | FastAPI WebSocket :5001 manual control + gimbal/zoom + asset upload | host |
rtk-ntrip | rtk | built from docker/rtk-ntrip/Dockerfile | NTRIP client: injects RTCM corrections into the FC | host |
camera-proxy | camera | built from docker/core/Dockerfile.proxy | Re-serves the SIYI RTSP camera as a local RTSP :8554 endpoint | host |
isaac-slam | slam | skyhub-isaac_ros_deploy:latest | GPU: Isaac ROS Visual SLAM + nvblox + docking + TTS audio | host |
COMPOSE_PROFILES in .env (catalog: .env.example:10). The shipped default is:
.env
camera and slam are opt-in — a stock drone runs four containers. All six are privileged: true in production; core, camera-proxy, and isaac-slam also pin 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 8554 land directly on the host. 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):
core, rtk-ntrip, and isaac-slam all depends_on mavproxy; isaac-slam additionally depends_on core and ws_proxy. mavproxy, ws_proxy, and camera-proxy have no depends_on — they come up first/immediately.
*_STARTDELAY— each service sleeps N seconds at boot before launching its process. These are read from the environment (.env.example:16-21):
| Service | Env var | Default | Where it sleeps |
|---|---|---|---|
mavproxy | — | none | starts immediately (root of the chain) |
core | DRONE_STARTDELAY | 5 | docker/core/entrypoint.sh:4 (MAVROS launch) |
camera-proxy | CAMERA_PROXY_STARTDELAY | 5 | container entry |
ws_proxy | WS_PROXY_STARTDELAY | 5 | docker/gamepad/startup.sh:6 |
rtk-ntrip | RTK_STARTDELAY | 10 | docker/rtk-ntrip/entrypoint.sh:5 |
isaac-slam | ISAAC_STARTDELAY | 5 | container entry |
Inside core: supervisord
core runs multiple processes under supervisord (docker/core/supervisord.conf), which is the container’s PID-1-like init (CMD ["supervisord", ...], docker/core/Dockerfile:155). Programs start in ascending priority:
| Program | Priority | Command | Notes |
|---|---|---|---|
sshd | 50 | /usr/sbin/sshd -D | shell access into the container |
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 |
drone_node | 999 | python3 /main.py | last; loads modules, subscribes /mavros/state |
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
core — ROS2, MAVROS, rosbridge, on-drone modules
core — ROS2, MAVROS, rosbridge, on-drone modules
Profile
Key env vars (
core · Image core:latest · privileged: true, cpus: 4, network_mode: host, restart: always · depends_on mavproxy.The heart of the drone. Runs supervisord (above) to bring up 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-compose.yml core block): FCUURL (udp://127.0.0.1:14550@ → mavp2p), SKYHUB_SERVER_URL (WHIP ingest, http://whip.skyhub-prod.internal:7080), ROS_DOMAIN_ID (1), VIDEO_STREAM_DRONE_STATE (ARMED|CONNECTED), FORCE_START/FAST_INIT/SKIP_CHECKS, CAMERA_TYPE, REDIS_HOST, plus the EARUCO_*/ARUCO_* and BATT_SOC1_* blocks consumed by the ArUco and battery modules.Devices/volumes: ReSpeaker microphone only — ALSA Card 1 (/dev/snd/controlC1, /dev/snd/pcmC1D0c, /dev/snd/timer) with group_add: "29" (audio group). Mounts /dev, /.janus_room_details (Janus room details for the video module when FAST_INIT), and ${LOG_DIR}/core.Module internals live in Core & Gamepad Module Systems; the video path is in Video Streaming; ArUco landing in Detection & 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:139:docker-compose.yml
| Endpoint | Direction | Consumer |
|---|---|---|
serial:/dev/ttyACM0:115200 | ↔ | ArduPilot flight controller |
udpc:127.0.0.1:14550 | → | core MAVROS (FCUURL) |
udps:0.0.0.0:14777 | ← | ws_proxy gamepad (MAVLINK) |
udps:0.0.0.0:14560 | ← | rtk-ntrip (RTK_MAVLINK_CONNECTION) |
udps:0.0.0.0:14561 | ← | core ArUco direct velocity control |
udps:0.0.0.0:14900 | ↔ | external / GCS (e.g. QGroundControl over VPN) |
core’s FCUURL and gamepad’s MAVLINK must match these numbers or the link silently dies. Full detail — including why isaac-slam does not use a MAVLink UDP endpoint — is in MAVLink Routing (mavp2p).ws_proxy (gamepad) — manual control + asset upload
ws_proxy (gamepad) — manual control + asset upload
Profile
gamepad · Image gamepad:latest · privileged: true, network_mode: host, restart: always. Started via docker/gamepad/startup.sh (honors WS_PROXY_STARTDELAY, then exec python main.py).A FastAPI service hosting the /gamepad WebSocket on 0.0.0.0:5001 (docker/gamepad/main.py:180,209) 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 it 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 service 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 (main.py:88).Key env vars: MAVLINK (udpout:127.0.0.1:14777), REDIS_HOST, API_URL (https://prod.skyhub.ai:5000, presigned asset uploads), VIDEO_UPLOAD_TRIGGER (ALWAYS), VIDEO_DOWNLOAD_TRIGGER (DISARMED), LOG_ERASE_AFTER_DOWNLOAD, BOOT_CHARGING_ENABLED, the GUIDED_* velocity limits, the GIMBAL_*/MNT1_* mount limits, and the ZOOM_* triggers.Devices/volumes: /dev/gpiochip1 with group_add: "999" (gpio, for the Jetson charging relay on pin 7); mounts /dev, ${VIDEO_HOST_DIR} → /app/videos, and ${LOG_DIR}/gamepad.rtk-ntrip — RTK GPS corrections
rtk-ntrip — RTK GPS corrections
Profile
rtk · built from docker/rtk-ntrip/Dockerfile · 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).camera-proxy — local RTSP re-serve (opt-in)
camera-proxy — local RTSP re-serve (opt-in)
Profile
camera (not in the default profile set) · built from docker/core/Dockerfile.proxy · privileged: true, cpus: 4, network_mode: host, restart: always · no depends_on.Re-serves the SIYI gimbal camera’s RTSP feed (rtsp://${CAMERA_IP}:${CAMERA_PORT}/${CAMERA_PATH}, default <camera-ip>:8554/main.264) as a local RTSP endpoint so multiple consumers can share it without hammering the camera. docker/core/rtsp_server.py exposes two mounts on SERVER_PORT (8554): rtsp://0.0.0.0:8554/stream (high quality, for storage) and rtsp://0.0.0.0:8554/fast_stream (low latency).Key env vars: CAMERA_IP, CAMERA_PORT, CAMERA_PATH, SERVER_PORT (8554), SERVER_PATH (stream). Disabled in the SITL overlay (TEST camera is used instead).isaac-slam — GPU Visual SLAM, nvblox, docking, audio (opt-in)
isaac-slam — GPU Visual SLAM, nvblox, docking, audio (opt-in)
Profile
slam (not in the default profile set) · Image skyhub-isaac_ros_deploy:latest · privileged: true, cpus: 4, runtime: nvidia (reserves 1 GPU), network_mode: host, restart: always, custom dns · depends_on mavproxy, core, ws_proxy.The Compose command sources ROS Humble, verifies import mavros_msgs succeeds, then runs python3 /home/main.py (MainController + a DI ServiceContainer). It launches Isaac ROS Visual SLAM + nvblox as ros2 launch subprocesses, bridges VIO pose to the FC for GPS-denied nav, monitors /mavros/state over DDS (no MAVLink UDP endpoint), drives vision docking, and plays TTS announcements. Architecture: Isaac Visual SLAM & Pose Bridge.Key env vars: NVIDIA_VISIBLE_DEVICES/NVIDIA_DRIVER_CAPABILITIES (all), SLAM_ENABLED/SLAM_AUTO_START/SLAM_DEFAULT_MODE, NVBLOX_MODE (static)/MAPPING_ENABLED/OBSTACLE_AVOIDANCE, FRONT_CAMERA_SERIAL/BACK_CAMERA_SERIAL (RealSense), AUTO_DOCK_AFTER_MISSION, OPENAI_API_KEY (TTS), and the SDL_*/PYGAME_*/ALSA_* audio block.Devices/volumes: built-in speaker only — ALSA Card 0 (/dev/snd/controlC0, /dev/snd/pcmC0D0p, /dev/snd/timer), group_add: "29". Mounts /tmp, /dev, the dbus socket, /home/skycore/audio_storage, and ${LOG_DIR}/isaac-slam, plus the source tree /home/skycore/skyhub_core/docker/issac-slam/isaac_ros-dev/src/isaac_ros_common/docker/ → /home/.Enabling and disabling services
Toggling a service is purely aCOMPOSE_PROFILES edit — no code change. To add SLAM and the camera proxy to a drone:
.env
docker compose up -d brings up only the newly-enabled containers. Remember: the gamepad service’s profile is gamepad, not ws_proxy.
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; disablesmavproxy/camera-proxy/rtk-ntrip/isaac-slamviaprofiles: ["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) installer topology with onlycore+camera-proxy+mavproxy+ws_proxy. Its mavp2p runs on/dev/ttyUSB1:230400and defines only14900/14550/14777(nortk/arucoports), and setsSTARTDELAY=0oncore(the other services omit STARTDELAY).

