diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index 15b27b31959bca6c9fead0ec138933792ef76b56..69dc95e4d5c487399b46d67e0ff263dbd597cf72 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -2430,7 +2430,7 @@
           "additionalProperties": false
         },
         "publish": {
-          "description": "A path to a directory that contains the files to be published with Pages",
+          "description": "Deprecated. Use `pages.publish` instead. A path to a directory that contains the files to be published with Pages.",
           "type": "string"
         },
         "pages": {
@@ -2446,6 +2446,10 @@
                 "expire_in": {
                   "type": "string",
                   "markdownDescription": "How long the deployment should be active. Deployments that have expired are no longer available on the web. Supports a wide variety of formats, e.g. '1 week', '3 mins 4 sec', '2 hrs 20 min', '2h20min', '6 mos 1 day', '47 yrs 6 mos and 4d', '3 weeks and 2 days'. Set to 'never' to prevent extra deployments from expiring. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#pagesexpire_in)."
+                },
+                "publish": {
+                  "type": "string",
+                  "markdownDescription": "A path to a directory that contains the files to be published with Pages."
                 }
               }
             },
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 5e1680b4f7f50804b7398f23adb247933c2475df..e1166c3a5508f567bae0cccb016b0292ca21305c 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -471,6 +471,7 @@ def other_scheduled_actions
 
     def pages_generator?
       return false unless Gitlab.config.pages.enabled
+      return false unless options.present?
       return true if options[:pages].is_a?(Hash) || options[:pages] == true
 
       options[:pages] != false && name == 'pages' # Legacy behaviour
@@ -479,11 +480,7 @@ def pages_generator?
     def pages
       return {} unless pages_generator? && publish_path_available?
 
-      {}.tap do |result|
-        result[:publish] = ExpandVariables.expand(options[:publish].to_s, -> {
-          base_variables.sort_and_expand_all
-        })
-      end
+      { publish: expanded_publish_path }
     end
     strong_memoize_attr :pages
 
@@ -995,8 +992,19 @@ def supports_artifacts_exclude?
       options&.dig(:artifacts, :exclude)&.any?
     end
 
+    def publish_path
+      return unless options.present?
+      return options[:publish] unless options[:pages].is_a?(Hash)
+
+      options.dig(:pages, :publish) || options[:publish]
+    end
+
     def publish_path_available?
-      options&.dig(:publish).present?
+      publish_path.present?
+    end
+
+    def expanded_publish_path
+      ExpandVariables.expand(publish_path.to_s, -> { base_variables.sort_and_expand_all })
     end
 
     def multi_build_steps?
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index 88055b66e160a08093b612f3c7654fd72205a80b..ac22628b254ea941f219bc72d1d03f79caccd52d 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -108,7 +108,7 @@ def pages_deployment_attributes(file, build)
         file_count: deployment_validations.entries_count,
         file_sha256: build.job_artifacts_archive.file_sha256,
         ci_build_id: build.id,
-        root_directory: build.options[:publish]
+        root_directory: build.pages[:publish]
       }
     end
 
diff --git a/ee/app/models/ee/ci/build.rb b/ee/app/models/ee/ci/build.rb
index ef52c50cd349ad06f1a70d2d87e471577f9206eb..78492018d2194415a651949f81dc2aa713251977 100644
--- a/ee/app/models/ee/ci/build.rb
+++ b/ee/app/models/ee/ci/build.rb
@@ -207,11 +207,13 @@ def pages
       private
 
       def expand_pages_variables
-        pages_config.tap do |pages_options|
-          pages_options[:path_prefix] = ExpandVariables.expand(pages_options[:path_prefix].to_s, -> {
-            base_variables.sort_and_expand_all
-          })
-        end
+        pages_options = {}
+
+        pages_options[:path_prefix] = expanded_path_prefix if pages_config[:path_prefix].present?
+
+        return pages_options unless pages_config[:expire_in].present?
+
+        pages_options.merge(expire_in: pages_config[:expire_in])
       end
 
       def variables_hash
@@ -299,6 +301,10 @@ def pages_config
         config = options&.dig(:pages)
         config.is_a?(Hash) ? config : {}
       end
+
+      def expanded_path_prefix
+        ExpandVariables.expand(pages_config[:path_prefix].to_s, -> { base_variables.sort_and_expand_all })
+      end
     end
   end
 end
diff --git a/ee/spec/models/ci/build_spec.rb b/ee/spec/models/ci/build_spec.rb
index 3c6b42ea23cd42b84f1eb6f97d9f22f5d0bfc28a..48004a758a612f1801403669438b309fa29382f2 100644
--- a/ee/spec/models/ci/build_spec.rb
+++ b/ee/spec/models/ci/build_spec.rb
@@ -1145,10 +1145,13 @@
       false | {} | {}
       false | { pages: { path_prefix: 'foo' } } | {}
       true | { pages: { path_prefix: 'foo' } } | { path_prefix: 'foo' }
