When developing graphics applications such as OpenGL or Vulkan apps, we typically using hardware acceleration need a graphics card (Graphics Processing Unit, aka. GPU) to render graphics for better performance and visual quality.
However, in some cases, we may not have access to a GPU, such as headless cloud server or in a CI/CD environment.
In this blog post, I will show you how to develop OpenGL applications inside a Linux server environment which is running in a GPU-less Docker container (in my case, a MacBook Pro with Apple Silicon M3).
On a headless Linux server without a GPU, we can use the software implementation of OpenGL provided by Mesa.
About Mesa
Mesa is an open-source implementation of OpenGL that provides software rasterizers inside its Gallium driver, such as softpipe and LLVMpipe, for CPU-based rendering. The Gallium LLVMpipe driver uses LLVM to do runtime code generation with LLVM IR, is multithreaded and offers better performance than softpipe. Read more about Mesa's llvmpipe.
On Linux, EGL is commonly used as the integration layer between OpenGL and the native windowing system, such as X window system and Wayland.
EGL also supports off-screen rendering by creating an headless surface called Pbuffer (pixel buffer), which enables rendering without any real display or window.
RTT (Render To Texture) with EGL + OpenGL Image made by author
Following code outlines the steps to create an OpenGL context and render to OpenGL FBO + Texture (RTT) on EGL:
// 5. Create a 1x1 pbuffer surface. // Pbuffer surface is not required for FBO-based render-to-texture, but some EGL implementations require a surface to make the context current. // See https://registry.khronos.org/EGL/extensions/KHR/EGL_KHR_surfaceless_context.txt for more details. EGLint pbAttribs[] = { EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE, }; EGLSurface surface = eglCreatePbufferSurface(eglDpy, eglCfg, pbAttribs); if (surface == EGL_NO_SURFACE) { printf("failed to create pbuffer surface\n"); printf("eglError: %04x\n", eglGetError()); eglDestroyContext(eglDpy, ctx); eglTerminate(eglDpy); return-1; } eglMakeCurrent(eglDpy, surface, surface, ctx);
// 6. Load OpenGL functions using glad if (!gladLoadGL(eglGetProcAddress)) { printf("failed to load OpenGL functions with glad(eglGetProcAddress)\n"); eglDestroySurface(eglDpy, surface); eglDestroyContext(eglDpy, ctx); eglTerminate(eglDpy); return-1; } printf("OpenGL version: %s\n", glGetString(GL_VERSION)); printf("OpenGL vendor: %s\n", glGetString(GL_VENDOR)); printf("OpenGL renderer: %s\n", glGetString(GL_RENDERER)); printf("OpenGL shading language version: %s\n", glGetString(GL_SHADING_LANGUAGE_VERSION));
setupRenderTarget(); // 7. render loop here while(!exit_condition) { // Render with OpenGL context to the FBO + Texture doRenderPass(); // Flush OpenGL commands on render passes done glFlush(); // eglSwapBuffers is a no-op for Pbuffer surfaces, but may be required by some EGL implementations for context synchronization. eglSwapBuffers(eglDpy, surface);
// Read back the rendered image data from OpenGL FBO + Texture void *rgbaRawImageData = NULL; readbackImageData(&rgbaRawImageData); processImageData(rgbaRawImageData); free(rgbaRawImageData); }
To simulate a GPU-less environment, Docker is a great tool, which can help us create isolated environments for development and testing.
Docker image is a portable and reproducible environment that can run on any machine with Docker installed.
Community-maintained Docker Images
There are several community-maintained Docker images that support OpenGL. You can find them by searching “docker opengl” on Github。 Here are some popular ones:
However, most of these images haven’t been actively maintained for 5–7 years.
Yeah, OpenGL is considered legacy now. There are many new graphics APIs such as Vulkan, Metal, and Direct3D 12 that are more modern and efficient.
Whatever, I decided to build my own Docker image for OpenGL development to meet my needs.
Custom Docker Image
Here, I use the Debian‘s bookworm-slim as the base image. It provides a lightweight and minimal Debian environment, ideal for building and running OpenGL applications.
APT Source
To speed up the installation of dependencies, I replace the apt source to Aliyun mirror. Feel free to substitute it with your preferred mirror.
FROM debian:bookworm-slim RUN rm -f /etc/apt/sources.list.d/debian.sources RUN cat <<EOF > /etc/apt/sources.list deb http://mirrors.aliyun.com/debian/ bookworm main non-free-firmware contrib EOF # Install ca-certificates before using https sources RUN apt-get update -y && apt-get install -y --no-install-recommends ca-certificates
# Switch to https sources RUN cat <<EOF > /etc/apt/sources.list deb https://mirrors.aliyun.com/debian/ bookworm main non-free-firmware contrib deb-src https://mirrors.aliyun.com/debian/ bookworm main non-free-firmware contrib deb https://mirrors.aliyun.com/debian-security/ bookworm-security main non-free-firmware contrib deb-src https://mirrors.aliyun.com/debian-security/ bookworm-security main non-free-firmware contrib deb https://mirrors.aliyun.com/debian bookworm-updates main non-free-firmware contrib deb-src https://mirrors.aliyun.com/debian bookworm-updates main non-free-firmware contrib EOF
We need the necessary build tools, such as g++, meson, ninja, and so on, to build Mesa from source code.
Create a Dockerfile with the following content:
1 2 3 4 5 6 7 8 9 10 11 12 13
FROM debian-custom-apt:bookworm-slim AS builder # Install build dependencies. RUN apt-get install -y --fix-missing \ git g++ cmake automake autoconf pkgconf ninja-build \ python3 python3-pip python3-yaml python3-setuptools zip curl wget xz-utils; \ apt-get clean;
# You can replace the index URL to your preferred mirror. RUN pip config set global.index-url http://mirrors.aliyun.com/pypi/simple/ ; \ pip config set global.trusted-host mirrors.aliyun.com
# Install the latest meson and mako. RUN pip install meson mako --no-cache-dir --break-system-packages
To minimize the Docker image size, build Mesa with only the required software rasterizer (llvmpipe) and libraries.
Config building options:
Option
Description
platforms=[]
Disable all OpenGL native platform support, including X11, Wayland, etc.
gallium-drivers=llvmpipe
Use the llvmpipe Gallium driver for software rendering.
glx=disabled
Disable GLX support, since x11 is excluded.
vulkan-drivers=[]
Disable all Vulkan drivers, since we are not using Vulkan in this case.
video-codecs=[]
Disable all video codecs.
egl-native-platform=surfaceless
Turn on the only EGL native platform, which is surfaceless. This allows EGL to create an off-screen surface without a native windowing system.
opengl=true gles1=disabled gles2=disabled
Enable desktop OpenGL support, disable GLES1, GLES2 and GLES3.
glvnd=disabled
Disable GLVND (OpenGL Vendor Neutral Dispatch), since only mesa’s OpenGL implementation is used.
Software implementation of Vulkan
If you prefer using Vulkan, Mesa also offers a software Vulkan implementation called Lavapipe (also base on LLVMpipe).
COPY --from=builder /var/tmp/installdir /usr/local # replace the temporary install directory with the real one. RUN find /usr/local/lib -iname '*.pc' -exec sed -i 's|/var/tmp/installdir|/usr/local|g' {} \;
{ "name": "MesaOpenGL", "build": { // Path is relative to the devcontainer.json file. "dockerfile": "Dockerfile" }, // Features to add to the dev container. More info: https://containers.dev/features. "features": { "ghcr.io/devcontainers/features/common-utils:2": {} }, "customizations": { "vscode": { "settings": {}, "extensions": [ "llvm-vs-code-extensions.vscode-clangd", "vadimcn.vscode-lldb" ] } }, }
Create a Dockerfile in the .devcontainer directory with the following content:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
# Use the Mesa llvmpipe image we built earlier as the base image for the dev container. FROM mesa-egl-opengl:v25.1.5-llvm16 ARG LLVM_VERSION=16 # Use llvm (clang) as the C/C++ compiler and tools. RUN apt-get update -y && \ apt-get install -y --no-install-recommends \ build-essential cmake ninja-build pkgconf \ clang-${LLVM_VERSION} libc++-${LLVM_VERSION}-dev libc++abi-${LLVM_VERSION}-dev \ clangd-${LLVM_VERSION} lldb-${LLVM_VERSION} clang-format-${LLVM_VERSION} \ libunwind8; \ apt-get clean
In this blog post, we used Mesa’s EGL and OpenGL software implementation for off-screen rendering on a headless Linux server without a GPU (using Docker).
AI Assistance
This blog post was written with the assistance of LLMs (such as GPT-4.1 and Google Gemini 2.5 Pro), to help translate my thoughts into English (my native language is Chinese) and refine the content into more natural and readable English. If you notice any mistakes in the code or explanations, please feel free to leave a comment below.