If you build scenery, at some point you need to look at it, and the machine that built it may have no display and no graphics card: a build server, a CI job, a Docker container on a laptop. Running FlightGear headless is the answer, and it works: no GPU, no X session, no window, and a frame at the end of it.
It is also how the renders on this site were made: inside a Docker container on an Apple Silicon Mac, where no graphics card is reachable at all. The recipe below ran on 23 August 2026 and produced those images; the capture script is published with the build record.
The short version
Xvfb :99 -screen 0 1024x576x24 &
export DISPLAY=:99 LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe
dbus-run-session -- fgfs \
--fg-root=/usr/share/games/flightgear \
--fg-scenery=/path/to/your/scenery \
--aircraft=ufo \
--airport=KSFO --altitude=2000 \
--disable-terrasync --disable-sound --disable-ai-models \
--disable-ai-traffic --disable-real-weather-fetch \
--disable-random-objects --disable-random-vegetation \
--disable-clouds --disable-clouds3d --disable-splash-screen \
--timeofday=noon --visibility=40000 --geometry=1024x576 \
--prop:/sim/rendering/multi-sample-buffers=0 \
--prop:/sim/rendering/shader-effects=0 \
--prop:/sim/rendering/quality-level=0 &
sleep 15
import -window root -display :99 frame.png
Two of those lines are the whole difficulty, and neither is obvious.
1. It needs a D-Bus session or it aborts instantly
Without one, fgfs dies in under a second; not on a graphics
error, on an assertion from a library you did not know it used:
dbus[11]: arguments to dbus_connection_set_exit_on_disconnect() were incorrect,
assertion "connection != NULL" failed in file ../../../dbus/dbus-connection.c line 3159.
Aborted
FlightGear’s Qt front end wants a session bus. Containers do not have one.
Install dbus and dbus-x11, then launch under
dbus-run-session, which creates a bus for the life of the command
and tears it down after.
2. The default aircraft will not finish a frame
With the default c172p, the log ran to 13.5 seconds, reached the
scenery, and then stopped. Nothing more was written for three minutes; the
process stayed alive at 100% of one core and never produced a frame. It is not
hung on anything you can see in the log: software rasterisation is simply too
slow for a fully modelled cockpit interior.
--aircraft=ufo has no cockpit model. The same setup rendered in
under fifteen seconds.
If you want an aircraft in shot, pick the simplest one that will do, and expect first-frame time to scale with how much of it is modelled.
Checking software OpenGL is actually active
glxinfo -B | grep -E "OpenGL (vendor|renderer|version)"
What you want to see:
OpenGL vendor string: Mesa/X.org
OpenGL renderer string: llvmpipe (LLVM 15.0.6, 128 bits)
OpenGL version string: 4.5 (Compatibility Profile) Mesa 22.3.6
llvmpipe is Mesa’s software rasteriser. OpenGL 4.5 in software is
more than FlightGear needs. If the renderer says anything else, or
glxinfo fails, the display or the driver is not set up and
fgfs will fail with an inability to create a window rather than
anything more helpful.
The packages
On Debian 12, this is the whole list:
apt-get install --no-install-recommends \
flightgear flightgear-data-base flightgear-data-models \
xvfb x11-utils imagemagick \
libgl1-mesa-dri mesa-utils \
dbus dbus-x11
Two things worth knowing on Debian: fgfs installs to
/usr/games, which is not on PATH for a container’s root
user, and FG_ROOT is
/usr/share/games/flightgear. Without
flightgear-data-models the scenery loads but every windsock, beacon
and taxiway sign logs a “file not found”; harmless, and a lot of noise.
The image comes to about 5.6 GB, most of it the 1.3 GB base data package.
Render a control frame
This is the part people skip, and it is what makes the other frames mean anything. Point FlightGear at an empty scenery directory and render the same view:
mkdir -p /tmp/empty/Terrain /tmp/empty/Objects
# ... same command, --fg-scenery=/tmp/empty
At KSFO that gives open ocean to the horizon, because FlightGear’s data
package ships terrain for three buckets only; w030n60,
w160n10 and w160n20; and San Francisco is not among
them. Anything visible in your real render therefore came out of your build and
not out of the box.
Without that check, a screenshot of terrain proves nothing: bundled scenery, a TerraSync download and your own tiles all look like terrain.
Knowing when a frame is ready
Sleeping for a fixed time works and wastes time. A better test is to capture repeatedly and look at how many distinct colours the image has:
import -window root -display :99 /tmp/f.png
convert /tmp/f.png -format "%[mean] %k" info:
A blank or still-loading frame comes back with a handful of colours. The rendered frames here had between eight and nine thousand. Polling for a colour count above a few hundred is a reliable “the world is drawn” signal and takes seconds rather than minutes.
What this does not give you
- Frame rates. llvmpipe is for producing an image, not for measuring performance. Nothing about timing here transfers to real hardware.
- Shader-dependent visuals. The recipe turns shader effects off. Anything whose appearance depends on them will not look the way it does on a GPU.
- Interaction. This renders a frame from a fixed position. Flying it is a different exercise.