Modernizing a legacy CMake build-system

by | Apr 22, 2026

CMake tends to have a bad reputation for being to complex and convoluted, but often that notion stems from very old versions of CMake. Sure, CMake is a Turing-complete scripting language, but that is really needed for an ecosystem as complex as that of C and C++. And as Greenspun’s tenth rule of programming goes:

Any sufficiently complicated C or Fortran program contains an ad hoc, informally-specified, bug-ridden, slow implementation of half of Common Lisp.

There are countless build-systems and build-system generators for the C/C++ ecosystem. Some of them tried to use a simple, declarative approach. But there’s always edge-cases and either the build-system lacks the feature to support them, or it needs to add scripting capabilities at some point. CMake was designed with that in mind from the start.

So C/C++ build-systems are always going to be complex, but what they don’t have to be, however, is ugly and hard to maintain. And thankfully, CMake walks that line quite well, at least if you know how to use the right idiomatic approach in the form of “modern” CMake, which describes anything from version 3.0 (released in 2014) onward.

In this blog post I want to showcase a few of the anti-patterns used in legacy CMake build-systems (with examples from Icinga 2) and make a case for the modern and idiomatic alternatives CMake provides for their use-cases.

Everything’s a Target

In the old days of CMake 2, the fundamental objects you’d work most with were lists, much like you would back in the day in hand written Makefiles. Across the project you’d collect, append to and further process lists of source files, of compiler and linker flags, of includes and definitions. And then you’d use them to declare your final build targets (executables or static/dynamic libraries) or to globally set include directories and flags for the entire project.

On modern CMake, the idiomatic approach is to create a hierarchy of virtual targets (OBJECT and INTERFACE libraries) within the subdirectories. Each target then contains all the information needed to build and use that part of the program, then successively link them together as we move up the directory tree again. With this approach, a clear dependency graph emerges that is simpler to reason about than the ball of yarn of concatenated lists you’d otherwise have on large projects. It also preserves all the metadata and what objects it applies to, which could be useful in some more complex situations, like when reusing parts built for something else.

I’ll give you an example from Icinga 2, which has many instances of patterns similar to this:

set(base_SOURCES
  application.cpp
  application-version.cpp
  application-environment.cpp
  [...]
  workqueue.cpp
)

add_library(base OBJECT ${base_SOURCES})

include_directories(SYSTEM ${icinga2_SOURCE_DIR}/third-party/execvpe)
link_directories(${icinga2_BINARY_DIR}/third-party/execvpe)

We collect a list of source files base_SOURCES, add them to an object library, then add global include and link directories that will be picked up by all targets.

Back in the root project file we do something like this:

add_subdirectory(base)
[...]
add_subdirectory(remote)

set(base_DEPS ${CMAKE_DL_LIBS} ${Boost_LIBRARIES} ${OPENSSL_LIBRARIES})
set(base_OBJS $<TARGET_OBJECTS:mmatch> $<TARGET_OBJECTS:socketpair> $<TARGET_OBJECTS:base>)

add_executable(icinga-app
  $<TARGET_OBJECTS:icingaloader>
  ${base_OBJS}
  $<TARGET_OBJECTS:config>
  $<TARGET_OBJECTS:remote>
  $<TARGET_OBJECTS:cli>
  $<TARGET_OBJECTS:icinga>
  $<TARGET_OBJECTS:methods>
  ${icinga_app_SOURCES}
)

target_link_libraries(icinga-app ${base_DEPS})

This extracts a list of objects from object libraries collected from all around the project and directly builds/links the final executable from them. This discards any potential metadata attached to the libraries (because we’ve set that information globally anyways) and then links to a list of dependencies also collected as a list throughout the project.

Also consider the way Boost is added to base_DEPS, in the form of the legacy Boost_LIBRARIES variable. What you should actually do is use the targets that find_package(Boost REQUIRED ...) returns in the form of Boost::<component> and link to that with target_link_libraries(). When you link against that target, CMake already adds all include directories and flags you will need. However, in the above example, we explicitly only append the link commands for the library objects to a list and then do a global include_directories(SYSTEM ${Boost_INCLUDE_DIRS}) elsewhere.