-      true | { pages: { path_prefix: nil } } | { path_prefix: '' }
+      true | { pages: { path_prefix: nil } } | {}
       true | { pages: { path_prefix: 'foo' }, publish: 'public' } | { path_prefix: 'foo', publish: 'public' }
+      true | { pages: { path_prefix: 'foo', publish: 'public' } } | { path_prefix: 'foo', publish: 'public' }
       true | { pages: { path_prefix: '$CI_COMMIT_BRANCH' } } | { path_prefix: 'master' }
       true | { pages: { path_prefix: 'foo', expire_in: '1d' } } | { path_prefix: 'foo', expire_in: '1d' }
+      true | { pages: { path_prefix: 'foo', expire_in: '1d', publish: '$CUSTOM_FOLDER' } } | { path_prefix: 'foo', expire_in: '1d', publish: 'custom_folder' }
+      true | { pages: { path_prefix: 'foo', expire_in: '1d', publish: 'public' } } | { path_prefix: 'foo', expire_in: '1d', publish: 'public' }
       true | { pages: { path_prefix: 'foo', expire_in: 'never' } } | { path_prefix: 'foo', expire_in: 'never' }
     end
 
@@ -1156,6 +1159,7 @@
       before do
         allow(job).to receive(:pages_generator?).and_return(pages_generator)
         allow(job).to receive(:options).and_return(options)
+        create(:ci_job_variable, key: 'CUSTOM_FOLDER', value: 'custom_folder', job: job)
       end
 
       subject(:pages_options) { job.pages }
diff --git a/lib/gitlab/ci/config/entry/pages.rb b/lib/gitlab/ci/config/entry/pages.rb
index 0a6dec6323699ce93ca30e34aa6f5e6389c7066e..daec4d7234a6de69ce486c3a0fea685f948d647e 100644
--- a/lib/gitlab/ci/config/entry/pages.rb
+++ b/lib/gitlab/ci/config/entry/pages.rb
@@ -9,7 +9,7 @@ module Entry
         # Entry that represents the pages attributes
         #
         class Pages < ::Gitlab::Config::Entry::Node
-          ALLOWED_KEYS = %i[path_prefix expire_in].freeze
+          ALLOWED_KEYS = %i[path_prefix expire_in publish].freeze
 
           include ::Gitlab::Config::Entry::Attributable
           include ::Gitlab::Config::Entry::Validatable
@@ -23,6 +23,7 @@ class Pages < ::Gitlab::Config::Entry::Node
             with_options allow_nil: true do
               validates :path_prefix, type: String
               validates :expire_in, duration: { parser: ::Gitlab::Ci::Build::DurationParser }
+              validates :publish, type: String
             end
           end
         end
diff --git a/lib/gitlab/pages/deployment_validations.rb b/lib/gitlab/pages/deployment_validations.rb
index 11b9e135acf3d529faffcd0ba5aa10bb2dc5f6d2..f0cfd0f0d6ab916f3c8606927b2217d1a5fc86fb 100644
--- a/lib/gitlab/pages/deployment_validations.rb
+++ b/lib/gitlab/pages/deployment_validations.rb
@@ -14,6 +14,7 @@ class DeploymentValidations
         validate :validate_max_size
         validate :validate_public_folder
         validate :validate_max_entries
+        validate :validate_pages_publish_options
       end
 
       def initialize(project, build)
@@ -93,6 +94,16 @@ def validate_outdated_sha
         errors.add(:base, 'build SHA is outdated for this ref')
       end
 
+      def validate_pages_publish_options
+        return unless build.options.present?
+        return unless build.options[:pages].is_a?(Hash)
+        return unless build.options.key?(:publish) && build.options[:pages]&.key?(:publish)
+
+        errors.add(
+          :base,
+          _("Either the `publish` or `pages.publish` option may be present in `.gitlab-ci.yml`, but not both."))
+      end
+
       def latest_sha
         project.commit(build.ref).try(:sha).to_s
       ensure
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 4d5e16b2bc04d4d5e649554541bfb278322fbee1..bdaf26a1321bf7f31b9d98308bb8720d6857786b 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -21827,6 +21827,9 @@ msgstr ""
 msgid "Edits"
 msgstr ""
 
+msgid "Either the `publish` or `pages.publish` option may be present in `.gitlab-ci.yml`, but not both."
+msgstr ""
+
 msgid "Either the title or description must reference a Jira issue."
 msgstr ""
 
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index 05d4b8772cf030acc491ad197206f7778048f77c..68a89141fc3d9614980b97480d17877ae1b73806 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -778,6 +778,7 @@
       :'something-else' | {} | false
       :'something-else' | { pages: true } | true
       :'something-else' | { pages: { path_prefix: 'foo' } } | true
