Introduction

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).

Github repo yrom/docker-egl-opengl

Overview

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
#include <EGL/egl.h>
#include <EGL/eglext.h>
#include "glad/gl.h" // OpenGL function loader
int main(int argc, char *argv[])
{
// 1. Initialize EGL
EGLDisplay eglDpy = eglGetDisplay(EGL_DEFAULT_DISPLAY);

EGLint major, minor;

if (!eglInitialize(eglDpy, &major, &minor)) {
// Handle error
return -1;
}
printf("EGL version = %d.%d\n", major, minor);
printf("EGL_VENDOR = %s\n", eglQueryString(eglDpy, EGL_VENDOR));
const char *clientApis = eglQueryString(eglDpy, EGL_CLIENT_APIS);
printf("EGL client APIs: %s\n", clientApis);
// 2. Choose an EGL configuration for OpenGL Context
eglBindAPI(EGL_OPENGL_API); // Bind OpenGL API

const EGLint configAttribs[] = {
EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, // Use OpenGL Context
EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, // Use off-screen surface
EGL_BLUE_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_RED_SIZE, 8,
EGL_DEPTH_SIZE, 24, // 24-bit depth buffer
EGL_NONE
};

EGLint numConfigs;
EGLConfig eglCfg;

if (!eglChooseConfig(eglDpy, configAttribs, &eglCfg, 1, &numConfigs) || !numConfigs) {
// Handle error
// maybe no OpenGL support!
printf("failed to choose EGL config, eglError: %04x\n", eglGetError());
eglTerminate(eglDpy);
return -1;
}

// 3. Bind the OpenGL API
eglBindAPI(EGL_OPENGL_API);


// 4. Create an OpenGL 4.5 core profile context.
// EGL_CONTEXT_MAJOR_VERSION and EGL_CONTEXT_MINOR_VERSION requires EGL_KHR_create_context extension.
const EGLint ctxAttribs[] = {
EGL_CONTEXT_MAJOR_VERSION, 4,
EGL_CONTEXT_MINOR_VERSION, 5,
EGL_CONTEXT_OPENGL_PROFILE_MASK, EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT
EGL_NONE,
};
EGLContext ctx = eglCreateContext(eglDpy, eglCfg, EGL_NO_CONTEXT, ctxAttribs);
if (ctx == EGL_NO_CONTEXT) {
// Handle error
printf("failed to create OpenGL context, eglError: %04x\n", eglGetError());
eglTerminate(eglDpy);
return -1;
}

// 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);
}

// 8. Terminate EGL when finished
eglMakeCurrent(eglDpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
eglDestroySurface(eglDpy, surface);
eglDestroyContext(eglDpy, ctx);
eglTerminate(eglDpy);
return 0;
}

Why Docker

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.

A Dockerfile for the base image is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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

RUN apt-get update -y && \
apt-get install -y locales && \
localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8

ENV LANG=en_US.UTF-8 \
LANGUAGE=en_US.UTF-8 \
LC_ALL=en_US.UTF-8

CMD ["/bin/bash"]

Build the base image:

1
docker build -t debian-custom-apt:bookworm-slim -f base.Dockerfile .

Build Mesa with llvmpipe

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:

OptionDescription
platforms=[]Disable all OpenGL native platform support, including X11, Wayland, etc.
gallium-drivers=llvmpipeUse the llvmpipe Gallium driver for software rendering.
glx=disabledDisable 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=surfacelessTurn 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=disabledEnable desktop OpenGL support, disable GLES1, GLES2 and GLES3.
glvnd=disabledDisable 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).

Append the following content to Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
ARG MESA_VERSION=24.3.4
ARG LLVM_VERSION=15
ARG BUILD_TYPE=debugoptimized
ARG BUILD_OPTIMIZATION=2
ARG UNWIND=enabled
# Install deps for building Mesa with llvmpipe software renderer.
RUN apt-get update -y && apt-get install -y --no-install-recommends flex bison zlib1g-dev libzstd-dev \
llvm-${LLVM_VERSION}-dev libclang-${LLVM_VERSION}-dev libclang-cpp${LLVM_VERSION}-dev libllvm${LLVM_VERSION} \
glslang-tools \
libdrm-dev \
libunwind-dev

