Table of Contents

  1. ExternalProject_Add Basics
  2. Download Methods
  3. Configure Arguments
  4. Build Steps
  5. Install and Test Steps
  6. Step Targets
  7. The Superbuild Pattern
  8. Passing Information Between Projects
  9. FetchContent vs ExternalProject
  10. Complete Superbuild Example
Back to CMake Mastery Series

Part 11: ExternalProject and Super Builds

June 4, 2026 Wasil Zafar 45 min read

Master ExternalProject_Add for building third-party dependencies from source and learn the superbuild pattern for orchestrating complex multi-project builds entirely within CMake.

ExternalProject_Add Basics

While FetchContent (covered in Part 10) integrates dependencies into your build tree at configure time, ExternalProject takes a fundamentally different approach — it downloads, configures, builds, and installs dependencies as separate build steps during your project's build phase. This makes it ideal for dependencies that don't use CMake or require isolation from your main build.

Key Insight: ExternalProject operates at build time, not configure time. This means the external project's targets, variables, and properties are not available in your CMakeLists.txt — only its installed artifacts (headers, libraries) become accessible after the build step completes.

Including the Module and Basic Usage

ExternalProject is a standard CMake module that must be explicitly included:

cmake_minimum_required(VERSION 3.24)
project(MyProject LANGUAGES CXX)

# Include the ExternalProject module
include(ExternalProject)

# Add an external project — minimal example
ExternalProject_Add(json_ext
    GIT_REPOSITORY  https://github.com/nlohmann/json.git
    GIT_TAG         v3.11.3
    CMAKE_ARGS
        -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/deps
        -DJSON_BuildTests=OFF
)

Every ExternalProject_Add call creates a custom target (here json_ext) that orchestrates a multi-step pipeline: download → update → patch → configure → build → install → test. Each step is a separate command that runs sequentially during the build phase.

cmake_minimum_required(VERSION 3.24)
project(SuperBuildDemo LANGUAGES CXX)

include(ExternalProject)

# Define where dependencies will be installed
set(DEPS_PREFIX ${CMAKE_BINARY_DIR}/deps_install)

# Build zlib from source with full step control
ExternalProject_Add(zlib_ext
    GIT_REPOSITORY  https://github.com/madler/zlib.git
    GIT_TAG         v1.3.1
    # Configure step
    CMAKE_ARGS
        -DCMAKE_INSTALL_PREFIX=${DEPS_PREFIX}
        -DCMAKE_BUILD_TYPE=Release
    # Optionally skip the test step
    TEST_COMMAND     ""
)

Reference: CMake ExternalProject documentation.

Download Methods

ExternalProject supports multiple source acquisition strategies. The download method you choose depends on where the source lives and how you want to track versions.

GIT_REPOSITORY

The most common approach — clone a Git repository at a specific tag, branch, or commit:

include(ExternalProject)

# Clone from Git with shallow clone for speed
ExternalProject_Add(spdlog_ext
    GIT_REPOSITORY  https://github.com/gabime/spdlog.git
    GIT_TAG         v1.13.0
    GIT_SHALLOW     ON          # Only fetch the specified tag (faster)
    GIT_PROGRESS    ON          # Show clone progress
    CMAKE_ARGS
        -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/deps
        -DSPDLOG_BUILD_EXAMPLE=OFF
        -DSPDLOG_BUILD_TESTS=OFF
)
Best Practice: Always specify GIT_TAG with a specific tag or commit hash — never use branch names like main or master. Branch names cause non-reproducible builds since the content changes over time.

URL Download

Download a source archive from a URL with hash verification:

include(ExternalProject)

# Download a tarball with integrity check
ExternalProject_Add(boost_ext
    URL         https://boostorg.jfrog.io/artifactory/main/release/1.84.0/source/boost_1_84_0.tar.gz
    URL_HASH    SHA256=cc4b893acf645c9d4b698e9a0f08ca8846aa5d6c68275c14c3e7c949c67983d3
    # Non-CMake project: override configure/build
    CONFIGURE_COMMAND ./bootstrap.sh --prefix=${CMAKE_BINARY_DIR}/deps
    BUILD_COMMAND     ./b2 install --with-filesystem --with-system
    BUILD_IN_SOURCE   ON
    INSTALL_COMMAND   ""
)

Local Source Directories

Use a directory already on disk — useful for monorepos or vendored dependencies:

include(ExternalProject)