So, how can we improve upon this mess?

add_library(base OBJECT
  application.cpp
  [...]
  workqueue.cpp
)

target_include_directories(
  PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}
)

target_link_libraries(base
  PUBLIC
    execvpe
    OpenSSL
    Boost::program_options
    Boost::filesystem
    [...]
)

First we define a library with all the sources we need. This could also be an INTERFACE library instead of OBJECT, depending on whether we want to build the source-files into objects right here or propagate them upwards as metadata instead.

We attach the current subdirectory as a PUBLIC include directory, because other parts of our program that will link against base will need those includes to compile. Previously that was done by globally adding the directory via include_directories. This works, but will expose includes to sources that aren’t getting linked to the required objects that contain the symbols, or shadow header files from previously added include directories.

Then we link all the dependencies these source-files need to this object library as PUBLIC. Note that this doesn’t actually link anything yet, it just describes the relationship between this library and the dependencies and how to propagate them to targets that link against *this* library.

Whether you link a dependency as PUBLIC or PRIVATE on OBJECT libraries depends entirely on whether you want to propagate the include directories and other metadata to the libraries consumers. On interface libraries you have no choice other than linking with INTERFACE, because propagating the metadata is the entire point. PRIVATE would be useful if you are sure the dependencies are only needed to build the OBJECT library itself, not its consumers. This could for example be the case when linking against a header-only library that is only used in the library’s TUs, but not its interface.

On the consumer’s side it looks essentially the same:

add_executable(icinga-app
  icinga.cpp
)

target_link_libraries(icinga-app
  PRIVATE
    base
    config
    remote
    mmatch
    socketpair
    [...]
)

This is our final target, the icinga2 executable in this case, but there could be any number of steps in our hierarchy of targets and it would always look more or less exactly like this: add_subdirectory, add targets, attach metadata (includes, defines, features, dependencies).

As a rule of thumb, if you need to low-level process a list of include files, libraries, flags etc. in your project files, it’s likely that you’re doing something wrong. And if you’re really sure that you need to do some low-level things that are not (yet) abstracted by CMake, you should move the functionality into a CMake module. That way the project file stays clean, only using high-level functions that operate on targets and their properties.

Unity Builds

Unity builds (also sometimes called “Jumbo” builds) are a way to reduce build times by combining multiple translation units (read: source files) into one, which especially comes in handy when many files include the same large headers again and again. This also helps with optimization, though the same effect can be reached with link time optimization (LTO) these days, so the build times are our main concern.

Icinga 2 actually rolled out unity builds long before they were natively supported by CMake starting with version 3.16. At the start of our current build process we make a custom helper binary mkunity, that concatenates all files in one of our source lists into a big one and replaces them in the target:

mkunity_target(base base base_SOURCES)

mkunity_target() is a custom CMake function that uses the aforementioned mkunity tool internally. At first glance it looks like it operates on the target, like the target_* CMake commands. But it essentially only uses the base parameter for naming the generated source file, while actually operating on the, you guessed it, *source list* as an output parameter, where it replaces eligible files with the generated <name>unity.cpp file.

This sounds (and is) pretty complicated, but luckily CMake’s built-in method to specify unity builds is essentially just this:

set_target_properties(base PROPERTIES
  UNITY_BUILD ON
)

If you flip this switch, CMake will get the source files associated with the target, generate a new source file that includes the other files in order, and compiles that instead.

If you need to exclude certain source files from unity-builds, for example due to ODR issues, you can set the source file property SKIP_UNITY_BUILD_INCLUSION to true for the file.

There’s additional target properties for batching and grouping, but you can read the documentation for more information on those.

Alternatively, you can also leave it up to the user to enable this globally, because it is initialized by default with the variable CMAKE_UNITY_BUILD. This should in many cases *just work*, but needs to be tested regularly or as part of the CI, so no ODR violations creep in.

Note that the snippet above doesn’t automatically unity-build the sources from linked object-libraries, since these have already built as objects themselves. If you want to collect sources from other targets and include them in one big unity build, INTERFACE libraries with sources attached as PUBLIC can be used. The caveat is that they have to be linked as PRIVATE, because otherwise the sources are getting propagated to the unity library’s consumers, leading to multiple definition linker errors.

