Mastering CMake: A Practical Guide for DevOps Engineers and Developers
CMake is the backbone of modern C++ build systems. Whether you're compiling a simple executable or orchestrating large-scale multi-module projects with external libraries, mastering CMake can make your builds cleaner, faster, and more maintainable. This guide captures everything you need to know to write robust CMakeLists.txt files with real-world examples and explanations. Table of Contents Basic CMake Commands Project Structure Commonly Used Variables Best Practices Compiler Warnings Configuring files Unit Testing with Catch2 Compile Features and Definitions Sanitizers IPO & LTO Generator Expressions External Libraries (Git Submodules & FetchContent) Useful CMake CLI Flags Basic CMake Commands cmake_minimum_required(VERSION 3.10) This command specifies the minimum CMake version required for your project. It should be the first line in your top-level CMakeLists.txt. project(MyApp VERSION 1.0 LANGUAGES CXX) Defines the project name, version, and language. add_executable(MyApp main.cpp helper.cpp) This tells CMake to compile main.cpp and helper.cpp into an executable named MyApp. You can list as many source files as needed. The target name (MyApp) is used in other commands, like target_link_libraries(MyApp PRIVATE SomeLib) add_library(MyLib STATIC mylib.cpp) Defines a library named MyLib. STATIC: Creates a static library (.lib or .a). Linked at compile time. SHARED: Creates a shared (dynamic) library (.dll or .so). Loaded at runtime. MODULE: Creates a library that is not linked but loaded at runtime (e.g., for plugins). INTERFACE: No output file is built. Used for header-only libraries. target_include_directories(MyLib PUBLIC include) Specifies include directories. Types: PUBLIC: Used by both the library and users PRIVATE: Used only by the library INTERFACE: Used only by users add_subdirectory(utils) Includes utils/CMakeLists.txt in the build. target_link_libraries(MyApp PRIVATE MyLib) Links MyLib with MyApp. set(MY_VAR "value") Defines a variable. option(BUILD_TESTS "Build test binaries" ON) Defines a boolean option with description. if(USE_MY_FEATURE) message(STATUS "Building with my feature") else() message(STATUS "Building without my feature") endif() Conditional logic in CMake. Project Structure project-root/ ├── CMakeLists.txt ├── main.cpp ├── include/ │ └── mylib.hpp ├── src/ │ └── mylib.cpp ├── tests/ │ ├── CMakeLists.txt │ └── test_main.cpp Commonly Used Variables CMAKE_SOURCE_DIR: Top-level source directory CMAKE_PROJECT_NAME: Name set in project() CMAKE_BINARY_DIR: Top-level build directory CMAKE_CURRENT_SOURCE_DIR: Source dir of the current CMakeLists.txt CMAKE_CURRENT_LIST_DIR: Directory containing the currently processed CMake file CMAKE_MODULE_PATH: List of additional module directories Best Practices ✅ Always list files manually instead of using file(GLOB ...) ✅ Modularize using add_subdirectory() ✅ Use target_* commands (not global ones) ✅ Separate headers and sources by folder ❌ Don’t pollute global scope with variables Compiler Warnings Use portable warnings with: target_compile_options(MyApp PRIVATE $ $ ) Configuring Files with configure_file CMake’s configure_file() command lets you copy and process a template file at configure time, replacing variable placeholders. For example, if you have a header template file.h.in: // file.h.in #define VERSION @PROJECT_VERSION@ and in your CMakeLists.txt you do: project(MyApp VERSION 1.2.3) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/file.h.in ${CMAKE_CURRENT_BINARY_DIR}/file.h @ONLY) CMake will produce file.h in the build directory with @PROJECT_VERSION@ replaced by the actual version string (e.g. #define VERSION 1.2.3). In general, configure_file() copies a “.in” file to the build tree and substitutes any ${VAR} with the current CMake variable values cliutils.gitlab.io . This is very useful for generating headers (or even .cmake files) that convey build-time settings like version, feature toggles, or compile options. Remember to add the build directory to your include path (e.g. via target_include_directories) so that code can #include "file.h" from the generated location. Unit Testing with Catch2 Setup git submodule add https://github.com/catchorg/Catch2.git external/Catch2 CMakeLists.txt add_subdirectory(external/Catch2) add_executable(tests test_main.cpp) target_link_libraries(tests PRIVATE Catch2::Catch2WithMain) include(CTest) include(Catch) catch_discover_tests(tests) Compile Features and Definitions Enforce Features target_compile_features(MyApp PRIVATE cxx_std_17) Add Macros target_compile_definitions(MyApp PRIVATE VERSION=1.0) Sanitizers target_compile_options(MyApp PRIVATE -fsanitize=address) target_link_options(MyApp PRIVATE -fsanitize=address) Apply similar for undefined san

