diff --git a/changelogs/unreleased/config-conditional-sections.md b/changelogs/unreleased/config-conditional-sections.md
new file mode 100644
index 0000000000000000000000000000000000000000..019bcf244ef8171e214b0825af707e6a748f158d
--- /dev/null
+++ b/changelogs/unreleased/config-conditional-sections.md
@@ -0,0 +1,3 @@
+## feature/config
+
+* For upgrading purposes, conditional sections are now supported (gh-9452).
diff --git a/doc/examples/config/upgrade.yaml b/doc/examples/config/upgrade.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..430799b3550daa61d4f17b27e97177cc6a1089b5
--- /dev/null
+++ b/doc/examples/config/upgrade.yaml
@@ -0,0 +1,24 @@
+conditional:
+- if: tarantool_version >= 3.99.0 && tarantool_version < 4.0.0
+  # This section shouldn't be validated and shouldn't be applied.
+  replication:
+    new_option: foo
+- if: tarantool_version < 3.99.0
+  # This section is to be applied.
+  process:
+    title: '{{ instance_name }} -- in upgrade'
+
+credentials:
+  users:
+    guest:
+      roles: [super]
+
+iproto:
+  listen: 'unix/:./{{ instance_name }}.iproto'
+
+groups:
+  group-001:
+    replicasets:
+      replicaset-001:
+        instances:
+          instance-001: {}
diff --git a/src/box/lua/config/cluster_config.lua b/src/box/lua/config/cluster_config.lua
index ed76367815de7c64099a647c263cd51b417f0340..5fa4397da5ba756593c2c0c7fcd1ad7d439777c6 100644
--- a/src/box/lua/config/cluster_config.lua
+++ b/src/box/lua/config/cluster_config.lua
@@ -1,7 +1,26 @@
 local schema = require('internal.config.utils.schema')
 local instance_config = require('internal.config.instance_config')
+local expression = require('internal.config.utils.expression')
 
-local function find_instance(_schema, data, instance_name)
+-- Extract a field from a table.
+--
+-- f({a = 1, b = 2}, 'a') -> {b = 2}, 1
+--
+-- The original table is not modified, but copied if necessary.
+local function table_extract_field(t, field_name)
+    local field_value = t[field_name]
+    if field_value == nil then
+        return t
+    end
+    t = table.copy(t)
+    t[field_name] = nil
+    return t, field_value
+end
+
+-- Cluster config methods.
+local methods = {}
+
+function methods.find_instance(_self, data, instance_name)
     -- Find group, replicaset, instance configuration for the
     -- given instance.
     local groups = data.groups or {}
@@ -24,13 +43,13 @@ local function find_instance(_schema, data, instance_name)
     return nil
 end
 
-local function instantiate(_schema, data, instance_name)
+function methods.instantiate(self, data, instance_name)
     -- No topology information provided.
     if data.groups == nil then
         return data
     end
 
-    local found = find_instance(nil, data, instance_name)
+    local found = self:find_instance(data, instance_name)
 
     if found == nil then
         local res = table.copy(data)
@@ -120,7 +139,7 @@ end
 -- Validate instance_name, replicaset_name, group_name.
 --
 -- Please, keep it is-sync with src/box/node_name.[ch].
-local function validate_name(_self, name)
+function methods.validate_name(_self, name)
     local NODE_NAME_LEN_MAX = 63
     if #name == 0 then
         return false, 'Zero length name is forbidden'
@@ -145,14 +164,31 @@ end
 local name = schema.scalar({
     type = 'string',
     validate = function(name, w)
-        local ok, err = validate_name(nil, name)
+        local ok, err = methods.validate_name(nil, name)
         if not ok then
             w.error(err)
         end
     end,
 })
 
