CMake, dependencies and project structure
Lay out a project that scales, fetch dependencies reproducibly, manage build types, and install or export what you build.
A layout that scales
project/
CMakeLists.txt # top level: options, subdirectories, nothing else
cmake/ # helper modules and toolchain files
include/app/ # public headers, installed with the package
src/ # implementation, compiled into a library target
tests/ # unit tests, linked against the library
bench/ # benchmarks, built only in a Bench config
third_party/ # vendored sources when fetching is not allowed
.github/workflows/ # one job per compiler and sanitizer configurationThe single most valuable structural decision is to put all logic in a library target and keep the executable a thin main. Tests and benchmarks then link the same code the product runs, with no need to recompile sources.
# top-level CMakeLists.txt
cmake_minimum_required(VERSION 3.24)
project(app VERSION 1.2.0 LANGUAGES CXX)
option(APP_BUILD_TESTS "Build tests" ON)
option(APP_BUILD_BENCH "Build benchmarks" OFF)
option(APP_WERROR "Warnings as errors" OFF)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # for clang-tidy and editors
# default build type for single-config generators
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "" FORCE)
endif()
add_subdirectory(src)
if(APP_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()Dependencies: find_package, FetchContent, vcpkg
include(FetchContent)
# 1. prefer the system or a package manager when it is already installed
find_package(fmt 10 CONFIG QUIET)
if(NOT fmt_FOUND)
# 2. otherwise pin an exact revision: never track a moving branch
FetchContent_Declare(fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 10.2.1
GIT_SHALLOW TRUE)
set(FMT_INSTALL OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(fmt)
endif()
target_link_libraries(app PRIVATE fmt::fmt)
# 3. for a large dependency graph, a manifest is more maintainable
# vcpkg.json + -DCMAKE_TOOLCHAIN_FILE=.../vcpkg.cmake
# Conan: conanfile.txt and conan install| Approach | Reproducible | Notes |
|---|---|---|
find_package | Depends on the host | Fast, no download; different machines can get different versions |
FetchContent | Yes, with a tag or commit hash | Builds from source; slower first configure |
| vcpkg manifest | Yes, with a baseline | Binary caching makes rebuilds fast; integrates via a toolchain file |
| Conan | Yes, with a lockfile | Strong binary management, more moving parts |
Submodule in third_party/ | Yes | No network at configure time, but bloats your repository |
Pin versions by tag or commit, never by branch. A dependency that silently upgrades between a developer machine and CI turns a reproducible build into a source of intermittent failures.
Install, export and package
# in src/CMakeLists.txt
install(TARGETS app
RUNTIME DESTINATION bin
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib)
install(DIRECTORY ../include/ DESTINATION include)
# a header-only interface library needs its headers and a target export
install(TARGETS core EXPORT appTargets
ARCHIVE DESTINATION lib
INCLUDES DESTINATION include)
install(EXPORT appTargets
FILE appConfig.cmake
NAMESPACE app::
DESTINATION lib/cmake/app)
include(CMakePackageConfigHelpers)
write_basic_package_version_file(
"${CMAKE_CURRENT_BINARY_DIR}/appConfigVersion.cmake"
VERSION ${PROJECT_VERSION} # escaped: expanded at install time
COMPATIBILITY SameMajorVersion)- Use
GNUInstallDirs(CMAKE_INSTALL_LIBDIR,CMAKE_INSTALL_BINDIR) rather than hard-codinglibandbin, so the package works on distributions with a multiarch layout. CMAKE_BUILD_TYPEonly applies to single-config generators (Makefiles, Ninja). Visual Studio and Xcode are multi-config and select the type at build time with--config.RelWithDebInfois usually the right shipping default: optimised code with symbols you can still read in a crash report.- Set
CMAKE_EXPORT_COMPILE_COMMANDSso clangd and clang-tidy know the exact flags for every file.
⚠️
Never run
cmake in the source directory. In-source builds leave CMakeCache.txt and generated files mixed with your sources, and the stale cache is a frequent cause of a build that refuses to pick up a new option.FAQ
Why does a find_package call work locally but fail in CI?
The dependency is installed on your machine and absent from the CI image. Either install it in the image, or make the build fall back to
FetchContent so it works from a clean checkout.How do I speed up a large CMake build?
Build with Ninja rather than Make, enable
ccache or sccache via CMAKE_CXX_COMPILER_LAUNCHER, use unity builds for translation-unit-count-bound projects, and only then consider precompiled headers.Related
Setting up C++: compilers, CMake and the build workflow C++20 and C++23 in practice
Last refreshed 2026-09-18.