Advanced CMake for C++: Multi-Target Projects, External Dependencies and install()

Introduction: builds get complex as projects grow

Projects with several libraries and executables tend to accumulate tangled build settings: include paths added globally “to make it compile”, the same source file listed in three executables, and a dependency that only builds on one developer’s machine. This post assumes the basics from #4 CMake intro and covers what changes at the next size up: splitting code into library targets, pulling in external dependencies, generator expressions, and making a library installable so other projects can find_package it. The examples need CMake 3.15 or later (3.24 for the FIND_PACKAGE_ARGS feature mentioned at the end) and GCC, Clang or MSVC.

The core idea carries through every section: model each piece as a target, attach its requirements to it, and link targets together. CMake targets and PUBLIC/PRIVATE goes deeper into visibility; this article focuses on how the pieces fit together in a real project.


Pain scenarios from real CMake usage

Scenario 1: “Cannot find header”

fatal error: mylib.h: No such file or directory

Cause: the include directory was added with PRIVATE on the library, so the library itself compiles, but targets that link it do not get the path. Or the consumer never linked the library at all and relied on a global include_directories that someone removed. The fix is target_include_directories(mylib PUBLIC include) on the library, and linking it from every target that includes its headers.

Scenario 2: “undefined reference”

Cause: a missing target_link_libraries entry, a function declared in a header whose .cpp was never added to any target, or a static library that depends on another one without saying so. Note what is not the cause: the order of add_subdirectory calls. target_link_libraries(app PRIVATE mylib) may appear before mylib is defined, because CMake resolves target names when it generates the build system, after all directories are processed. If a name is misspelled, it silently becomes -lmylbi and fails only in the linker, which is one reason to use namespaced ALIAS targets (MyProject::mylib), where a typo is a configure-time error.

Scenario 3: Windows-only failures

Cause: missing platform libraries (ws2_32 for sockets), shared libraries built without exported symbols (no .lib import library is produced, so linking fails with LNK1104: cannot open file 'mylib.lib'), or DLLs not found at run time because they are in a different directory from the .exe.

Scenario 4: External library version skew

Cause: find_package finds whatever is installed. Two developers with different Boost versions get different behavior, and CI finds a third. Pin versions, either with a package manager manifest (vcpkg, Conan) or with FetchContent at a fixed tag.

Scenario 5: Slow builds

Cause: the same sources compiled into several targets, heavy headers included everywhere, no precompiled headers, or builds run without parallelism. cmake --build build --parallel (or -j N) uses all cores with Make; Ninja parallelizes by default.

Scenario 6: Generated code not built

Cause: .proto files or other code generators run by hand or with execute_process at configure time, so CMake does not know the generated .pb.cc depends on the .proto. Changes to the input are ignored, or the generated file is missing on a clean build.

Scenario 7: find_package fails after install

Cause: the library was installed without a package configuration file (MyLibConfig.cmake), or the consumer does not know the install prefix. CMAKE_PREFIX_PATH=/path/to/prefix tells find_package where to look.

Scenario 8: CI fetch timeouts

Cause: FetchContent cloning a large repository with full history on every clean CI run. Use GIT_SHALLOW TRUE, or better, download a release archive with URL and URL_HASH, which is smaller and verified.


Problem CMakeLists (anti-pattern)

Listing the same util.cpp and db.cpp in every executable compiles them once per executable, and any change to them rebuilds all three.

# Bad: everything in one place
add_executable(app1 main1.cpp util.cpp db.cpp)
add_executable(app2 main2.cpp util.cpp db.cpp)
add_executable(app3 main3.cpp util.cpp db.cpp)

Better: make the shared code a library and link it.

add_library(util STATIC util.cpp)
add_library(db STATIC db.cpp)
add_executable(app1 main1.cpp)
target_link_libraries(app1 PRIVATE util db)

Beyond build time, the library version gives the shared code one set of compile flags and include paths. With the duplicated sources, nothing stops app2 from compiling db.cpp with a different -D definition than app1, which is a quiet way to get two incompatible versions of the same code.


Project dependency architecture

