diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
index ef52800a2034ee709a2b84578d62d973428cf187..5e6195296f763dcec1bf994e37c92e772b4690cc 100644
--- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb
+++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
@@ -11,8 +11,8 @@ class Groups::DependencyProxyForContainersController < ::Groups::DependencyProxy
   before_action :ensure_token_granted!, only: [:blob, :manifest]
   before_action :ensure_feature_enabled!
 
-  before_action :verify_workhorse_api!, only: [:authorize_upload_blob, :upload_blob]
-  skip_before_action :verify_authenticity_token, only: [:authorize_upload_blob, :upload_blob]
+  before_action :verify_workhorse_api!, only: [:authorize_upload_blob, :upload_blob, :authorize_upload_manifest, :upload_manifest]
+  skip_before_action :verify_authenticity_token, only: [:authorize_upload_blob, :upload_blob, :authorize_upload_manifest, :upload_manifest]
 
   attr_reader :token
 
@@ -22,20 +22,11 @@ def manifest
     result = DependencyProxy::FindOrCreateManifestService.new(group, image, tag, token).execute
 
     if result[:status] == :success
-      response.headers['Docker-Content-Digest'] = result[:manifest].digest
-      response.headers['Content-Length'] = result[:manifest].size
-      response.headers['Docker-Distribution-Api-Version'] = DependencyProxy::DISTRIBUTION_API_VERSION
-      response.headers['Etag'] = "\"#{result[:manifest].digest}\""
-      content_type = result[:manifest].content_type
-
-      event_name = tracking_event_name(object_type: :manifest, from_cache: result[:from_cache])
-      track_package_event(event_name, :dependency_proxy, namespace: group, user: auth_user)
-      send_upload(
-        result[:manifest].file,
-        proxy: true,
-        redirect_params: { query: { 'response-content-type' => content_type } },
-        send_params: { type: content_type }
-      )
+      if result[:manifest]
+        send_manifest(result[:manifest], from_cache: result[:from_cache])
+      else
+        send_dependency(manifest_header, DependencyProxy::Registry.manifest_url(image, tag), manifest_file_name)
+      end
     else
       render status: result[:http_status], json: result[:message]
     end
@@ -59,7 +50,7 @@ def blob
   def authorize_upload_blob
     set_workhorse_internal_api_content_type
 
-    render json: DependencyProxy::FileUploader.workhorse_authorize(has_length: false, maximum_size: 5.gigabytes)
+    render json: DependencyProxy::FileUploader.workhorse_authorize(has_length: false, maximum_size: DependencyProxy::Blob::MAX_FILE_SIZE)
   end
 
   def upload_blob
@@ -75,6 +66,27 @@ def upload_blob
     head :ok
   end
 
+  def authorize_upload_manifest
+    set_workhorse_internal_api_content_type
+
+    render json: DependencyProxy::FileUploader.workhorse_authorize(has_length: false, maximum_size: DependencyProxy::Manifest::MAX_FILE_SIZE)
+  end
+
+  def upload_manifest
+    @group.dependency_proxy_manifests.create!(
+      file_name: manifest_file_name,
+      content_type: request.headers[Gitlab::Workhorse::SEND_DEPENDENCY_CONTENT_TYPE_HEADER],
+      digest: request.headers['Docker-Content-Digest'],
+      file: params[:file],
+      size: params[:file].size
+    )
+
+    event_name = tracking_event_name(object_type: :manifest, from_cache: false)
+    track_package_event(event_name, :dependency_proxy, namespace: group, user: auth_user)
+
+    head :ok
+  end
+
   private
 
   def blob_via_workhorse
@@ -86,14 +98,38 @@ def blob_via_workhorse
 
       send_upload(blob.file)
     else
-      send_dependency(token, DependencyProxy::Registry.blob_url(image, params[:sha]), blob_file_name)
+      send_dependency(token_header, DependencyProxy::Registry.blob_url(image, params[:sha]), blob_file_name)
     end
   end
 
+  def send_manifest(manifest, from_cache:)
+    # Technical debt: change to read_at https://gitlab.com/gitlab-org/gitlab/-/issues/341536
+    manifest.touch
+    response.headers['Docker-Content-Digest'] = manifest.digest
+    response.headers['Content-Length'] = manifest.size
+    response.headers['Docker-Distribution-Api-Version'] = DependencyProxy::DISTRIBUTION_API_VERSION
+    response.headers['Etag'] = "\"#{manifest.digest}\""
+    content_type = manifest.content_type
+
+    event_name = tracking_event_name(object_type: :manifest, from_cache: from_cache)
+    track_package_event(event_name, :dependency_proxy, namespace: group, user: auth_user)
+
+    send_upload(
+      manifest.file,
+      proxy: true,
+      redirect_params: { query: { 'response-content-type' => content_type } },
+      send_params: { type: content_type }
+    )
+  end
+
   def blob_file_name
     @blob_file_name ||= params[:sha].sub('sha256:', '') + '.gz'
   end
 