# remove temporary files
RUN apt-get clean

# Get Mesa3D source code.
RUN set -e; \
mkdir -p /var/tmp; \
cd /var/tmp; \
wget "https://archive.mesa3d.org/mesa-${MESA_VERSION}.tar.xz"; \
test -f mesa-${MESA_VERSION}.tar.xz && \
tar xvf mesa-${MESA_VERSION}.tar.xz && \
rm mesa-${MESA_VERSION}.tar.xz;

# meson options: https://gitlab.freedesktop.org/mesa/mesa/-/raw/mesa-24.3.4/meson_options.txt
RUN set -e; \
cd /var/tmp/mesa-${MESA_VERSION}; \
meson setup build/ \
-D buildtype=${BUILD_TYPE} \
-D prefix=/usr/local \
-D platforms=[] \
-D llvm=enabled \
-D egl-native-platform=surfaceless \
-D gallium-drivers=llvmpipe \
-D glvnd=disabled \
-D gles1=disabled \
-D gles2=disabled \
-D opengl=true \
-D gbm=disabled \
-D glx=disabled \
-D vulkan-drivers=[] \
-D lmsensors=disabled \
-D gallium-xa=disabled \
-D gallium-vdpau=disabled \
-D gallium-va=disabled \
-D libunwind=${UNWIND}; \
meson install -C build;

FROM debian-custom-apt:bookworm-slim AS runtime

ARG LLVM_VERSION=15

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' {} \;

# Install Runtime dependencies of mesa's EGL + OpenGL (Gallium llvmpipe) implementation.
RUN apt-get update -y && \
apt-get install -y --no-install-recommends libzstd1 libgcc-s1 zlib1g libc6 libllvm${LLVM_VERSION} libdrm2 libunwind8 && \
apt-get clean

ENV LIBGL_ALWAYS_SOFTWARE="1" \
GALLIUM_DRIVER="llvmpipe"

Build the Mesa with llvmpipe Gallium-driver image:

1
2
3
4
5
docker build -t mesa-egl-opengl:v25.1.5-llvm16 -f Dockerfile \
--build-arg MESA_VERSION=25.1.5 --build-arg LLVM_VERSION=16 \
--build-arg BUILD_TYPE=release --build-arg BUILD_OPTIMIZATION=3 \
--build-arg UNWIND=disabled \
.

Develop OpenGL Application using VScode Dev Containers

Create a .devcontainer directory in your project root, and add a devcontainer.json file with the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"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

RUN update-alternatives --install /usr/bin/clang clang /usr/lib/llvm-${LLVM_VERSION}/bin/clang 100 && \
update-alternatives --install /usr/bin/clang++ clang++ /usr/lib/llvm-${LLVM_VERSION}/bin/clang++ 100 && \
update-alternatives --install /usr/bin/clangd clangd /usr/lib/llvm-${LLVM_VERSION}/bin/clangd 100 && \
update-alternatives --install /usr/bin/clang-format clang-format /usr/lib/llvm-${LLVM_VERSION}/bin/clang-format 100 && \
update-alternatives --install /usr/bin/lldb lldb /usr/lib/llvm-${LLVM_VERSION}/bin/lldb 100 && \
update-alternatives --install /usr/bin/lldb-server lldb-server /usr/lib/llvm-${LLVM_VERSION}/bin/lldb-server 100 && \
update-alternatives --install /usr/bin/llvm-config llvm-config /usr/lib/llvm-${LLVM_VERSION}/bin/llvm-config 100

Start VS Code, run the Dev Containers: Reopen In Container command from the Command Palette.

Reopen In Container

The complete project structure should look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
├── .devcontainer
│   ├── devcontainer.json
│   └── Dockerfile
├── .gitignore
├── .vscode
│   ├── launch.json
│   ├── settings.json
│   └── tasks.json
├── build
├── include
│   └── glad
├── Makefile
├── README.md
└── main.c

See my github repo yrom/docker-egl-opengl for the complete source code.

Conclusion

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.

References