flowchart TB
    subgraph apps[Executables]
        app1[app1]
        app2[app2]
        app3[app3]
    end
    subgraph libs[Libraries]
        util[util]
        db[db]
    end
    subgraph external[External]
        boost[Boost]
        json[nlohmann_json]
    end
    app1 --> util
    app1 --> db
    app2 --> util
    app2 --> db
    app3 --> util
    app3 --> db
    db --> boost
    app1 --> json

Project structure

Use a root CMakeLists.txt for project-wide settings and add_subdirectory for src, lib, tests and external. Each subdirectory’s CMakeLists.txt defines the targets for the code in that directory.

myproject/
├── CMakeLists.txt
├── src/
│   ├── CMakeLists.txt
│   └── ...
├── lib/
│   └── mylib/
├── tests/
└── external/

Root CMakeLists.txt

cmake_minimum_required(VERSION 3.15)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(NOT CMAKE_BUILD_TYPE)
    set(CMAKE_BUILD_TYPE Release)
endif()
add_subdirectory(lib)
add_subdirectory(src)
add_subdirectory(tests)

The CMAKE_BUILD_TYPE default is useful, because a single-config build with no type set gets neither debug info nor optimization, but it has two limitations. It does nothing for multi-config generators (Visual Studio, Xcode, Ninja Multi-Config), which choose the configuration at build time with --config Release. And a plain set does not show up in the cache, so tools that read the cache report an empty build type. The more complete version checks if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) and uses set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE). When the project is consumed by another project via add_subdirectory or FetchContent, it should not change the parent’s build type at all, which is another reason to guard the block with if(PROJECT_IS_TOP_LEVEL) (CMake 3.21+).


Targets and libraries

  • STATIC: an archive (.a / .lib) whose object files are copied into each executable at link time.
  • SHARED: a .so / .dll / .dylib loaded at run time; one copy on disk and in memory, but deployment and symbol visibility become your problem.
  • INTERFACE: nothing to compile, only usage requirements, for header-only libraries: target_include_directories(mylib INTERFACE include/).

PUBLIC vs PRIVATE vs INTERFACE

PUBLIC on target_link_libraries(B PUBLIC A) means consumers of B also need A, typically because B’s headers include A’s. PRIVATE keeps A internal to B’s implementation.

target_link_libraries(B PUBLIC A)
target_link_libraries(C PRIVATE B)
# C gets A's include paths and definitions automatically through B's PUBLIC edge.

That is all this article needs from visibility. How propagation works through longer chains, when INTERFACE is the right keyword for a compiled library, OBJECT libraries, and the mistakes that leak private dependencies to consumers are in CMake Targets: PUBLIC, PRIVATE and INTERFACE.


Target properties

Use target_include_directories, target_compile_options, target_compile_definitions and target_link_libraries with PUBLIC/PRIVATE/INTERFACE as appropriate, instead of the directory-wide include_directories, add_compile_options and add_definitions.

Generator expressions

$<CONFIG:Debug>, $<BUILD_INTERFACE:...> and $<INSTALL_INTERFACE:...> are evaluated at generate time, not while CMakeLists.txt is being read. That is why they can depend on things that are unknown during configuration, such as the configuration chosen at build time with multi-config generators.

target_compile_definitions(mylib PRIVATE
    $<$<CONFIG:Debug>:DEBUG_MODE>
)
target_include_directories(mylib PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:include>
)

The consequence is that message("${...}") or if() cannot see their values: printing a generator expression prints the expression itself. To debug one, write it into a file with file(GENERATE OUTPUT debug.txt CONTENT "$<TARGET_PROPERTY:mylib,INCLUDE_DIRECTORIES>") and read the file after configuring. The other common trap is spaces: an unquoted $<$<CONFIG:Debug>:-O0 -g> is split into two arguments; quote it and separate items with ;.

The BUILD_INTERFACE/INSTALL_INTERFACE pair exists because a library’s headers live in two places: in the source tree while building, and under the install prefix after cmake --install. An absolute source path baked into an installed package would point to a directory that does not exist on the consumer’s machine, and install(EXPORT) refuses it with an error that the path “is prefixed in the source directory”.


External libraries

find_package

find_package(Boost REQUIRED COMPONENTS system filesystem)
target_link_libraries(myapp PRIVATE Boost::system Boost::filesystem)