-return schema.new('cluster_config', record_from_fields({
+-- Nested cluster config. It is an auxiliary schema.
+--
+-- The cluster config has the following structure.
+--
+-- {
+--     conditional = array-of(*nested_cluster_config + if),
+--     *nested_cluster_config,
+-- }
+--
+-- Note 1: The asterisk denotes unpacking all the fields into the
+-- parent schema node.
+--
+-- Note 2: The nested cluster config in the conditional section is
+-- represented as an arbitrary map in the schema, but validatated
+-- against the nested cluster config schema before merging into
+-- the main config.
+local schema_name = 'nested_cluster_config'
+local nested_cluster_config = schema.new(schema_name, record_from_fields({
     instance_config_with_scope('global'),
     groups = schema.map({
         key = name,
@@ -172,10 +208,122 @@ return schema.new('cluster_config', record_from_fields({
             }),
         }),
     }),
+}))
+
+-- {{{ Support conditional sections
+
+local conditional_vars = {
+    tarantool_version = _TARANTOOL:match('^%d+%.%d+%.%d+'),
+}
+assert(conditional_vars.tarantool_version ~= nil)
+
+-- Merge conditional sections into the main config.
+--
+-- Accepts a cluster configuration data with conditional sections
+-- as an input and returns the data with the sections removed and
+-- applied to the main config.
+--
+-- Conditional sections that don't fit the `if` clause criteria
+-- are skipped.
+--
+--  | conditional:
+--  | - aaa: {bbb: 1}
+--  |   if: tarantool_version >= 3.0.0
+--  | - ccc: {ddd: 2}
+--  |   if: tarantool_version < 1.0.0
+--  | eee: {fff: 3}
+--
+-- ->
+--
+--  | aaa: {bbb: 1}
+--  | # no ccc
+--  | eee: {fff: 3}
+function methods.apply_conditional(_self, data)
+    if data == nil then
+        return data
+    end
+    local data, conditional = table_extract_field(data, 'conditional')
+    if conditional == nil then
+        return data
+    end
+
+    -- Look over the conditional sections and if the predicate in
+    -- `if` field evaluates to `true`, merge the section in the
+    -- data.
+    local res = data
+    for _, conditional_config in ipairs(conditional) do
+        local config, expr = table_extract_field(conditional_config, 'if')
+        if expression.eval(expr, conditional_vars) then
+            -- NB: The nested configuration doesn't allow a
+            -- presence of the 'conditional' field. Use the
+            -- corresponding schema to perform the merge: nested
+            -- cluster config.
+            --
+            -- It is also important that we use this schema
+            -- instead of a map of 'any' values to perform a deep
+            -- merge, not just replace fields at the top level.
+            --
+            -- NB: The section is already validated in
+            -- validate_conditional().
+            res = nested_cluster_config:merge(res, config)
+        end
+    end
+    return res
+end
+
+-- Verify conditional sections, whose `if` predicate evaluates to
+-- `true`.
+--
+-- Also, verify that the predicate is present in each of such
+-- sections and has correct expression.
+local function validate_conditional(data, w)
+    -- Each conditional section should have 'if' key.
+    local data, expr = table_extract_field(data, 'if')
+    if expr == nil then
+        w.error('A conditional section should have field "if"')
+    end
+
+    -- Fail the validation if the expression is incorrect.
+    local ok, res = pcall(expression.eval, expr, conditional_vars)
+    if not ok then
+        w.error(res)
+    end
+
+    -- Fail the validation if this conditional section is to be
+    -- applied, but it doesn't fit the schema.
+    --
+    -- NB: This validation doesn't accept 'conditional' field, so
+    -- the appropriate schema is used for the validation (nested
+    -- cluster config).
+    if res then
+        nested_cluster_config:validate(data)
+    end
+end
+
+return schema.new('cluster_config', record_from_fields({
+    conditional = schema.array({
+        items = schema.map({
+            -- This map represents a cluster config with
+            -- additional 'if' key.
+            --
+            -- However, it is not necessarily represents a cluster
+            -- config of the given tarantool version: it may be
+            -- older or newer and so it may contain fields that
+            -- are unknown from our point of view.
+            --
+            -- It is why the mapping type is used here: we allow
+            -- arbitrary data and valitate only those items that
+            -- are going to be merged into the main config
+            -- (i.e. the validation is performed after the version
+            -- comparison).
+            key = schema.scalar({type = 'string'}),
+            value = schema.scalar({type = 'any'}),
+            validate = validate_conditional,
+        })
+    }),
+    nested_cluster_config.schema,
 }), {
-    methods = {
-        instantiate = instantiate,
-        find_instance = find_instance,
-        validate_name = validate_name,
-    },
+    methods = methods,
 })
+
+-- }}} Support conditional sections
diff --git a/src/box/lua/config/init.lua b/src/box/lua/config/init.lua
index 3610d03d75b5c1519a3a59baf43155e837e2910e..4b42d7cc3fd28b96d9ca9c164e57ecb0d08b10b6 100644
--- a/src/box/lua/config/init.lua
+++ b/src/box/lua/config/init.lua
@@ -224,6 +224,26 @@ function methods._collect(self, opts)
         local source_iconfig
         if source.type == 'cluster' then
             local source_cconfig = source:get()
+
+            -- Extract and merge conditional sections into the
+            -- data from the source.
+            --
+            -- It is important to call :apply_conditional() before
+            -- :merge() for each cluster config.
+            --
+            -- The 'conditional' field is an array and if several
+            -- sources have the field, the last one replaces all
+            -- the previous ones. If we merge all the configs and
+            -- then call :apply_conditional(), we loss all the
+            -- conditional sections except the last one.
+            --
+            -- The same is applicable for config sources that
+            -- construct a config from several separately stored
+            -- parts using :merge(). They should call
+            -- :apply_conditional() for each of the parts before
+            -- :merge().
+            source_cconfig = cluster_config:apply_conditional(source_cconfig)
+
             cconfig = cluster_config:merge(source_cconfig, cconfig)
             source_iconfig = cluster_config:instantiate(cconfig,
                 self._instance_name)
diff --git a/test/config-luatest/conditional_section_test.lua b/test/config-luatest/conditional_section_test.lua
new file mode 100644
index 0000000000000000000000000000000000000000..bb2b098097731a837aaf737d53a9fe22e9c76b07
--- /dev/null
+++ b/test/config-luatest/conditional_section_test.lua
@@ -0,0 +1,199 @@
+local fun = require('fun')
+local fio = require('fio')
+local t = require('luatest')
+local treegen = require('test.treegen')
+local server = require('test.luatest_helpers.server')
+local helpers = require('test.config-luatest.helpers')
+local cluster_config = require('internal.config.cluster_config')
+
+local g = helpers.group()
+
+-- Verify that the example is working.
+--
+-- It has one conditional section that shouldn't be applied: it
+-- would fail otherwise, because it contains an unknown option.
+--
+-- It also contains one more conditional section, which is to be
+-- applied. It sets a custom process title, which is verified here.
+g.test_example = function(g)
+    local dir = treegen.prepare_directory(g, {}, {})
+    local config_file = fio.abspath('doc/examples/config/upgrade.yaml')
+    local opts = {config_file = config_file, chdir = dir}
+    g.server = server:new(fun.chain(opts, {alias = 'instance-001'}):tomap())
+    g.server:start()
+    t.assert_equals(g.server:exec(function()
+        return box.cfg.custom_proc_title
+    end), 'instance-001 -- in upgrade')
+end
+
+-- Basic scenario: one section that shouldn't be applied and one
+-- to be applied.
+g.test_basic = function(g)
+    helpers.success_case(g, {
+        options = {
+            ['conditional'] = {
+                {
+                    ['if'] = 'tarantool_version < 1.0.0',
+                    process = {
+                        title = 'skipped section',
+                    },
+                },
+                {
+                    ['if'] = 'tarantool_version > 1.0.0',
+                    process = {
+                        title = 'applied section',
+                    },
+                },
+            },
+            ['process.title'] = 'main config',
+        },
+        verify = function()
+            t.assert_equals(box.cfg.custom_proc_title, 'applied section')
+        end,
+    })
+end
+
+-- Verify that an unknown option is OK if it is in a sections that
+-- is NOT going to be applied.
+g.test_unknown_option = function(g)
+    helpers.success_case(g, {
+        options = {
+            ['conditional'] = {
+                {
+                    ['if'] = 'tarantool_version < 1.0.0',
+                    unknown_option = 'foo',
+                },
+                {
+                    ['if'] = 'tarantool_version > 1.0.0',
+                    process = {
+                        title = 'applied section',
+                    },
+                },
+            },
+            ['process.title'] = 'main config',
+        },
+        verify = function()
+            t.assert_equals(box.cfg.custom_proc_title, 'applied section')
+        end,
+    })
+end
+
+-- Verify that the last conditional sections wins if they set the
+-- same option.
+g.test_priority = function(g)
+    helpers.success_case(g, {
+        options = {
+            ['conditional'] = {
+                {
+                    ['if'] = 'tarantool_version > 1.0.0',
+                    process = {
+                        title = 'foo',
+                    },
+                },
+                {
+                    ['if'] = 'tarantool_version > 1.0.0',
+                    process = {
+                        title = 'bar',
+                    },
+                },
+                {
+                    ['if'] = 'tarantool_version > 1.0.0',
+                    process = {
+                        title = 'baz',
+                    },
+                },
+                {
+                    ['if'] = 'tarantool_version < 1.0.0',
+                    process = {
+                        title = 'skipped',
+                    },
+                },
+
+            },
+        },
+        verify = function()
+            t.assert_equals(box.cfg.custom_proc_title, 'baz')
+        end,
+    })
+end
+
+-- Several incorrect configuration cases.
+g.test_validation = function()
+    -- 'if' should exists in each conditiona; section.
+    local exp_err = '[cluster_config] conditional[1]: A conditional section ' ..
+        'should have field "if"'
+    t.assert_error_msg_equals(exp_err, function()
+        cluster_config:validate({
+            ['conditional'] = {
+                {
+                    process = {
+                        title = 'foo',
+                    },
+                },
+            },
+        })
+    end)
+
+    -- The 'if' expression should be correct.
+    local exp_err = '[cluster_config] conditional[1]: An expression should ' ..
+        'be a predicate, got variable'
+    t.assert_error_msg_equals(exp_err, function()
+        cluster_config:validate({
+            ['conditional'] = {
+                {
+                    ['if'] = 'incorrect',
+                    process = {
+                        title = 'foo',
+                    },
+                },
+            },
+        })
+    end)
+
+    -- A content of the section that is going to be applied is
+    -- validated as a cluster config.
+    local exp_err = '[nested_cluster_config] Unexpected field "unknown_option"'
+    t.assert_error_msg_equals(exp_err, function()
+        cluster_config:validate({
+            ['conditional'] = {
+                {
+                    ['if'] = 'tarantool_version > 1.0.0',
+                    unknown_option = 'foo',
+                },
+            },
+        })
+    end)
+end
+
+-- Verify that it is possible to set an option in a nested scope:
+-- say, for particular instance.
+g.test_basic = function(g)
+    helpers.success_case(g, {
+        options = {
+            ['conditional'] = {
+                {
+                    ['if'] = 'tarantool_version > 1.0.0',
+                    groups = {
+                        ['group-001'] = {
+                            replicasets = {
+                                ['replicaset-001'] = {
+                                    instances = {
+                                        ['instance-001'] = {
+                                            process = {
+                                                title = 'applied section',
+                                            },
+                                        },
+                                    },
+                                },
+                            },
+                        },
+                    },
+                },
+            },
+            ['process.title'] = 'main config',
+        },
+        verify = function()
+            t.assert_equals(box.cfg.custom_proc_title, 'applied section')
+        end,
+    })
+end