+      :'something-else' | { pages: { publish: '/some-folder' } } | true
       :'something-else' | { pages: false } | false
       :'something-else' | { pages: nil } | false
     end
diff --git a/spec/lib/gitlab/ci/config/entry/pages_spec.rb b/spec/lib/gitlab/ci/config/entry/pages_spec.rb
index a34c3acd378abbef93b872e911ac7ba487402bd5..67a1156eb7cd344fa94513346c80048c1de06be3 100644
--- a/spec/lib/gitlab/ci/config/entry/pages_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/pages_spec.rb
@@ -17,13 +17,14 @@
 
     context 'when value is a hash' do
       context 'when the hash is valid' do
-        let(:config) { { path_prefix: 'prefix', expire_in: '1 day' } }
+        let(:config) { { path_prefix: 'prefix', expire_in: '1 day', publish: '/some-folder' } }
 
         it 'is valid' do
           expect(entry).to be_valid
           expect(entry.value).to eq({
             path_prefix: 'prefix',
-            expire_in: '1 day'
+            expire_in: '1 day',
+            publish: '/some-folder'
           })
         end
       end
diff --git a/spec/lib/gitlab/pages/deployment_validations_spec.rb b/spec/lib/gitlab/pages/deployment_validations_spec.rb
index 62f0b76e4e260275dee144c2d188f60129a61629..a423e915b466bb9b4806089713f70391fb745912 100644
--- a/spec/lib/gitlab/pages/deployment_validations_spec.rb
+++ b/spec/lib/gitlab/pages/deployment_validations_spec.rb
@@ -81,6 +81,21 @@
         include_examples "valid pages deployment"
       end
 
+      context 'and the directory specified with `pages.publish` is included in the artifacts' do
+        let(:build_options) { { pages: { publish: 'foo' } } }
+
+        include_examples "valid pages deployment"
+      end
+
+      context 'and `publish` is present in root as well as pages' do
+        let(:build_options) { { publish: 'foo', pages: { publish: 'foo' } } }
+
+        include_examples "invalid pages deployment",
+          message: <<~MSG.squish
+          Either the `publish` or `pages.publish` option may be present in `.gitlab-ci.yml`, but not both.
+          MSG
+      end
+
       context 'and the directory specified with `publish` is not included in the artifacts' do
         let(:build_options) { { publish: 'bar' } }
 
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index e1b9c4469f97fb0c4e42e789e6ba217c88d7642c..c7bbb2738aca3e433b0eb2e6571d05c4123eecf5 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -4386,8 +4386,11 @@ def run_job_without_exception
       true  | {}                    | {}
       true  | { publish: nil }      | {}
       true  | { publish: 'public' } | { publish: 'public' }
+      true  | { pages: { publish: 'public' } } | { publish: 'public' }
       true  | { publish: '$CUSTOM_FOLDER' } | { publish: 'custom_folder' }
+      true  | { pages: { publish: '$CUSTOM_FOLDER' } } | { publish: 'custom_folder' }
       true  | { publish: '$CUSTOM_FOLDER/$CUSTOM_SUBFOLDER' } | { publish: 'custom_folder/custom_subfolder' }
+      true  | { pages: { publish: '$CUSTOM_FOLDER/$CUSTOM_SUBFOLDER' } } | { publish: 'custom_folder/custom_subfolder' }
     end
 
     with_them do
diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb
index 224d8ae1325488bf8f68bc78297c4c76221b3346..4c783104104949f72f5371a4c98bde67d8e39d94 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -201,6 +201,38 @@
           end
         end
 
+        context 'when the directory specified with `pages.publish` is included in the artifacts' do
+          let(:options) { { pages: { publish: 'foo' } } }
+
+          it 'sets the correct root directory for pages deployment' do
+            expect(service.execute[:status]).to eq(:success)
+
+            deployment = project.pages_deployments.last
+            expect(deployment.root_directory).to eq('foo')
+          end
+        end
+
+        context 'when `publish` and `pages.publish` is not specified and there is a folder named `public`' do
+          let(:file) { fixture_file_upload("spec/fixtures/pages.zip") }
+          let(:metadata_filename) { "spec/fixtures/pages.zip.meta" }
+
+          it 'creates pages_deployment and saves it in the metadata' do
+            expect(service.execute[:status]).to eq(:success)
+          end
+        end
+
+        context 'when `publish` and `pages.publish` both are specified' do
+          let(:options) { { pages: { publish: 'foo' }, publish: 'bar' } }
+
+          it 'returns an error' do
+            expect(service.execute[:status]).not_to eq(:success)
+
+            expect(GenericCommitStatus.last.description)
+              .to eq(
+                "Either the `publish` or `pages.publish` option may be present in `.gitlab-ci.yml`, but not both.")
+          end
+        end
+
         context 'when the directory specified with `publish` is not included in the artifacts' do
           let(:options) { { publish: 'bar' } }