add_library(Otel INTERFACE)
target_sources(Otel
  PUBLIC
    Otel.cpp
)
[...]
target_link_libraries(base
  PRIVATE
    Otel
)

That way nothing is built until base, which now knows about all sources required for the unity-build.

Compiler Flags and Presets

One of the most egregious things about Icinga 2’s build-system is how much we manipulate CMAKE_CXX_FLAGS and related variables in our CMakeLists.txt project files. Not only are these cache variables, so changing them inside the project files after a build directory has already been created will have no effect, but they’re also not meant to be touched by project files in the first place.

I’ll take you through a few common types of compiler flags and how to deal with them in CMake.

Library Flags

Some of those flags, the ones absolutely needed the build a target, should only be added to the target itself as required.

Take a quick glance at how adding thread support is handled in Icinga 2’s project files:

if(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  if (NOT CMAKE_CXX_COMPILER_ID MATCHES "AppleClang")
    set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -pthread")
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pthread")
  endif()
endif()

if(CMAKE_C_COMPILER_ID STREQUAL "SunPro")
  set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -mt")
  set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -mt -library=stlport4")
endif()

if(CMAKE_C_COMPILER_ID STREQUAL "GNU")
  if(CMAKE_SYSTEM_NAME MATCHES AIX)
    set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -g -lpthread")
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -lpthread")
  elseif(CMAKE_SYSTEM_NAME MATCHES "kOpenBSD.*|OpenBSD.*")
    set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -g -pthread")
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -pthread")
    set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -lpthread")
    set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -lpthread")
    set(CMAKE_STATIC_LINKER_FLAGS "${CMAKE_STATIC_LINKER_FLAGS} -lpthread")
  else()
    set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -g -pthread")
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -pthread")
  endif()
endif()

You can see a battery of nested if() conditions for all kinds of platforms and what to do with them. In this specific case, CMake has all of that logic already built-in (I haven’t verified Sun systems and the more exotic environments, but I’m confident). Unless anything breaks, all you should need is the following:

set(THREADS_PREFER_PTHREAD_FLAG TRUE) # Optional
find_package(Threads REQUIRED)
target_link_libraries(icinga-app PRIVATE Threads::Threads)

Note again how we’re not adding any flags manually to anything, we’re creating a target that we can link against to get Thread support enabled. What that means in practice is up to CMake, unless we have good reason to override the behavior.

C/C++ Standard Flags

Another thing you can often see in older projects (thankfully not in Icinga 2) is directly adding the C++ standard as a compiler flag:

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++17")

Which is bad for a number of reasons, but primarily because it’s again platform dependent when it’s completely unnecessary. CMake has the CMAKE_CXX_STANDARD variable, or better yet, a way to set it on a per-target basis (making partial migration to new standards much easier):

target_compile_features(base PUBLIC cxx_std_17)

This would for example allow you to compile one part of the library/executable with C++23, while keeping other legacy parts on C++17. As long as they’re compiled under the same ABI it should generally be safe mixing object files compiled with different standards that way.

Warning and Optimization Flags

And then you have flags like warnings and optimizations. For example, Icinga 2 currently enables Link Time Optimization by adding the -flto flag when the user sets ICINGA2_LTO_BUILD by default:

if(ICINGA2_LTO_BUILD)
  check_cxx_compiler_flag("-flto" CXX_FLAG_LTO)

  if(NOT CXX_FLAG_LTO)
    message(WARNING "Compiler does not support LTO, falling back to non-LTO build")
  else()
    set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -flto")
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -flto")
    set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -flto")
    set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -flto")
  endif()
endif()

This should really be up to the user and/or package maintainer, which is why CMake’s recommendation is to never set flags like these (directly or on targets) in project files and especially not enable these by default.

The proper way of setting these and similar variables in modern CMake is by providing presets (more on that later).

But I absolute must set this flag!

If there is no way talking you out of setting compiler flags in your project files, you should never append them to variables like CMAKE_CXX_FLAGS directly, or globally via add_compile_options, but instead on a per-target basis:

target_compile_options(icinga-app
  PRIVATE
    # Arcane incantations instead of compiler flags for Windows
    /bigobj /GL- /EHs
)
target_link_options(icinga-app
  PRIVATE
    /SAFESEH:NO
)

Set these to PUBLIC instead if you want them to propagate to the targets linking to this one.

Presets

As mentioned above, the proper way to set miscellaneous optional flags for your program in CMake is to provide presets. You can define any number of custom presets for configure, build, testing and even packaging steps inside a CMakePresets.json file in the project root. This can then also be picked up by IDEs to provide an easy selection of the most common build profiles (like one for release and one for debug).

Here’s an example of a preset file:

{
    "version": 10,
    "cmakeMinimumRequired": {
        "major": 3,
        "minor": 17,
        "patch": 0
    },
    "configurePresets": [
        {
            "name": "default",
            "hidden": true,
            "inherits": [ "base" ],
            "environment": {
                "CXXFLAGS" : "-Wall -Wextra -Wno-stringop-overflow"
            }
        },
        {
            "name": "release",
            "inherits": "default",
            "binaryDir": "${sourceDir}/build/release",
            "cacheVariables": {
                "CMAKE_BUILD_TYPE": "RelWithDebInfo"
            }
        },
        {
            "name": "debug",
            "inherits": "default",
            "binaryDir": "${sourceDir}/build/debug",
            "cacheVariables": {
                "CMAKE_BUILD_TYPE": "Debug"
            }
        }
    ],
    "buildPresets": [
        {
            "name": "release",
            "configurePreset": "release"
        },
        {
            "name": "debug",
            "configurePreset": "debug"
        }
    ]
}

 

These would then be invoked with the --preset flag when preparing the build directory, or when building in a single step:

$ cmake --preset debug
$ cmake --build --preset debug

Profiles can be used to specify defaults for all CMake cache variables like the build type above, the generator used (Ninja or Unix Makefiles on Linux), environment variables used during the configure, build and testing runs, default build directories with binaryDir and a lot more.

For example, to build with clang instead of gcc you’d set the CXX environment-variable in a configure preset and inherit from it in :

// ...
    {
        "name": "clang",
        "hidden": true,
        "environment": {
            "CC" : "clang"
            "CXX" : "clang++"
        }
    },
    {
        "name": "debug-clang",
        "inherits": [ "debug", "clang" ],
        "binaryDir": "${sourceDir}/build/debug-clang"
    }
// ...

This also showcases how presets can be combined with inheritance, allowing your non-hidden top-level presets to mix and match between compiler support, flags, platform settings, etc.

Alternatively, you can also specify toolchain files when your toolchain is more complicated than just providing CC/CXX.

Presets can be shown or hidden based on conditions which evaluate a few predefined constants or environment variables.

There is also a lesser known trick to automatically select different versions of the same presets based on the host operating system (or environment variables). The following works because include allows the evaluation of the macros like ${hostSystemName}:

// ...
    "include": [
    "CMake${hostSystemName}Presets.json"
],
"configurePresets": [
    {
        "name": "default",
        "hidden": true,
        "inherits": [ "base" ] // base is defined differntly in each OS-specific files
    }, "base" ] // base is defined differntly in each OS-specific files
},
// ...

For example on my system, this would look for a CMakeLinuxPresets.json file. If the file or the expected presets are missing, cmake will fail with an error, so if you do this, make sure you have a file for each supported system, even if it’s just a stub.

Conclusion

Modern CMake gives you many ways to write clean and readable build-systems. But due to the immense amount of backwards compatibility, it is up to you to maintain discipline and up-to-date knowledge to write it in an idiomatic way.

Personally I’m looking forward to finally cleaning up the Icinga 2 build-system after the upcoming v2.16 release is on its way.

You May Also Like…

 

Icinga Director v1.11.6 Release

Icinga Director v1.11.6 Release

We are happy to announce the release of Icinga Director version 1.11.6. This release addresses several important bug...

Subscribe to our Newsletter

A monthly digest of the latest Icinga news, releases, articles and community topics.