# Use a local source directory
ExternalProject_Add(mylib_ext
    SOURCE_DIR      ${CMAKE_SOURCE_DIR}/third_party/mylib
    CMAKE_ARGS
        -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/deps
    # Disable download/update since source is local
    DOWNLOAD_COMMAND ""
    UPDATE_COMMAND   ""
)

Configure Arguments

CMAKE_ARGS

Pass options to the external project's CMake configure step. These become -D definitions on the command line:

include(ExternalProject)

ExternalProject_Add(fmt_ext
    GIT_REPOSITORY  https://github.com/fmtlib/fmt.git
    GIT_TAG         10.2.1
    CMAKE_ARGS
        -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/deps
        -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
        -DFMT_DOC=OFF
        -DFMT_TEST=OFF
        -DFMT_INSTALL=ON
        -DCMAKE_POSITION_INDEPENDENT_CODE=ON
        -DCMAKE_C_COMPILER=${CMAKE_C_COMPILER}
        -DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}
)

CMAKE_CACHE_ARGS

For arguments containing semicolons (which CMake interprets as list separators), use CMAKE_CACHE_ARGS which writes directly to the cache file:

include(ExternalProject)

ExternalProject_Add(opencv_ext
    GIT_REPOSITORY  https://github.com/opencv/opencv.git
    GIT_TAG         4.9.0
    # CMAKE_CACHE_ARGS handles semicolons in values correctly
    CMAKE_CACHE_ARGS
        -DCMAKE_INSTALL_PREFIX:PATH=${CMAKE_BINARY_DIR}/deps
        -DBUILD_LIST:STRING=core;imgproc;highgui
        -DWITH_CUDA:BOOL=OFF
        -DWITH_FFMPEG:BOOL=OFF
        -DBUILD_TESTS:BOOL=OFF
        -DBUILD_PERF_TESTS:BOOL=OFF
        -DBUILD_EXAMPLES:BOOL=OFF
)

Build Steps

BUILD_COMMAND and BUILD_IN_SOURCE

Override the default build command for non-CMake projects or special build requirements:

include(ExternalProject)

# Build a project that uses autotools (configure/make)
ExternalProject_Add(openssl_ext
    URL             https://www.openssl.org/source/openssl-3.2.1.tar.gz
    URL_HASH        SHA256=83c7329fe52c850677d75e5d0b0ca245309b97e8ecbcfdc1dfdc4ab9fac35b39
    BUILD_IN_SOURCE ON
    CONFIGURE_COMMAND ./config --prefix=${CMAKE_BINARY_DIR}/deps
                      --openssldir=${CMAKE_BINARY_DIR}/deps/ssl
                      no-shared
    BUILD_COMMAND     make -j4
    INSTALL_COMMAND   make install_sw
    TEST_COMMAND      ""
)

BUILD_BYPRODUCTS

When using Ninja generator, you must declare output files so Ninja knows what the external project produces. Without this, Ninja cannot establish proper dependency chains:

include(ExternalProject)

set(ZLIB_INSTALL_DIR ${CMAKE_BINARY_DIR}/deps)

ExternalProject_Add(zlib_ext
    GIT_REPOSITORY  https://github.com/madler/zlib.git
    GIT_TAG         v1.3.1
    CMAKE_ARGS
        -DCMAKE_INSTALL_PREFIX=${ZLIB_INSTALL_DIR}
    # Declare what files this build produces (required for Ninja)
    BUILD_BYPRODUCTS
        ${ZLIB_INSTALL_DIR}/lib/libz.a
        ${ZLIB_INSTALL_DIR}/lib/${CMAKE_STATIC_LIBRARY_PREFIX}z${CMAKE_STATIC_LIBRARY_SUFFIX}
)

# Create an imported target that depends on the external project
add_library(ZLIB::ZLIB STATIC IMPORTED GLOBAL)
set_target_properties(ZLIB::ZLIB PROPERTIES
    IMPORTED_LOCATION ${ZLIB_INSTALL_DIR}/lib/libz.a
    INTERFACE_INCLUDE_DIRECTORIES ${ZLIB_INSTALL_DIR}/include
)
add_dependencies(ZLIB::ZLIB zlib_ext)

Install and Test Steps

Control what happens after the build completes:

include(ExternalProject)

ExternalProject_Add(googletest_ext
    GIT_REPOSITORY  https://github.com/google/googletest.git
    GIT_TAG         v1.14.0
    CMAKE_ARGS
        -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/deps
        -DBUILD_GMOCK=ON
        -DINSTALL_GTEST=ON
    # Custom install: only install Release config on multi-config generators
    INSTALL_COMMAND ${CMAKE_COMMAND} --build . --target install --config Release
    # Run GTest's own test suite after building
    TEST_COMMAND    ${CMAKE_CTEST_COMMAND} --output-on-failure
    # Alternatively, skip testing entirely:
    # TEST_COMMAND ""
)