+  def manifest_file_name
+    @manifest_file_name ||= "#{image}:#{tag}.json"
+  end
+
   def group
     strong_memoize(:group) do
       Group.find_by_full_path(params[:group_id], follow_redirects: true)
@@ -137,4 +173,12 @@ def ensure_token_granted!
       render status: result[:http_status], json: result[:message]
     end
   end
+
+  def token_header
+    { Authorization: ["Bearer #{token}"] }
+  end
+
+  def manifest_header
+    token_header.merge(Accept: ::ContainerRegistry::Client::ACCEPTED_TYPES)
+  end
 end
diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb
index 4862282bc735432b4071499562a23171d395d9ea..2460c956bb611c54437649362eb6bd23bd9c803a 100644
--- a/app/helpers/workhorse_helper.rb
+++ b/app/helpers/workhorse_helper.rb
@@ -41,8 +41,8 @@ def send_artifacts_entry(file, entry)
     head :ok
   end
 
-  def send_dependency(token, url, filename)
-    headers.store(*Gitlab::Workhorse.send_dependency(token, url))
+  def send_dependency(dependency_headers, url, filename)
+    headers.store(*Gitlab::Workhorse.send_dependency(dependency_headers, url))
     headers['Content-Disposition'] =
       ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: filename)
     headers['Content-Type'] = 'application/gzip'
diff --git a/app/models/dependency_proxy/blob.rb b/app/models/dependency_proxy/blob.rb
index 7ca15652586e06e9ba11f0506e744e45510ed176..bd5c022e6923e14cc75f8f34dbfd694e82a33cd9 100644
--- a/app/models/dependency_proxy/blob.rb
+++ b/app/models/dependency_proxy/blob.rb
@@ -7,6 +7,8 @@ class DependencyProxy::Blob < ApplicationRecord
 
   belongs_to :group
 
+  MAX_FILE_SIZE = 5.gigabytes.freeze
+
   validates :group, presence: true
   validates :file, presence: true
   validates :file_name, presence: true
diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb
index b83047efe54b6c50b6f239e59d50c8a473635200..6a5ccd12cac51ffcafd3723dbc129572ba303af6 100644
--- a/app/models/dependency_proxy/manifest.rb
+++ b/app/models/dependency_proxy/manifest.rb
@@ -7,6 +7,8 @@ class DependencyProxy::Manifest < ApplicationRecord
 
   belongs_to :group
 
+  MAX_FILE_SIZE = 10.megabytes.freeze
+
   validates :group, presence: true
   validates :file, presence: true
   validates :file_name, presence: true
@@ -14,10 +16,7 @@ class DependencyProxy::Manifest < ApplicationRecord
 
   mount_file_store_uploader DependencyProxy::FileUploader
 
-  def self.find_or_initialize_by_file_name_or_digest(file_name:, digest:)
-    result = find_by(file_name: file_name) || find_by(digest: digest)
-    return result if result
-
-    new(file_name: file_name, digest: digest)
+  def self.find_by_file_name_or_digest(file_name:, digest:)
+    find_by(file_name: file_name) || find_by(digest: digest)
   end
 end
diff --git a/app/services/dependency_proxy/find_or_create_manifest_service.rb b/app/services/dependency_proxy/find_or_create_manifest_service.rb
index 1976d4d47f499265e5c4d8c970db93dd0ec95bb6..055980c431f85b9ba77d28c13644ac853d59be40 100644
--- a/app/services/dependency_proxy/find_or_create_manifest_service.rb
+++ b/app/services/dependency_proxy/find_or_create_manifest_service.rb
@@ -14,18 +14,18 @@ def initialize(group, image, tag, token)
     def execute
       @manifest = @group.dependency_proxy_manifests
                         .active
-                        .find_or_initialize_by_file_name_or_digest(file_name: @file_name, digest: @tag)
+                        .find_by_file_name_or_digest(file_name: @file_name, digest: @tag)
 
       head_result = DependencyProxy::HeadManifestService.new(@image, @tag, @token).execute
 
-      if cached_manifest_matches?(head_result)
-        @manifest.touch
+      return respond if cached_manifest_matches?(head_result)
 
-        return success(manifest: @manifest, from_cache: true)
+      if Feature.enabled?(:dependency_proxy_manifest_workhorse, @group, default_enabled: :yaml)
+        success(manifest: nil, from_cache: false)
+      else
+        pull_new_manifest
+        respond(from_cache: false)
       end