find_package looks for an already installed package, either through a <Name>Config.cmake file provided by the package or through a Find<Name>.cmake module. With REQUIRED, a missing package stops configuration immediately with a message listing where it looked, which is better than a link error later. Always link the imported targets (Boost::filesystem) rather than variables like ${Boost_LIBRARIES}; the targets carry include paths and dependencies with them. CMake find_package covers search paths and the common failure messages.

FetchContent

include(FetchContent)
FetchContent_Declare(
    json
    GIT_REPOSITORY https://github.com/nlohmann/json.git
    GIT_TAG v3.11.2
)
FetchContent_MakeAvailable(json)
target_link_libraries(myapp PRIVATE nlohmann_json::nlohmann_json)

FetchContent_MakeAvailable downloads the source at configure time and adds it to your build with add_subdirectory, so the dependency’s targets become ordinary targets in your project. That has side effects worth knowing: the dependency’s own options, tests and warning flags become part of your configure step, and its CMakeLists.txt runs in your scope. Many libraries expose options to turn off their tests and examples (for nlohmann/json, JSON_BuildTests); set them before FetchContent_MakeAvailable, or a fresh configure spends minutes building someone else’s test suite.

Pin GIT_TAG to a release tag or, better, a commit hash. A branch name such as main makes the build depend on whatever was pushed last, and CMake contacts the remote on every configure to check for updates.

find_package first, else FetchContent

Try the system or package-manager copy, and fall back to a pinned download. Since CMake 3.24 this is built in: add FIND_PACKAGE_ARGS to FetchContent_Declare, and FetchContent_MakeAvailable calls find_package first. It gives developers with a package manager fast configures and keeps a fresh checkout buildable anywhere.

ExternalProject

ExternalProject_Add downloads and builds a dependency during the build step, in its own separate CMake (or Make, or Autotools) invocation. Its targets are not visible to your project at configure time, so you cannot simply link them; the usual pattern is a “superbuild” that builds and installs dependencies first and then configures your project against the install prefix. Use it for non-CMake dependencies or ones whose CMake does not work well as a subdirectory. For CMake-based libraries, FetchContent is simpler.

add_custom_command / protobuf

Declare generated files as the OUTPUT of an add_custom_command, with the inputs in DEPENDS, and list the outputs as sources of a target. CMake then runs the generator only when the inputs change and in the right order. For protobuf, protobuf_generate() from the protobuf package does exactly this.


Build options

option(BUILD_TESTS "Build tests" ON)
if(BUILD_TESTS)
    add_subdirectory(tests)
endif()

option() values are cached: once a build directory exists, changing the default in CMakeLists.txt has no effect there. Pass -DBUILD_TESTS=OFF or delete CMakeCache.txt to pick up a new default. For libraries meant to be embedded, defaulting tests to ${PROJECT_IS_TOP_LEVEL} builds them for the library’s own developers and skips them for consumers.

CMAKE_BUILD_TYPE: Debug (-g, no optimization), Release (-O3 -DNDEBUG with GCC/Clang), RelWithDebInfo (-O2 -g -DNDEBUG) and MinSizeRel. RelWithDebInfo is often the right choice for profiling and for production binaries whose crashes you want to debug.

Platform blocks: if(WIN32), elseif(APPLE), elseif(UNIX) for macros and extra libraries (ws2_32, Threads::Threads). Prefer generator expressions such as $<$<PLATFORM_ID:Windows>:ws2_32> when the difference is a single link item, since they keep the condition next to the thing it controls.


Common errors (summary)

IssueFix
Missing headers in a dependent targetPUBLIC on the library’s target_include_directories, and link the library
Undefined referenceAdd the missing target to target_link_libraries; check that the .cpp is in some target
find_package failsSet CMAKE_PREFIX_PATH, use the vcpkg toolchain file, or Conan’s generated toolchain
Multiple definitionNon-inline function or variable defined in a header; make it inline or move it to one .cpp
DLL not found at run timePut DLLs next to the .exe (RUNTIME_OUTPUT_DIRECTORY) or copy them with $<TARGET_RUNTIME_DLLS:app> (CMake 3.21+)
Generator expression errorQuote expressions containing spaces or ;; multi-value compiler checks like $<CXX_COMPILER_ID:GNU,Clang> need CMake 3.15+
ExternalProject rebuilds every time or Ninja cannot find its outputsDeclare the produced files in BUILD_BYPRODUCTS
Custom command never runsIts OUTPUT must be a source of some target, or a dependency of an add_custom_target