CMake is the backbone of modern C++ build systems. Whether you're compiling a simple executable or orchestrating large-scale multi-module projects with external libraries, mastering CMake can make your builds cleaner, faster, and more maintainable.
This guide captures everything you need to know to write robust CMakeLists.txt files with real-world examples and explanations.
Table of Contents
Basic CMake Commands
Project Structure
Commonly Used Variables
Best Practices
Compiler Warnings
Configuring files
Unit Testing with Catch2
Compile Features and Definitions
Sanitizers
IPO & LTO
Generator Expressions
External Libraries (Git Submodules & FetchContent)
Useful CMake CLI Flags
Basic CMake Commands
cmake_minimum_required(VERSION 3.10)
This command specifies the minimum CMake version required for your project. It should be the first line in your top-level CMakeLists.txt.
project(MyApp VERSION 1.0 LANGUAGES CXX)
Defines the project name, version, and language.
add_executable(MyApp main.cpp helper.cpp)
This tells CMake to compile main.cpp and helper.cpp into an executable named MyApp. You can list as many source files as needed. The target name (MyApp) is used in other commands, like target_link_libraries(MyApp PRIVATE SomeLib)
add_library(MyLib STATIC mylib.cpp)
Defines a library named MyLib.
STATIC: Creates a static library (.lib or .a). Linked at compile time.
SHARED: Creates a shared (dynamic) library (.dll or .so). Loaded at runtime.
MODULE: Creates a library that is not linked but loaded at runtime (e.g., for plugins).
INTERFACE: No output file is built. Used for header-only libraries.
target_include_directories(MyLib PUBLIC include)
Specifies include directories. Types:
PUBLIC: Used by both the library and users
PRIVATE: Used only by the library
INTERFACE: Used only by users
add_subdirectory(utils)
Includes utils/CMakeLists.txt in the build.
target_link_libraries(MyApp PRIVATE MyLib)
Links MyLib with MyApp.
set(MY_VAR "value")
Defines a variable.
option(BUILD_TESTS "Build test binaries" ON)
Defines a boolean option with description.
if(USE_MY_FEATURE)
message(STATUS "Building with my feature")
else()
message(STATUS "Building without my feature")
endif()
Conditional logic in CMake.
Project Structure
project-root/
├── CMakeLists.txt
├── main.cpp
├── include/
│ └── mylib.hpp
├── src/
│ └── mylib.cpp
├── tests/
│ ├── CMakeLists.txt
│ └── test_main.cpp
Commonly Used Variables
CMAKE_SOURCE_DIR: Top-level source directory
CMAKE_PROJECT_NAME: Name set in project()
CMAKE_BINARY_DIR: Top-level build directory
CMAKE_CURRENT_SOURCE_DIR: Source dir of the current CMakeLists.txt
CMAKE_CURRENT_LIST_DIR: Directory containing the currently processed CMake file
CMAKE_MODULE_PATH: List of additional module directories
Best Practices
✅ Always list files manually instead of using file(GLOB ...)
✅ Modularize using add_subdirectory()
✅ Use target_* commands (not global ones)
✅ Separate headers and sources by folder
❌ Don’t pollute global scope with variables
Compiler Warnings
Use portable warnings with:
target_compile_options(MyApp PRIVATE
$<$:-Wall -Wextra -Wpedantic>
$<$:/W4>
)
Configuring Files with configure_file
CMake’s configure_file() command lets you copy and process a template file at configure time, replacing variable placeholders. For example, if you have a header template file.h.in:
// file.h.in
#define VERSION @PROJECT_VERSION@
and in your CMakeLists.txt you do:
project(MyApp VERSION 1.2.3)
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/file.h.in
${CMAKE_CURRENT_BINARY_DIR}/file.h
@ONLY)
CMake will produce file.h in the build directory with @PROJECT_VERSION@ replaced by the actual version string (e.g. #define VERSION 1.2.3). In general, configure_file() copies a “.in” file to the build tree and substitutes any ${VAR} with the current CMake variable values
cliutils.gitlab.io
. This is very useful for generating headers (or even .cmake files) that convey build-time settings like version, feature toggles, or compile options. Remember to add the build directory to your include path (e.g. via target_include_directories) so that code can #include "file.h" from the generated location.
Unit Testing with Catch2
Setup
git submodule add https://github.com/catchorg/Catch2.git external/Catch2
CMakeLists.txt
add_subdirectory(external/Catch2)
add_executable(tests test_main.cpp)
target_link_libraries(tests PRIVATE Catch2::Catch2WithMain)
include(CTest)
include(Catch)
catch_discover_tests(tests)
Compile Features and Definitions
Enforce Features
target_compile_features(MyApp PRIVATE cxx_std_17)
Add Macros
target_compile_definitions(MyApp PRIVATE VERSION=1.0)
Sanitizers
target_compile_options(MyApp PRIVATE -fsanitize=address)
target_link_options(MyApp PRIVATE -fsanitize=address)
Apply similar for undefined sanitizer.
IPO & LTO
Enable link-time optimization:
set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE)
Or per target:
set_property(TARGET MyApp PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE)
Generator Expressions
Inline conditional logic:
$<$
External Libraries
Git Submodules
Add submodule: git submodule add
In root CMake:
add_subdirectory(external/LibName)
target_link_libraries(MyApp PRIVATE LibName)
FetchContent
include(FetchContent)
FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG master
)
FetchContent_MakeAvailable(fmt)
target_link_libraries(MyApp PRIVATE fmt::fmt)
Useful CMake CLI Flags
cmake -S . -B build/ -G "Ninja"
-S: Source directory
-B: Build directory
-G: Generator
cmake --build build/ --target MyApp
Builds only MyApp
cmake --build . --target extLib
Build external target
Dependency Graph
Use Graphviz to visualize target dependencies:
cmake --graphviz=graph.dot .
dot -Tpng graph.dot -o graph.png
Conclusion
Mastering CMake is about understanding its modular philosophy. Start with small clean builds, add subdirectories and targets gradually, and leverage the power of target_ commands for scalability and control.