-
-      pull_new_manifest
-      respond(from_cache: false)
     rescue Timeout::Error, *Gitlab::HTTP::HTTP_ERRORS
       respond
     end
@@ -34,12 +34,19 @@ def execute
 
     def pull_new_manifest
       DependencyProxy::PullManifestService.new(@image, @tag, @token).execute_with_manifest do |new_manifest|
-        @manifest.update!(
+        params = {
+          file_name: @file_name,
           content_type: new_manifest[:content_type],
           digest: new_manifest[:digest],
           file: new_manifest[:file],
           size: new_manifest[:file].size
-        )
+        }
+
+        if @manifest
+          @manifest.update!(params)
+        else
+          @manifest = @group.dependency_proxy_manifests.create!(params)
+        end
       end
     end
 
@@ -50,10 +57,7 @@ def cached_manifest_matches?(head_result)
     end
 
     def respond(from_cache: true)
-      if @manifest.persisted?
-        # Technical debt: change to read_at https://gitlab.com/gitlab-org/gitlab/-/issues/341536
-        @manifest.touch if from_cache
-
+      if @manifest
         success(manifest: @manifest, from_cache: from_cache)
       else
         error('Failed to download the manifest from the external registry', 503)
diff --git a/config/feature_flags/development/dependency_proxy_manifest_workhorse.yml b/config/feature_flags/development/dependency_proxy_manifest_workhorse.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5ff6fc93e0a8865e399eae1c363fbe9c34a23400
--- /dev/null
+++ b/config/feature_flags/development/dependency_proxy_manifest_workhorse.yml
@@ -0,0 +1,8 @@
+---
+name: dependency_proxy_manifest_workhorse
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73033
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/344216
+milestone: '14.4'
+type: development
+group: group::package
+default_enabled: false
diff --git a/config/routes/group.rb b/config/routes/group.rb
index c093be6db4d9c735c79089f89a0acfa4c00ffad6..9a50d580747b9d4006111b091a5892d7c25cf8d2 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -155,5 +155,7 @@
     get 'v2/*group_id/dependency_proxy/containers/*image/blobs/:sha' => 'groups/dependency_proxy_for_containers#blob' # rubocop:todo Cop/PutGroupRoutesUnderScope
     post 'v2/*group_id/dependency_proxy/containers/*image/blobs/:sha/upload/authorize' => 'groups/dependency_proxy_for_containers#authorize_upload_blob' # rubocop:todo Cop/PutGroupRoutesUnderScope
     post 'v2/*group_id/dependency_proxy/containers/*image/blobs/:sha/upload' => 'groups/dependency_proxy_for_containers#upload_blob' # rubocop:todo Cop/PutGroupRoutesUnderScope
+    post 'v2/*group_id/dependency_proxy/containers/*image/manifests/*tag/upload/authorize' => 'groups/dependency_proxy_for_containers#authorize_upload_manifest' # rubocop:todo Cop/PutGroupRoutesUnderScope
+    post 'v2/*group_id/dependency_proxy/containers/*image/manifests/*tag/upload' => 'groups/dependency_proxy_for_containers#upload_manifest' # rubocop:todo Cop/PutGroupRoutesUnderScope
   end
 end
diff --git a/doc/administration/instance_limits.md b/doc/administration/instance_limits.md
index c7481abc84e6305c9722fe65c0200aa959cb1df8..7ca9e9d5110f6c13f45976cc8ab4dcfdbeb05c9d 100644
--- a/doc/administration/instance_limits.md
+++ b/doc/administration/instance_limits.md
@@ -828,6 +828,17 @@ Set the limit to `0` to allow any file size.
 
 When asking for versions of a given NuGet package name, the GitLab Package Registry returns a maximum of 300 versions.
 
+## Dependency Proxy Limits
+
+> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/6396) in GitLab 14.5.
+
+The maximum file size for an image cached in the
+[Dependency Proxy](../user/packages/dependency_proxy/index.md)
+varies by file type:
+
+- Image blob: 5 GB
+- Image manifest: 10 MB
+
 ## Branch retargeting on merge
 
 > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/320902) in GitLab 13.9.
diff --git a/ee/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/ee/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
index c50e889755dcdc02267a749f8f5d80841d04a7ea..46a9b518090dc6f0e4b0c1fa400292ca183ea4bf 100644
--- a/ee/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
+++ b/ee/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
@@ -81,18 +81,23 @@
   end
 
   describe 'GET #manifest' do