You can also add arbitrary commands at any step:

include(ExternalProject)

ExternalProject_Add(mylib_ext
    GIT_REPOSITORY  https://github.com/example/mylib.git
    GIT_TAG         v2.0.0
    CMAKE_ARGS      -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/deps
    # Add a custom patch step between download and configure
    PATCH_COMMAND   ${CMAKE_COMMAND} -E copy
                    ${CMAKE_SOURCE_DIR}/patches/mylib_fix.patch
                    /fix.patch
    # Run a custom command after install
    TEST_COMMAND    ${CMAKE_COMMAND} -E echo "mylib_ext installed successfully"
)

Step Targets

By default, ExternalProject creates a single target that runs all steps. You can expose individual steps as separate targets for finer-grained control:

include(ExternalProject)

ExternalProject_Add(heavy_dep
    GIT_REPOSITORY  https://github.com/example/heavy.git
    GIT_TAG         v5.0.0
    CMAKE_ARGS      -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/deps
)

# Expose individual steps as targets
ExternalProject_Add_StepTargets(heavy_dep
    download
    configure
    build
    install
)

# Now you can reference: heavy_dep-download, heavy_dep-configure, etc.
# Useful for CI: build just the download step to cache source
# cmake --build . --target heavy_dep-download

You can also add entirely custom steps that execute between built-in steps:

include(ExternalProject)

ExternalProject_Add(protobuf_ext
    GIT_REPOSITORY  https://github.com/protocolbuffers/protobuf.git
    GIT_TAG         v25.3
    CMAKE_ARGS      -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/deps
                    -Dprotobuf_BUILD_TESTS=OFF
)

# Add a custom step that runs after install
ExternalProject_Add_Step(protobuf_ext verify
    COMMENT         "Verifying protoc installation..."
    COMMAND         ${CMAKE_BINARY_DIR}/deps/bin/protoc --version
    DEPENDEES       install    # Run after install step
    DEPENDERS       ""         # No steps depend on this
    ALWAYS          OFF
)
ExternalProject Step Execution Flow
        flowchart TD
            A[mkdir] --> B[download]
            B --> C[update]
            C --> D[patch]
            D --> E[configure]
            E --> F[build]
            F --> G[install]
            G --> H[test]
            D -.->|Custom Step| I[verify_patch]
            I -.-> E
            G -.->|Custom Step| J[post_install]
            style A fill:#f8f9fa,stroke:#3B9797
            style B fill:#f8f9fa,stroke:#3B9797
            style C fill:#f8f9fa,stroke:#3B9797
            style D fill:#f8f9fa,stroke:#3B9797
            style E fill:#f8f9fa,stroke:#16476A
            style F fill:#f8f9fa,stroke:#16476A
            style G fill:#f8f9fa,stroke:#16476A
            style H fill:#f8f9fa,stroke:#16476A
            style I fill:#fff3cd,stroke:#BF092F
            style J fill:#fff3cd,stroke:#BF092F
    

The Superbuild Pattern

The superbuild pattern is a widely-adopted CMake architecture that solves a fundamental problem: how do you build your project when its dependencies aren't installed on the system and you need to build them from source?

Two-Level CMake Architecture

A superbuild uses two levels of CMake configuration:

  1. Outer project (superbuild) — Uses ExternalProject_Add to build all dependencies AND the main project itself
  2. Inner project (your application) — A normal CMakeLists.txt that uses find_package to locate pre-installed dependencies
Superbuild Architecture: Outer and Inner Projects
        flowchart TB
            subgraph Outer["Outer Project (superbuild/CMakeLists.txt)"]
                A[ExternalProject: zlib] --> D[ExternalProject: main_project]
                B[ExternalProject: libpng] --> D
                C[ExternalProject: spdlog] --> D
            end
            subgraph Inner["Inner Project (src/CMakeLists.txt)"]
                E[find_package zlib]
                F[find_package PNG]
                G[find_package spdlog]
                H[add_executable MyApp]
                E --> H
                F --> H
                G --> H
            end
            D -->|"CMAKE_PREFIX_PATH=deps/"| Inner
            style Outer fill:#f0f7f7,stroke:#3B9797
            style Inner fill:#f0f4f9,stroke:#16476A
    

Outer vs Inner Project

Pattern Superbuild Directory Layout

