SkyCore is not one program — it is a set of Docker Compose services that run on the Jetson and, together, turn a raw ArduPilot flight controller into a cloud-connected drone. Every service is gated by a Compose profile, runs in 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 in docker-compose.yml (project root). Six services, each with exactly one profile:
Service (compose name)ProfileImageRolenetwork_mode
corecorecore:latest (ECR skyhub-prod-drone-ros2-image)ROS2: MAVROS + rosbridge :9090 + modules (video, ArUco, battery)host
mavproxymavproxyECR skyhub-prod-drone-mavp2p-imagemavp2p — the single MAVLink router between the FC and every consumerhost
ws_proxygamepadgamepad:latest (ECR skyhub-prod-drone-ws-image)FastAPI WebSocket :5001 manual control + gimbal/zoom + asset uploadhost
rtk-ntriprtkbuilt from docker/rtk-ntrip/DockerfileNTRIP client: injects RTCM corrections into the FChost
camera-proxycamerabuilt from docker/core/Dockerfile.proxyRe-serves the SIYI RTSP camera as a local RTSP :8554 endpointhost
isaac-slamslamskyhub-isaac_ros_deploy:latestGPU: Isaac ROS Visual SLAM + nvblox + docking + TTS audiohost
The Compose service name and its profile name differ for the gamepad service: the service is ws_proxy but the profile is gamepad. To disable manual control you drop gamepad (not ws_proxy) from COMPOSE_PROFILES; to docker compose logs it you target ws_proxy.
Which services actually start is set by COMPOSE_PROFILES in .env (catalog: .env.example:10). The shipped default is:
.env
COMPOSE_PROFILES=mavproxy,core,rtk,gamepad
So 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:
  1. 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.
  1. *_STARTDELAY — each service sleeps N seconds at boot before launching its process. These are read from the environment (.env.example:16-21):
ServiceEnv varDefaultWhere it sleeps
mavproxynonestarts immediately (root of the chain)
coreDRONE_STARTDELAY5docker/core/entrypoint.sh:4 (MAVROS launch)
camera-proxyCAMERA_PROXY_STARTDELAY5container entry
ws_proxyWS_PROXY_STARTDELAY5docker/gamepad/startup.sh:6
rtk-ntripRTK_STARTDELAY10docker/rtk-ntrip/entrypoint.sh:5
isaac-slamISAAC_STARTDELAY5container entry
Because ordering is delay + depends_on only (Compose depends_on waits for the container to start, not to become healthy), races are possible. The one component that self-heals is set_stream_rates.sh inside core, which independently polls up to 120 s for the FC connection (see below). The SITL local overlay is the only place ordering is health-gated — it uses condition: service_healthy on Redis. See Local Development with SITL.

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:
ProgramPriorityCommandNotes
sshd50/usr/sbin/sshd -Dshell access into the container
mavros100docker/core/entrypoint.shros2 launch mavros apm.launch fcu_url:=$FCUURL
rosbridge200docker/core/rosbridge_entrypoint.shrosbridge_websocket on ROSBRIDGE_PORT (default 9090)
stream_rates300docker/core/set_stream_rates.shone-shot (autorestart=false); raises MAVLink stream rate to 10 Hz
drone_node999python3 /main.pylast; 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.
docker/core/entrypoint.sh:8 hardcodes ROS_DOMAIN_ID=1 on the MAVROS launch line, ignoring the ROS_DOMAIN_ID env var that the rest of the stack (drone_node, isaac-slam, SITL) honors. If someone sets ROS_DOMAIN_ID to anything other than 1, MAVROS ends up on a different DDS domain than drone_node/isaac-slam and the SLAM ↔ core link silently breaks.

Per-service reference

Profile 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):
ModuleEnv flagDefault
video_streamVIDEO_STREAM_ENABLEDon
aruco_landingARUCO_LANDING_ENABLEDoff
batteryBATTERY_INDICATOR_ENABLEDon
Key env vars (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.
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.
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).
docker/rtk-ntrip/src/shared/config.py:16-17 ships real Swift Skylark NTRIP credentials as code defaults (NTRIP_USERNAME / NTRIP_PASSWORD = <redacted>). Always override them via .env; treat scrubbing these from source as a follow-up. Deep dive: RTK NTRIP GPS Corrections.
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).
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/.
The audio cards are deliberately split to avoid ALSA contention: core owns the ReSpeaker mic on Card 1 (INPUT), isaac-slam owns the built-in speaker on Card 0 (OUTPUT). Reassigning cards will make them collide. Also note the on-disk directory is misspelled docker/issac-slam while the service/image use isaac — and the Compose mount hardcodes the absolute host path /home/skycore/skyhub_core/docker/issac-slam/....

Enabling and disabling services

Toggling a service is purely a COMPOSE_PROFILES edit — no code change. To add SLAM and the camera proxy to a drone:
.env
COMPOSE_PROFILES=mavproxy,core,rtk,gamepad,camera,slam
Then 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 a sitl-vpn bridge net (10.223.0.0/16), a local redis:7-alpine, and a skyhub-sitl ArduPilot container; disables mavproxy/camera-proxy/rtk-ntrip/isaac-slam via profiles: ["disabled"]; drops privileged; and injects IP_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 only core + camera-proxy + mavproxy + ws_proxy. Its mavp2p runs on /dev/ttyUSB1:230400 and defines only 14900/14550/14777 (no rtk/aruco ports), and sets STARTDELAY=0 on core (the other services omit STARTDELAY).
The Gateway also spawns SITL drones as an interchangeable, rosbridge-compatible stand-in for physical drones — that server-side orchestration is documented in SITL Drone Lifecycle.

Gotchas a refactor must preserve

These invariants are easy to break and hard to notice:
  • All services are network_mode: host. DDS discovery, 127.0.0.1:145xx MAVLink loopback, and the host-exposed ports (9090, 5001, 8554) all depend on it. Do not switch to bridged networking without redesigning all three fabrics.
  • core/entrypoint.sh hardcodes ROS_DOMAIN_ID=1 for MAVROS, ignoring the env var. Setting ROS_DOMAIN_ID != 1 splits MAVROS off from drone_node/isaac-slam.
  • The mavp2p port map is CLI-only and load-bearing (14550 core, 14777 gamepad, 14560 rtk, 14561 aruco, 14900 external). Change one number and you must change the matching consumer’s env.
  • Startup is delay-based, not health-gated in prod. Rely on set_stream_rates.sh’s 120 s poll, not on depends_on, for FC-ready sequencing.
  • Redis channels are namespaced by the wg0 IP (or IP_OVERRIDE). Without one, ws_proxy raises RuntimeError at boot. Two channels — video_stream_state and video_stream_status_request — are intentionally global (unprefixed).
  • supervisord priority order inside core — sshd(50) → mavros(100) → rosbridge(200) → stream_rates(300) → drone_node(999). Reordering can break the /mavros/state subscription and stream-rate bootstrap.