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 configuration

The 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
ApproachReproducibleNotes
find_packageDepends on the hostFast, no download; different machines can get different versions
FetchContentYes, with a tag or commit hashBuilds from source; slower first configure
vcpkg manifestYes, with a baselineBinary caching makes rebuilds fast; integrates via a toolchain file
ConanYes, with a lockfileStrong binary management, more moving parts
Submodule in third_party/YesNo 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-coding lib and bin, so the package works on distributions with a multiarch layout.
  • CMAKE_BUILD_TYPE only applies to single-config generators (Makefiles, Ninja). Visual Studio and Xcode are multi-config and select the type at build time with --config.
  • RelWithDebInfo is usually the right shipping default: optimised code with symbols you can still read in a crash report.
  • Set CMAKE_EXPORT_COMPILE_COMMANDS so 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.

Setting up C++: compilers, CMake and the build workflow C++20 and C++23 in practice

Last refreshed 2026-09-18.