Installing a library for find_package

Making a library consumable with find_package(MyLib) takes four pieces, which is why this step is often skipped until someone needs it:

include(GNUInstallDirs)
include(CMakePackageConfigHelpers)

install(TARGETS mylib EXPORT MyLibTargets
        LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
        ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
        RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(EXPORT MyLibTargets NAMESPACE MyLib::
        DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyLib)

configure_package_config_file(cmake/MyLibConfig.cmake.in
        ${CMAKE_CURRENT_BINARY_DIR}/MyLibConfig.cmake
        INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyLib)
write_basic_package_version_file(${CMAKE_CURRENT_BINARY_DIR}/MyLibConfigVersion.cmake
        COMPATIBILITY SameMajorVersion)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/MyLibConfig.cmake
              ${CMAKE_CURRENT_BINARY_DIR}/MyLibConfigVersion.cmake
        DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyLib)

install(TARGETS ... EXPORT) records the target and its usage requirements; install(EXPORT) writes them to MyLibTargets.cmake with the MyLib:: prefix; the config file (usually a template that includes MyLibTargets.cmake and calls find_dependency() for each public dependency) is what find_package actually loads; and the version file lets consumers write find_package(MyLib 1.2). The find_dependency() calls are the part most often forgotten: if mylib links Boost::filesystem publicly, a consumer’s find_package(MyLib) fails with The link interface of target "MyLib::mylib" contains: Boost::filesystem but the target was not found until the config file finds Boost first.

Other patterns worth adopting at this stage:

  • CTest with GoogleTest via FetchContent, and gtest_discover_tests() so each test case appears individually in ctest output.
  • target_precompile_headers (CMake 3.16+) for heavy, rarely changing headers such as the standard library or Qt.
  • CMakePresets.json so that every developer and CI job uses the same generator, build type and cache variables.

Best practices

  • Split shared code into library targets; never list the same source in several targets.
  • Decide PUBLIC vs PRIVATE by asking whether the dependency appears in the target’s public headers.
  • Pin dependency versions: package manager manifests, or FetchContent with a tag or commit hash.
  • Keep platform-specific settings next to the targets they affect.
  • Put tests, examples and sanitizer builds behind options, defaulting to off when the project is not top-level.
  • Add install rules and a package config file before the first external user asks for one.

Build flow (sequence)

sequenceDiagram
    participant Dev as Developer
    participant CMake as CMake configure
    participant Fetch as FetchContent
    participant Build as Build system
    Dev->>CMake: cmake -B build
    CMake->>Fetch: Download external deps
    Fetch->>CMake: Targets created
    CMake->>CMake: Dependency graph
    CMake->>Build: Generate Ninja/Makefile
    Dev->>Build: cmake --build build
    Build->>Build: lib (mylib)
    Build->>Build: src (myapp)
    Build->>Build: tests (optional)

The diagram shows why FetchContent and ExternalProject feel so different: FetchContent works during the configure step, so its targets exist when your CMakeLists.txt links them, while ExternalProject work happens only in the build step.


Which command for which task

TaskCommand
Libraryadd_library
Executableadd_executable
Linktarget_link_libraries
Includestarget_include_directories
Externalfind_package, FetchContent
Optionsoption
Testsenable_testing, add_test
Packaginginstall(TARGETS ... EXPORT), install(EXPORT), config file

Next: Package managers (#17-2)


Frequently Asked Questions (FAQ)

Q. Should I use FetchContent or find_package for a dependency?

A. find_package uses a copy already installed on the system or by a package manager, which keeps configure fast but depends on what version each machine has. FetchContent downloads and builds the dependency at a pinned tag as part of your project, which makes builds reproducible at the cost of longer configure and build times. A common pattern is to try find_package first and fall back to FetchContent, which CMake 3.24 and later support directly via FIND_PACKAGE_ARGS in FetchContent_Declare.