SkyCore is a small 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 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 in docker-compose.yml (project root). Three services, each with exactly one profile:
Service (compose name)ProfileImageRolenetwork_mode
skyhubskyhubskyhub-app:latestThe whole application: the ROS half (MAVROS + rosbridge :9090 + drone_node modules) and the agent half (manual control, asset upload) in one process tree under supervisordhost
mavproxymavproxyECR skyhub-prod-drone-mavp2p-image (built from docker/mavp2p/Dockerfile)mavp2p — the single MAVLink router between the FC and every consumerhost
rtk-ntriprtkskyhub-rtk-ntrip:latest, built from ./docker/rtk-ntripNTRIP client: injects RTCM corrections into the FChost
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.
The vehicle runs a single 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.
Which services actually start is set by COMPOSE_PROFILES in .env (catalog: .env.example:7). The shipped default is:
.env
COMPOSE_PROFILES=skyhub,mavproxy,rtk
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:
  1. 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.
  1. *_STARTDELAY — each service sleeps N seconds at boot before launching its process:
ServiceEnv varDefaultWhere it sleeps
mavproxynonestarts immediately (root of the chain)
skyhub (ROS half)STARTDELAY, from DRONE_STARTDELAY5docker/core/entrypoint.sh:4 (MAVROS launch)
skyhub (agent half)AGENT_STARTDELAY, from WS_PROXY_STARTDELAY5docker/gamepad/startup.sh
rtk-ntripRTK_STARTDELAY10docker/rtk-ntrip/entrypoint.sh:5
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.
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, 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 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:
ProgramPriorityCommandNotes
sshd50/usr/sbin/sshd -Dshell access; binds SKYHUB_SSH_PORT (default 322)
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
agent400/app/startup.shthe agent half; startsecs=10, startretries=3
drone_node999python3 /main.pylast; loads modules, subscribes /mavros/state
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

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):
ModuleEnv flagDefault
video_streamVIDEO_STREAM_ENABLEDon
aruco_landingARUCO_LANDING_ENABLEDoff
batteryBATTERY_INDICATOR_ENABLEDon
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.
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).
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.

Enabling and disabling services

Toggling a service is purely a COMPOSE_PROFILES edit — no code change. There are exactly three profiles, and rtk is the only one a stock vehicle might reasonably run without:
.env
COMPOSE_PROFILES=skyhub,mavproxy
Then 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 a sitl-vpn bridge net (10.223.0.0/16), a local redis:7-alpine, and a skyhub-sitl ArduPilot container; parks mavproxy and rtk-ntrip on 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) topology that a provisioned vehicle downloads as its docker-compose.yml. Two services: skyhub (SKYHUB_ROLE=vehicle, {{ECR_APP_IMAGE}}) and mavproxy ({{ECR_MAVP2P_IMAGE}}). Its mavp2p runs on /dev/ttyUSB1:230400 and defines only 14900/14550/14777 (no rtk/aruco ports), and sets STARTDELAY=0.
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, 322) all depend on it. Do not switch to bridged networking without redesigning all three fabrics. Do not hardcode those ports back, either — containers can share a host network namespace and the second one to bind a fixed port dies.
  • The mavp2p port map is CLI-only and load-bearing (14550 MAVROS, 14777 agent, 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, the agent half raises RuntimeError at boot. Two channels — video_stream_state and video_stream_status_request — are intentionally global (unprefixed).
  • supervisord priority order — sshd(50) → mavros(100) → rosbridge(200) → stream_rates(300) → agent(400) → drone_node(999). Reordering can break the /mavros/state subscription and stream-rate bootstrap.
  • The two source trees stay separate on disk — ROS half at /, agent at /app. Both root at src/ and collide on several module paths; they stay apart only because Python puts the script’s own directory on sys.path. Nothing may be added to PYTHONPATH that would undo it.