A typical superbuild project has this structure:

my_project/
├── CMakeLists.txt          # Outer: superbuild that builds deps + inner
├── superbuild/
│   └── CMakeLists.txt      # Alternative: separate superbuild file
├── src/
│   ├── CMakeLists.txt      # Inner: normal project with find_package
│   ├── main.cpp
│   └── app/
│       ├── CMakeLists.txt
│       └── app.cpp
└── cmake/
    └── Superbuild.cmake    # Or keep superbuild logic in a module

The outer project treats your own source as just another ExternalProject_Add call, passing the install prefix of all dependencies via CMAKE_PREFIX_PATH.

architecture superbuild project-layout
# Outer CMakeLists.txt (the superbuild)
cmake_minimum_required(VERSION 3.24)
project(MyProjectSuper LANGUAGES NONE)  # NONE: no compilation in outer

include(ExternalProject)

set(DEPS_PREFIX ${CMAKE_BINARY_DIR}/deps_install)

# Build zlib
ExternalProject_Add(zlib_ext
    GIT_REPOSITORY  https://github.com/madler/zlib.git
    GIT_TAG         v1.3.1
    CMAKE_ARGS
        -DCMAKE_INSTALL_PREFIX=${DEPS_PREFIX}
        -DCMAKE_BUILD_TYPE=Release
)

# Build libpng (depends on zlib)
ExternalProject_Add(libpng_ext
    GIT_REPOSITORY  https://github.com/glennrp/libpng.git
    GIT_TAG         v1.6.43
    CMAKE_ARGS
        -DCMAKE_INSTALL_PREFIX=${DEPS_PREFIX}
        -DCMAKE_PREFIX_PATH=${DEPS_PREFIX}
        -DCMAKE_BUILD_TYPE=Release
        -DPNG_TESTS=OFF
    DEPENDS zlib_ext  # Ensure zlib is built first
)

# Build the actual project (depends on all libraries)
ExternalProject_Add(main_project
    SOURCE_DIR      ${CMAKE_SOURCE_DIR}/src
    CMAKE_ARGS
        -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/install
        -DCMAKE_PREFIX_PATH=${DEPS_PREFIX}
        -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
    DEPENDS zlib_ext libpng_ext
    # Don't download — source is local
    DOWNLOAD_COMMAND ""
    UPDATE_COMMAND   ""
)

Passing Information Between Projects

Since ExternalProject operates at build time, you cannot directly access variables or targets from external projects. Use ExternalProject_Get_Property to retrieve directory paths:

include(ExternalProject)

ExternalProject_Add(spdlog_ext
    GIT_REPOSITORY  https://github.com/gabime/spdlog.git
    GIT_TAG         v1.13.0
    CMAKE_ARGS      -DCMAKE_INSTALL_PREFIX=
)

# Retrieve properties of the external project
ExternalProject_Get_Property(spdlog_ext
    SOURCE_DIR
    BINARY_DIR
    INSTALL_DIR
)

message(STATUS "spdlog source:  ${SOURCE_DIR}")
message(STATUS "spdlog build:   ${BINARY_DIR}")
message(STATUS "spdlog install: ${INSTALL_DIR}")

# Use INSTALL_DIR to create imported targets
add_library(spdlog::spdlog INTERFACE IMPORTED)
set_target_properties(spdlog::spdlog PROPERTIES
    INTERFACE_INCLUDE_DIRECTORIES "${INSTALL_DIR}/include"
)
add_dependencies(spdlog::spdlog spdlog_ext)
Technique Imported Targets for External Libraries

The IMPORTED target pattern bridges the gap between ExternalProject (build-time) and your targets (configure-time). By creating an imported library that references the future install location and adding a dependency on the external project target, CMake ensures correct build ordering.

include(ExternalProject)

set(FMT_INSTALL_DIR ${CMAKE_BINARY_DIR}/fmt_install)

ExternalProject_Add(fmt_ext
    GIT_REPOSITORY https://github.com/fmtlib/fmt.git
    GIT_TAG        10.2.1
    CMAKE_ARGS     -DCMAKE_INSTALL_PREFIX=${FMT_INSTALL_DIR}
                   -DFMT_TEST=OFF
    BUILD_BYPRODUCTS ${FMT_INSTALL_DIR}/lib/libfmt.a
)

# Create imported target pointing to future install location
add_library(fmt::fmt STATIC IMPORTED GLOBAL)
set_target_properties(fmt::fmt PROPERTIES
    IMPORTED_LOCATION "${FMT_INSTALL_DIR}/lib/libfmt.a"
    INTERFACE_INCLUDE_DIRECTORIES "${FMT_INSTALL_DIR}/include"
)
add_dependencies(fmt::fmt fmt_ext)

