Guide
How to run PX4 SITL in GitHub Actions
This guide runs PX4 SITL in CI on a free GitHub-hosted runner, with no simulation service and nothing to self-host. It uses a public Docker image, waits for a MAVLink heartbeat, flies an arm-takeoff-land smoke test, and uploads the flight log as a workflow artifact. Every command below is copied from a run that passed.
The three files are in Hangars-Dev/px4-sitl-github-actions, MIT licensed, if you would rather fork than copy. That repository runs this workflow on itself, so its badge is the same job described here.
What SITL is
Software-in-the-loop runs the real PX4 flight-control firmware as an ordinary Linux process, with a physics simulator standing in for the airframe and its sensors. The autopilot code is the same code that flies the vehicle; only the hardware underneath it is simulated. That makes it the cheapest honest way to catch a navigation or mission bug — you fly the change before it reaches an aircraft.
What you need
- A repository with GitHub Actions enabled.
- Nothing else. No self-hosted runner, no GPU, no simulation account, and no PX4 build — the Docker image below already contains a compiled PX4 and Gazebo.
Three files do the work: a workflow at .github/workflows/sitl.yml, and two Python scripts under tests/.
The workflow file
This is the complete file. It is reproduced exactly as it ran.
name: PX4 SITL
on:
push:
pull_request:
workflow_dispatch:
jobs:
sitl:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Install MAVLink clients
run: pip install pymavlink==2.4.49 mavsdk-grpc==3.17.4
- name: Pull the PX4 image
run: docker pull jonasvautherin/px4-gazebo-headless:1.17.0
- name: Start PX4 SITL
run: |
mkdir -p logs
# --network host + an explicit 127.0.0.1 puts MAVLink on the runner's
# own loopback. Without the argument the image targets the default
# gateway, which under host networking is the wrong machine.
#
# --log-opt caps the container console. PX4 redraws its `pxh>` prompt
# on stdout forever, which fills the runner's disk and the job log
# at roughly 100 MB per minute if you let it.
docker run -d --name px4 \
--network host \
--log-opt max-size=10m --log-opt max-file=1 \
-v "$PWD/logs:/root/px4/build/rootfs/log" \
jonasvautherin/px4-gazebo-headless:1.17.0 127.0.0.1
- name: Wait for MAVLink heartbeat
run: python tests/wait_for_heartbeat.py
- name: Fly the smoke test
run: python tests/smoke_test.py
- name: Collect the console tail
if: always()
run: |
mkdir -p logs
# Never redirect the whole console into the job log.
docker logs --tail 2000 px4 > logs/px4-console.log 2>&1 || true
ls -laR logs
- name: Upload PX4 logs
if: always()
uses: actions/upload-artifact@v7
with:
name: px4-logs
path: logs/
if-no-files-found: error
- name: Stop PX4
if: always()
run: docker rm -f px4 || truePinning the image
jonasvautherin/px4-gazebo-headless is a community image that ships a prebuilt PX4 and Gazebo with no X server needed. Pin the tag. On :latest your CI silently changes flight-stack version underneath you, and a regression suite that moves on its own is not a regression suite. The tag here, 1.17.0, really is PX4 v1.17.0 — checked inside the container rather than read off the tag name: git -C /root/px4 describe --tags --exact-match returns v1.17.0.
Networking: why the explicit 127.0.0.1
The image sends MAVLink to a target address rather than listening for you. Its entrypoint takes that address as an argument; with no argument it picks the container's default gateway. Under --network host the default gateway is the runner's real network gateway, not the runner, so the packets leave the machine and the test waits forever. Passing 127.0.0.1 puts the offboard stream on the runner's own loopback, where the test scripts are listening. PX4 puts that stream on UDP 14540.
Capping the container console
This one costs a job if you miss it. PX4 runs its interactive pxh> shell on standard output and redraws the prompt continuously, so the container console grows without bound. On the first run of this guide, a plain docker logs px4 > file produced a 1.05 GB job log before the step was killed. The two --log-opt flags cap what Docker keeps, and docker logs --tail 2000 caps what the step reads. Never redirect the whole console into the job log.
Bind-mounting the logs
PX4 writes ULog files to /root/px4/build/rootfs/log/<date>/ inside this image, and it starts logging at boot rather than at arming. Mounting a host directory there puts the log on the runner's filesystem as it is written, which is what actions/upload-artifact needs. docker cp also works, but only if the container is still there to copy from — the bind mount survives a simulator that dies mid-flight, which is exactly the run whose log you want.
Failing the job
Nothing special is required. Both scripts exit non-zero when they fail, and a step that exits non-zero fails the job. The collect and upload steps carry if: always() so the logs still come out of a failed run, and if-no-files-found: error means a missing log is itself a failure rather than a silently empty artifact.
Waiting for the heartbeat
The simulator needs time before it can be flown. Rather than sleeping for a guessed number of seconds, block until PX4 actually announces itself. A MAVLink heartbeat is that announcement.
"""Block until PX4 sends a MAVLink heartbeat, or fail."""
import sys
import time
from pymavlink import mavutil
TIMEOUT_S = 180
def main() -> int:
conn = mavutil.mavlink_connection("udpin:0.0.0.0:14540")
deadline = time.monotonic() + TIMEOUT_S
while time.monotonic() < deadline:
msg = conn.recv_match(type="HEARTBEAT", blocking=True, timeout=5)
if msg is None:
print("... no heartbeat yet", flush=True)
continue
print(
f"heartbeat from system {msg.get_srcSystem()} "
f"component {msg.get_srcComponent()} "
f"autopilot={msg.autopilot} type={msg.type}",
flush=True,
)
return 0
print(f"FAIL: no heartbeat on udp:14540 after {TIMEOUT_S}s", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())A fixed sleep would be both slower and less reliable: too short and the flight script races the simulator, too long and every run pays for the worst case.
The smoke test
Arm, climb to five metres, hold, land. It is deliberately small — the point is to prove the whole path works end to end, so that a real mission suite has somewhere to plug in.
"""Arm, take off to 5 m, hold, land. Non-zero exit means the flight failed."""
import asyncio
import sys
# The 'mavsdk' name now points at the native binding, which has a different
# API. 'mavsdk-grpc' is the same gRPC wrapper this script was written against.
from mavsdk_grpc import System
TARGET_ALTITUDE_M = 5.0
CONNECT_TIMEOUT_S = 120
READY_TIMEOUT_S = 120
CLIMB_TIMEOUT_S = 60
LAND_TIMEOUT_S = 120
async def wait_for(condition, timeout_s, description):
"""Fail loudly instead of hanging until the job timeout kills us."""
try:
await asyncio.wait_for(condition, timeout_s)
except asyncio.TimeoutError:
raise SystemExit(f"FAIL: timed out after {timeout_s}s waiting for {description}")
async def connected(drone):
async for state in drone.core.connection_state():
if state.is_connected:
print("connected to PX4", flush=True)
return
async def position_ok(drone):
async for health in drone.telemetry.health():
if health.is_global_position_ok and health.is_home_position_ok:
print("global position and home position OK", flush=True)
return
async def reached_altitude(drone, target_m):
async for position in drone.telemetry.position():
altitude = position.relative_altitude_m
if altitude >= target_m * 0.95:
print(f"reached {altitude:.2f} m", flush=True)
return
async def landed(drone):
async for in_air in drone.telemetry.in_air():
if not in_air:
print("landed", flush=True)
return
async def run():
drone = System()
await drone.connect(system_address="udpin://0.0.0.0:14540")
await wait_for(connected(drone), CONNECT_TIMEOUT_S, "connection")
await wait_for(position_ok(drone), READY_TIMEOUT_S, "global position estimate")
print("arming", flush=True)
await drone.action.arm()
await drone.action.set_takeoff_altitude(TARGET_ALTITUDE_M)
print(f"taking off to {TARGET_ALTITUDE_M} m", flush=True)
await drone.action.takeoff()
await wait_for(
reached_altitude(drone, TARGET_ALTITUDE_M), CLIMB_TIMEOUT_S, "takeoff altitude"
)
# Hold briefly so the log contains steady hover, not just the climb.
await asyncio.sleep(5)
print("landing", flush=True)
await drone.action.land()
await wait_for(landed(drone), LAND_TIMEOUT_S, "landing")
# Disarm is automatic after landing; give PX4 a moment to close the log.
await asyncio.sleep(5)
print("smoke test passed", flush=True)
if __name__ == "__main__":
try:
asyncio.run(run())
except SystemExit as exc:
print(exc, file=sys.stderr)
sys.exit(1)Two details are worth copying. First, every wait has its own timeout, so a failure names the stage it died at instead of hanging until the job timer kills it twenty minutes later. Second, the script waits for is_global_position_ok and is_home_position_ok before arming; arming before PX4 has a position estimate is the most common way this script fails on a cold simulator.
A note on the MAVSDK package name
The library is installed as mavsdk-grpc, not mavsdk. Both were run here. mavsdk 3.17.4 works and flies the same test, but it prints a FutureWarning on import, saying the mavsdk name now refers to a different, native binding and pointing at mavsdk-grpc for the gRPC API this script uses. mavsdk-grpc 3.17.4 is that same wrapper at the same version number, so the guide installs it and the warning goes away. The runs behind the numbers below all used mavsdk-grpc.
Getting logs out when a run fails
A failing CI flight is useless without the flight log. The workflow uploads two things, and the split matters.
- The ULog — the binary flight log PX4 writes itself. Download the artifact and open it in PX4 Flight Review or PlotJuggler to see estimator state, setpoints and actuator outputs at full rate. This is the file that tells you why the vehicle did what it did.
- The console tail — the last 2000 lines of the container's output. This is where preflight rejections and module init failures appear, so it answers the different question of why the vehicle never got as far as flying.
Both come out of a failed run because the steps are marked if: always(). Without that, the upload is skipped exactly when you need it. To fetch them locally from a failed run:
# Find the run you care about.
gh run list --workflow sitl.yml
# Pull its artifact down. The artifact is named px4-logs, so it lands
# in ./artifacts/px4-logs/.
gh run download <run-id> -D artifacts
# artifacts/px4-logs/2026-09-22/03_05_04.ulg the flight log
# artifacts/px4-logs/px4-console.log the console tailHonest limits
Every number here was measured on 22 September 2026 on ubuntu-latest, the standard GitHub-hosted runner — 2 cores, 7 GiB of RAM, Intel Xeon Platinum 8573C — across four passing runs of the workflow above. Nothing below is an estimate, and a range means that is the spread across those four runs.
- Whole job, end to end
- 2m49s – 3m09s
- Pulling the image
- 1m41s – 2m06s
- Slowest pull seen, same image, fifth run
- 13m51s
- PX4 boot to first heartbeat
- 11s – 13s
- Arm, climb to 5 m, hold, land
- 31s – 46s
- Image download, linux/amd64
- 3.3 GB
- Runner disk free, before and after the pull
- 14 GB → 7 GB
- ULog written by one flight
- 8.3 MB
- Real-time factor, one instance
- 0.891
- Real-time factor, second instance also running
- 0.861
The image pull dominates the run
The pull is most of the run. Across the four passing runs the whole job took 2m49s to 3m09s, and 1m41s to 2m06s of that was pulling the image — the flight itself, boot through landing, never reached a minute. The download is 3.3 GB for linux/amd64, and unpacking it takes half the free disk on the runner: available space on / fell from 14 GB to 7 GB. Pull time is also the least predictable part of the job. A fifth run the same day spent 13m51s on the identical pull, with no change to the workflow — eight times the slowest of the other four. Budget for that case rather than the two-minute one.
One vehicle per job, with this image as shipped
PX4 itself supports multi-vehicle SITL, with a separate instance number, MAVLink system ID and port set per vehicle. This image does not configure any of that by default, and that is the limit worth knowing about. Starting a second container with the settings above is the trap, because it looks like it worked: both containers stayed up. But a client listening on UDP 14540 saw exactly one MAVLink system before and after, because both instances default to MAVLink system ID 1 and both send to the same port on the runner. With the defaults this image ships, a second instance is not separately addressable — you cannot tell the two apart, let alone command one of them. Getting real multi-vehicle out of this setup means assigning those IDs and ports yourself, per the PX4 multi-vehicle simulation guide, which this article does not cover and did not test. Until then, treat it as one vehicle per job, and fan out with a job matrix if you need more — every job pays the pull again.
The simulator does not run at real time
Simulated time does not keep up with the clock on the wall. Comparing the PX4 clock against wall time over 60 seconds, a single instance managed 54.00 s of simulated time in 60.61 s — a real-time factor of 0.891. Running a second container alongside it dropped the measured instance to 0.861. Read that as the cost of the extra load on a two-core runner, not as the throughput of two working vehicles: as the previous section says, the second instance was never separately addressable. The one-minute load average reached 13.75 on two cores. The same one-instance measurement on an Apple Silicon laptop gave 0.992, and that is not an emulation artifact: this image is multi-arch, so the laptop pulled and ran the native arm64 variant, with uname -m inside the container returning aarch64, while the runner ran linux/amd64. Different build, different hardware, so read it as a rough ceiling rather than a like-for-like comparison. The practical warning holds either way: set test timeouts in wall-clock terms with headroom, because a threshold tuned on a laptop can fail in CI for no reason except this.
What this setup does not give you
- No fleet without extra work. One addressable instance per job, as above.
- No video. The image can expose a camera stream, but that needs a different vehicle model, which this guide does not test.
- Nothing on the pull request. The result is a green or red check and an artifact you download by hand; there is no comment with the failing timestamp, and no replay.
- PX4 only. This guide is not tested against ArduPilot, so it does not claim to work there.
Or skip all of this — Hangars is building this as a hosted service. Join the mailing list.