-    let_it_be(:manifest) { create(:dependency_proxy_manifest) }
+    let_it_be(:manifest) { create(:dependency_proxy_manifest, group: group) }
 
     let(:pull_response) { { status: :success, manifest: manifest, from_cache: false } }
+    let(:head_response) { { status: :success, digest: manifest.digest, content_type: manifest.content_type } }
+    let(:tag) { manifest.file_name.sub('.json', '').split(':').last }
 
     subject(:get_manifest) do
-      get :manifest, params: { group_id: group.to_param, image: 'alpine', tag: '3.9.2' }
+      get :manifest, params: { group_id: group.to_param, image: 'alpine', tag: tag }
     end
 
     before do
       allow_next_instance_of(DependencyProxy::FindOrCreateManifestService) do |instance|
         allow(instance).to receive(:execute).and_return(pull_response)
       end
+      allow_next_instance_of(DependencyProxy::HeadManifestService) do |instance|
+        allow(instance).to receive(:execute).and_return(head_response)
+      end
     end
 
     it_behaves_like 'when sso is enabled for the group', 'a successful manifest pull'
diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb
index c40aa2273aae4f31aea72eba2f7a4ce8535bf4fb..3a905a2e1c5a2ec17673d94bacb33ed07b70d019 100644
--- a/lib/gitlab/workhorse.rb
+++ b/lib/gitlab/workhorse.rb
@@ -8,6 +8,7 @@
 module Gitlab
   class Workhorse
     SEND_DATA_HEADER = 'Gitlab-Workhorse-Send-Data'
+    SEND_DEPENDENCY_CONTENT_TYPE_HEADER = 'Workhorse-Proxy-Content-Type'
     VERSION_FILE = 'GITLAB_WORKHORSE_VERSION'
     INTERNAL_API_CONTENT_TYPE = 'application/vnd.gitlab-workhorse+json'
     INTERNAL_API_REQUEST_HEADER = 'Gitlab-Workhorse-Api-Request'
@@ -170,9 +171,9 @@ def send_scaled_image(location, width, content_type)
         ]
       end
 
-      def send_dependency(token, url)
+      def send_dependency(headers, url)
         params = {
-          'Header' => { Authorization: ["Bearer #{token}"] },
+          'Header' => headers,
           'Url' => url
         }
 
diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
index 5c8e080199b131e4a1dc4d3b0d84e187c6fab948..61f38e7e379eca8870d676f2e9284f5f6117201b 100644
--- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
+++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
@@ -124,6 +124,34 @@
     end
   end
 
+  shared_examples 'authorize action with permission' do
+    context 'with a valid user' do
+      before do
+        group.add_guest(user)
+      end
+
+      it 'sends Workhorse local file instructions', :aggregate_failures do
+        subject
+
+        expect(response.headers['Content-Type']).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+        expect(json_response['TempPath']).to eq(DependencyProxy::FileUploader.workhorse_local_upload_path)
+        expect(json_response['RemoteObject']).to be_nil
+        expect(json_response['MaximumSize']).to eq(maximum_size)
+      end
+
+      it 'sends Workhorse remote object instructions', :aggregate_failures do
+        stub_dependency_proxy_object_storage(direct_upload: true)
+
+        subject
+
+        expect(response.headers['Content-Type']).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+        expect(json_response['TempPath']).to be_nil
+        expect(json_response['RemoteObject']).not_to be_nil
+        expect(json_response['MaximumSize']).to eq(maximum_size)
+      end
+    end
+  end
+
   before do
     allow(Gitlab.config.dependency_proxy)
       .to receive(:enabled).and_return(true)
@@ -136,9 +164,10 @@
   end
 
   describe 'GET #manifest' do
-    let_it_be(:manifest) { create(:dependency_proxy_manifest) }
+    let_it_be(:manifest) { create(:dependency_proxy_manifest, group: group) }
 
     let(:pull_response) { { status: :success, manifest: manifest, from_cache: false } }
+    let(:tag) { 'latest1' }
 
     before do
       allow_next_instance_of(DependencyProxy::FindOrCreateManifestService) do |instance|
@@ -146,7 +175,7 @@
       end
     end
 
-    subject { get_manifest }
+    subject { get_manifest(tag) }
 
     context 'feature enabled' do
       before do
@@ -207,11 +236,26 @@
         it_behaves_like 'a successful manifest pull'
         it_behaves_like 'a package tracking event', described_class.name, 'pull_manifest'
 
-        context 'with a cache entry' do
-          let(:pull_response) { { status: :success, manifest: manifest, from_cache: true } }
+        context 'with workhorse response' do
+          let(:pull_response) { { status: :success, manifest: nil, from_cache: false } }
 