# Now use it like any other target
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE fmt::fmt)
imported-targets dependency-bridge

FetchContent vs ExternalProject

Both modules manage external dependencies, but they work at fundamentally different phases of the build:

Feature FetchContent (Part 10) ExternalProject (Part 11)
When it runs Configure time Build time
Target visibility Full — all targets available in your CMakeLists.txt None — only installed artifacts visible
Non-CMake projects Difficult (requires wrapper) Native support (custom configure/build commands)
Build isolation Shares your build tree (can cause conflicts) Fully isolated build directory per project
Configure speed Slows configure (downloads at configure time) Fast configure (downloads at build time)
Use case CMake-based libraries you want tight integration with Isolation, non-CMake deps, superbuild architecture
When to Choose ExternalProject: Use ExternalProject when (1) the dependency doesn't use CMake, (2) you need complete build isolation, (3) you're implementing a superbuild that pre-installs everything, or (4) the dependency's build system conflicts with yours. For CMake-native dependencies you want to use directly, prefer FetchContent.

Complete Superbuild Example

Here's a real-world superbuild that builds zlib, libpng, and a main application that uses both:

Complete Example Multi-Dependency Superbuild
# superbuild/CMakeLists.txt
cmake_minimum_required(VERSION 3.24)
project(ImageProcessorSuper LANGUAGES NONE)

include(ExternalProject)

# Common settings
set(EP_PREFIX     ${CMAKE_BINARY_DIR}/ep)
set(INSTALL_DIR   ${CMAKE_BINARY_DIR}/install)
set(BUILD_TYPE    Release)

set(COMMON_CMAKE_ARGS
    -DCMAKE_BUILD_TYPE=${BUILD_TYPE}
    -DCMAKE_INSTALL_PREFIX=${INSTALL_DIR}
    -DCMAKE_PREFIX_PATH=${INSTALL_DIR}
)

# ─── Dependency 1: zlib ───────────────────────────────────────
ExternalProject_Add(ep_zlib
    PREFIX          ${EP_PREFIX}/zlib
    GIT_REPOSITORY  https://github.com/madler/zlib.git
    GIT_TAG         v1.3.1
    GIT_SHALLOW     ON
    CMAKE_ARGS      ${COMMON_CMAKE_ARGS}
    TEST_COMMAND    ""
)

# ─── Dependency 2: libpng (depends on zlib) ───────────────────
ExternalProject_Add(ep_libpng
    PREFIX          ${EP_PREFIX}/libpng
    GIT_REPOSITORY  https://github.com/glennrp/libpng.git
    GIT_TAG         v1.6.43
    GIT_SHALLOW     ON
    CMAKE_ARGS      ${COMMON_CMAKE_ARGS}
                    -DPNG_TESTS=OFF
                    -DPNG_SHARED=OFF
    DEPENDS         ep_zlib
    TEST_COMMAND    ""
)

# ─── Dependency 3: spdlog (standalone) ────────────────────────
ExternalProject_Add(ep_spdlog
    PREFIX          ${EP_PREFIX}/spdlog
    GIT_REPOSITORY  https://github.com/gabime/spdlog.git
    GIT_TAG         v1.13.0
    GIT_SHALLOW     ON
    CMAKE_ARGS      ${COMMON_CMAKE_ARGS}
                    -DSPDLOG_BUILD_EXAMPLE=OFF
                    -DSPDLOG_BUILD_TESTS=OFF
    TEST_COMMAND    ""
)

# ─── Main project (depends on all above) ─────────────────────
ExternalProject_Add(ep_main
    PREFIX          ${EP_PREFIX}/main
    SOURCE_DIR      ${CMAKE_SOURCE_DIR}/../src
    CMAKE_ARGS
        -DCMAKE_BUILD_TYPE=${BUILD_TYPE}
        -DCMAKE_PREFIX_PATH=${INSTALL_DIR}
        -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/app_install
    DEPENDS         ep_zlib ep_libpng ep_spdlog
    DOWNLOAD_COMMAND ""
    UPDATE_COMMAND   ""
)
# Build the superbuild
mkdir build && cd build
cmake ../superbuild
cmake --build . -j$(nproc)

# The application is now at build/app_install/bin/image_processor
superbuild zlib libpng production-pattern
Official Reference: See the full ExternalProject module documentation for all available options, including SVN support, patch commands, and log control.