-          it_behaves_like 'returning response status', :success
-          it_behaves_like 'a package tracking event', described_class.name, 'pull_manifest_from_cache'
+          it 'returns Workhorse send-dependency instructions', :aggregate_failures do
+            subject
+
+            send_data_type, send_data = workhorse_send_data
+            header, url = send_data.values_at('Header', 'Url')
+
+            expect(send_data_type).to eq('send-dependency')
+            expect(header).to eq(
+              "Authorization" => ["Bearer abcd1234"],
+              "Accept" => ::ContainerRegistry::Client::ACCEPTED_TYPES
+            )
+            expect(url).to eq(DependencyProxy::Registry.manifest_url('alpine', tag))
+            expect(response.headers['Content-Type']).to eq('application/gzip')
+            expect(response.headers['Content-Disposition']).to eq(
+              ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: manifest.file_name)
+            )
+          end
         end
       end
 
@@ -237,8 +281,8 @@
 
     it_behaves_like 'not found when disabled'
 
-    def get_manifest
-      get :manifest, params: { group_id: group.to_param, image: 'alpine', tag: '3.9.2' }
+    def get_manifest(tag)
+      get :manifest, params: { group_id: group.to_param, image: 'alpine', tag: tag }
     end
   end
 
@@ -383,53 +427,71 @@ def get_blob
 
   describe 'GET #authorize_upload_blob' do
     let(:blob_sha) { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4' }
+    let(:maximum_size) { DependencyProxy::Blob::MAX_FILE_SIZE }
 
-    subject(:authorize_upload_blob) do
+    subject do
       request.headers.merge!(workhorse_internal_api_request_header)
 
       get :authorize_upload_blob, params: { group_id: group.to_param, image: 'alpine', sha: blob_sha }
     end
 
+    it_behaves_like 'without permission'
+    it_behaves_like 'authorize action with permission'
+  end
+
+  describe 'GET #upload_blob' do
+    let(:blob_sha) { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4' }
+    let(:file) { fixture_file_upload("spec/fixtures/dependency_proxy/#{blob_sha}.gz", 'application/gzip') }
+
+    subject do
+      request.headers.merge!(workhorse_internal_api_request_header)
+
+      get :upload_blob, params: {
+        group_id: group.to_param,
+        image: 'alpine',
+        sha: blob_sha,
+        file: file
+      }
+    end
+
     it_behaves_like 'without permission'
 
     context 'with a valid user' do
       before do
         group.add_guest(user)
-      end
 
-      it 'sends Workhorse local file instructions', :aggregate_failures do
-        authorize_upload_blob
-
-        expect(response.headers['Content-Type']).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
-        expect(json_response['TempPath']).to eq(DependencyProxy::FileUploader.workhorse_local_upload_path)
-        expect(json_response['RemoteObject']).to be_nil
-        expect(json_response['MaximumSize']).to eq(5.gigabytes)
+        expect_next_found_instance_of(Group) do |instance|
+          expect(instance).to receive_message_chain(:dependency_proxy_blobs, :create!)
+        end
       end
 
-      it 'sends Workhorse remote object instructions', :aggregate_failures do
-        stub_dependency_proxy_object_storage(direct_upload: true)
+      it_behaves_like 'a package tracking event', described_class.name, 'pull_blob'
+    end
+  end
 
-        authorize_upload_blob
+  describe 'GET #authorize_upload_manifest' do
+    let(:maximum_size) { DependencyProxy::Manifest::MAX_FILE_SIZE }
 
-        expect(response.headers['Content-Type']).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
-        expect(json_response['TempPath']).to be_nil
-        expect(json_response['RemoteObject']).not_to be_nil
-        expect(json_response['MaximumSize']).to eq(5.gigabytes)
-      end
+    subject do
+      request.headers.merge!(workhorse_internal_api_request_header)
+
+      get :authorize_upload_manifest, params: { group_id: group.to_param, image: 'alpine', tag: 'latest' }
     end
+
+    it_behaves_like 'without permission'
+    it_behaves_like 'authorize action with permission'
   end
 
-  describe 'GET #upload_blob' do
-    let(:blob_sha) { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4' }
-    let(:file) { fixture_file_upload("spec/fixtures/dependency_proxy/#{blob_sha}.gz", 'application/gzip') }
+  describe 'GET #upload_manifest' do
+    let(:file) { fixture_file_upload("spec/fixtures/dependency_proxy/manifest", 'application/json') }
 
     subject do
       request.headers.merge!(workhorse_internal_api_request_header)
 
-      get :upload_blob, params: {
+      get :upload_manifest, params: {
         group_id: group.to_param,
         image: 'alpine',
-        sha: blob_sha,
+        tag: 'latest',
         file: file
       }
     end
@@ -441,11 +503,11 @@ def get_blob
         group.add_guest(user)
 
         expect_next_found_instance_of(Group) do |instance|
-          expect(instance).to receive_message_chain(:dependency_proxy_blobs, :create!)
+          expect(instance).to receive_message_chain(:dependency_proxy_manifests, :create!)
         end
       end
 
-      it_behaves_like 'a package tracking event', described_class.name, 'pull_blob'
+      it_behaves_like 'a package tracking event', described_class.name, 'pull_manifest'
     end
   end
 
diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb
index 8ba56af561dc92d83f087fa2cf7b2e46b84c31dd..3bab9aec454810e9078ad4df88df9a57883808df 100644
--- a/spec/lib/gitlab/workhorse_spec.rb
+++ b/spec/lib/gitlab/workhorse_spec.rb
@@ -512,6 +512,24 @@ def call_verify(headers)
     end
   end
 
+  describe '.send_dependency' do
+    let(:headers) { { Accept: 'foo', Authorization: 'Bearer asdf1234' } }
+    let(:url) { 'https://foo.bar.com/baz' }
+
+    subject { described_class.send_dependency(headers, url) }
+
+    it 'sets the header correctly', :aggregate_failures do
+      key, command, params = decode_workhorse_header(subject)
+
+      expect(key).to eq("Gitlab-Workhorse-Send-Data")
+      expect(command).to eq("send-dependency")
+      expect(params).to eq({
+        'Header' => headers,
+        'Url' => url
+      }.deep_stringify_keys)
+    end
+  end
+
   describe '.send_git_snapshot' do
     let(:url) { 'http://example.com' }
 
diff --git a/spec/models/dependency_proxy/manifest_spec.rb b/spec/models/dependency_proxy/manifest_spec.rb
index e7f0889345a3edba3289bd4b9f8c85785fb0f5b1..4629ff4b5b3743e6c3065335dcfb0a877a3dfd5b 100644
--- a/spec/models/dependency_proxy/manifest_spec.rb
+++ b/spec/models/dependency_proxy/manifest_spec.rb
@@ -31,18 +31,14 @@
     end
   end
 
-  describe '.find_or_initialize_by_file_name_or_digest' do
+  describe '.find_by_file_name_or_digest' do
     let_it_be(:file_name) { 'foo' }
     let_it_be(:digest) { 'bar' }
 
-    subject { DependencyProxy::Manifest.find_or_initialize_by_file_name_or_digest(file_name: file_name, digest: digest) }
+    subject { DependencyProxy::Manifest.find_by_file_name_or_digest(file_name: file_name, digest: digest) }
 
     context 'no manifest exists' do
-      it 'initializes a manifest' do
-        expect(DependencyProxy::Manifest).to receive(:new).with(file_name: file_name, digest: digest)
-
-        subject
-      end
+      it { is_expected.to be_nil }
     end
 
     context 'manifest exists and matches file_name' do
diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb
index 35ce942ed7e3362a97753a001c721090406d754c..ab0c76397e48623dd463bb040161685ffb6ff8b3 100644
--- a/spec/requests/rack_attack_global_spec.rb
+++ b/spec/requests/rack_attack_global_spec.rb
@@ -517,11 +517,15 @@ def do_request
       let(:path) { "/v2/#{group.path}/dependency_proxy/containers/alpine/manifests/latest" }
       let(:other_path) { "/v2/#{other_group.path}/dependency_proxy/containers/alpine/manifests/latest" }
       let(:pull_response) { { status: :success, manifest: manifest, from_cache: false } }
+      let(:head_response) { { status: :success } }
 
       before do
         allow_next_instance_of(DependencyProxy::FindOrCreateManifestService) do |instance|
           allow(instance).to receive(:execute).and_return(pull_response)
         end
+        allow_next_instance_of(DependencyProxy::HeadManifestService) do |instance|
+          allow(instance).to receive(:execute).and_return(head_response)
+        end
       end
 
       it_behaves_like 'rate-limited token-authenticated requests'
diff --git a/spec/routing/group_routing_spec.rb b/spec/routing/group_routing_spec.rb
index f171c2faf5e6820f72ef4a36a1137429f5b86a06..5c2ef62683eb754ff572f380446f13e6a478cb89 100644
--- a/spec/routing/group_routing_spec.rb
+++ b/spec/routing/group_routing_spec.rb
@@ -85,6 +85,26 @@
       expect(get('/v2')).to route_to('groups/dependency_proxy_auth#authenticate')
     end
 
+    it 'routes to #upload_manifest' do
+      expect(post('v2/gitlabhq/dependency_proxy/containers/alpine/manifests/latest/upload'))
+        .to route_to('groups/dependency_proxy_for_containers#upload_manifest', group_id: 'gitlabhq', image: 'alpine', tag: 'latest')
+    end
+
+    it 'routes to #upload_blob' do
+      expect(post('v2/gitlabhq/dependency_proxy/containers/alpine/blobs/abc12345/upload'))
+        .to route_to('groups/dependency_proxy_for_containers#upload_blob', group_id: 'gitlabhq', image: 'alpine', sha: 'abc12345')
+    end
+
+    it 'routes to #upload_manifest_authorize' do
+      expect(post('v2/gitlabhq/dependency_proxy/containers/alpine/manifests/latest/upload/authorize'))
+        .to route_to('groups/dependency_proxy_for_containers#authorize_upload_manifest', group_id: 'gitlabhq', image: 'alpine', tag: 'latest')
+    end
+
+    it 'routes to #upload_blob_authorize' do
+      expect(post('v2/gitlabhq/dependency_proxy/containers/alpine/blobs/abc12345/upload/authorize'))
+        .to route_to('groups/dependency_proxy_for_containers#authorize_upload_blob', group_id: 'gitlabhq', image: 'alpine', sha: 'abc12345')
+    end
+
     context 'image name without namespace' do
       it 'routes to #manifest' do
         expect(get('/v2/gitlabhq/dependency_proxy/containers/ruby/manifests/2.3.6'))
diff --git a/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb b/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb
index b3f88f91289ae4a2fb1cb18bd05cef89d57dd31a..f2a605756fb03aab84e30010dc3c0437672cc876 100644
--- a/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb
+++ b/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb
@@ -31,6 +31,14 @@
       end
     end
 
+    shared_examples 'returning no manifest' do
+      it 'returns a nil manifest' do
+        expect(subject[:status]).to eq(:success)
+        expect(subject[:from_cache]).to eq false
+        expect(subject[:manifest]).to be_nil
+      end
+    end
+
     context 'when no manifest exists' do
       let_it_be(:image) { 'new-image' }
 
@@ -40,7 +48,15 @@
           stub_manifest_download(image, tag, headers: headers)
         end
 
-        it_behaves_like 'downloading the manifest'
+        it_behaves_like 'returning no manifest'
+
+        context 'with dependency_proxy_manifest_workhorse feature disabled' do
+          before do
+            stub_feature_flags(dependency_proxy_manifest_workhorse: false)
+          end
+
+          it_behaves_like 'downloading the manifest'
+        end
       end
 
       context 'failed head request' do
@@ -49,7 +65,15 @@
           stub_manifest_download(image, tag, headers: headers)
         end
 
-        it_behaves_like 'downloading the manifest'
+        it_behaves_like 'returning no manifest'
+
+        context 'with dependency_proxy_manifest_workhorse feature disabled' do
+          before do
+            stub_feature_flags(dependency_proxy_manifest_workhorse: false)
+          end
+
+          it_behaves_like 'downloading the manifest'
+        end
       end
     end
 
@@ -60,7 +84,7 @@
 
       shared_examples 'using the cached manifest' do
         it 'uses cached manifest instead of downloading one', :aggregate_failures do
-          expect { subject }.to change { dependency_proxy_manifest.reload.updated_at }
+          subject
 
           expect(subject[:status]).to eq(:success)
           expect(subject[:manifest]).to be_a(DependencyProxy::Manifest)
@@ -80,12 +104,20 @@
           stub_manifest_download(image, tag, headers: { 'docker-content-digest' => digest, 'content-type' => content_type })
         end
 
-        it 'downloads the new manifest and updates the existing record', :aggregate_failures do
-          expect(subject[:status]).to eq(:success)
-          expect(subject[:manifest]).to eq(dependency_proxy_manifest)
-          expect(subject[:manifest].content_type).to eq(content_type)
-          expect(subject[:manifest].digest).to eq(digest)
-          expect(subject[:from_cache]).to eq false
+        it_behaves_like 'returning no manifest'
+
+        context 'with dependency_proxy_manifest_workhorse feature disabled' do
+          before do
+            stub_feature_flags(dependency_proxy_manifest_workhorse: false)
+          end
+
+          it 'downloads the new manifest and updates the existing record', :aggregate_failures do
+            expect(subject[:status]).to eq(:success)
+            expect(subject[:manifest]).to eq(dependency_proxy_manifest)
+            expect(subject[:manifest].content_type).to eq(content_type)
+            expect(subject[:manifest].digest).to eq(digest)
+            expect(subject[:from_cache]).to eq false
+          end
         end
       end
 
@@ -96,7 +128,15 @@
           stub_manifest_download(image, tag, headers: headers)
         end
 
-        it_behaves_like 'downloading the manifest'
+        it_behaves_like 'returning no manifest'
+
+        context 'with dependency_proxy_manifest_workhorse feature disabled' do
+          before do
+            stub_feature_flags(dependency_proxy_manifest_workhorse: false)
+          end
+
+          it_behaves_like 'downloading the manifest'
+        end
       end
 
       context 'failed connection' do
diff --git a/workhorse/internal/dependencyproxy/dependencyproxy.go b/workhorse/internal/dependencyproxy/dependencyproxy.go
index 0bba2610d9ecf0f3caf76eaed98cbd431f5e90ea..90f3042a342c452fa7a3dbbcbf1fbe406103a025 100644
--- a/workhorse/internal/dependencyproxy/dependencyproxy.go
+++ b/workhorse/internal/dependencyproxy/dependencyproxy.go
@@ -75,6 +75,19 @@ func (p *Injector) Inject(w http.ResponseWriter, r *http.Request, sendData strin
 		helper.Fail500(w, r, fmt.Errorf("dependency proxy: failed to create request: %w", err))
 	}
 	saveFileRequest.Header = helper.HeaderClone(r.Header)
+
+	// forward headers from dependencyResponse to rails and client
+	for key, values := range dependencyResponse.Header {
+		saveFileRequest.Header.Del(key)
+		w.Header().Del(key)
+		for _, value := range values {
+			saveFileRequest.Header.Add(key, value)
+			w.Header().Add(key, value)
+		}
+	}
+
+	// workhorse hijack overwrites the Content-Type header, but we need this header value
+	saveFileRequest.Header.Set("Workhorse-Proxy-Content-Type", dependencyResponse.Header.Get("Content-Type"))
 	saveFileRequest.ContentLength = dependencyResponse.ContentLength
 
 	nrw := &nullResponseWriter{header: make(http.Header)}
diff --git a/workhorse/internal/dependencyproxy/dependencyproxy_test.go b/workhorse/internal/dependencyproxy/dependencyproxy_test.go
index 657ea388e18bdd0c129a01543ef74bf6da6a689d..d9169b2b4ce126788ea840dacee519be66b88e80 100644
--- a/workhorse/internal/dependencyproxy/dependencyproxy_test.go
+++ b/workhorse/internal/dependencyproxy/dependencyproxy_test.go
@@ -113,8 +113,14 @@ func TestInject(t *testing.T) {
 func TestSuccessfullRequest(t *testing.T) {
 	content := []byte("result")
 	contentLength := strconv.Itoa(len(content))
+	contentType := "foo"
+	dockerContentDigest := "sha256:asdf1234"
+	overriddenHeader := "originResourceServer"
 	originResourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.Header().Set("Content-Length", contentLength)
+		w.Header().Set("Content-Type", contentType)
+		w.Header().Set("Docker-Content-Digest", dockerContentDigest)
+		w.Header().Set("Overridden-Header", overriddenHeader)
 		w.Write(content)
 	}))
 
@@ -131,12 +137,16 @@ func TestSuccessfullRequest(t *testing.T) {
 
 	require.Equal(t, "/target/upload", uploadHandler.request.URL.Path)
 	require.Equal(t, int64(6), uploadHandler.request.ContentLength)
+	require.Equal(t, contentType, uploadHandler.request.Header.Get("Workhorse-Proxy-Content-Type"))
+	require.Equal(t, dockerContentDigest, uploadHandler.request.Header.Get("Docker-Content-Digest"))
+	require.Equal(t, overriddenHeader, uploadHandler.request.Header.Get("Overridden-Header"))
 
 	require.Equal(t, content, uploadHandler.body)
 
 	require.Equal(t, 200, response.Code)
 	require.Equal(t, string(content), response.Body.String())
 	require.Equal(t, contentLength, response.Header().Get("Content-Length"))
+	require.Equal(t, dockerContentDigest, response.Header().Get("Docker-Content-Digest"))
 }
 
 func TestIncorrectSendData(t *testing.T) {
@@ -177,6 +187,7 @@ func TestFailedOriginServer(t *testing.T) {
 func makeRequest(injector *Injector, data string) *httptest.ResponseRecorder {
 	w := httptest.NewRecorder()
 	r := httptest.NewRequest("GET", "/target", nil)
+	r.Header.Set("Overridden-Header", "request")
 
 	sendData := base64.StdEncoding.EncodeToString([]byte(data))
 	injector.Inject(w, r, sendData)