diff --git a/config/open_api.yml b/config/open_api.yml
index cbf70c24ce1ce25096062b210f775b72e596528a..257db9dd69258282dcc63bc7d990dfc2cffa75bf 100644
--- a/config/open_api.yml
+++ b/config/open_api.yml
@@ -95,6 +95,8 @@ metadata:
     description: Operations related to metadata of the GitLab instance
   - name: metrics_user_starred_dashboards
     description: Operations related to User-starred metrics dashboards
+  - name: ml_model_registry
+    description: Operations related to Model registry
   - name: npm_packages
     description: Operations related to NPM packages
   - name: nuget_packages
diff --git a/lib/api/api.rb b/lib/api/api.rb
index a7acd44e72a2bb8a0965e21295b72e9aeb7d0ce0..5f7faa7eb7d37f70993b742f7fb157e3534da3e5 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -255,6 +255,7 @@ class API < ::API::Base
         mount ::API::Metadata
         mount ::API::Metrics::Dashboard::Annotations
         mount ::API::Metrics::UserStarredDashboards
+        mount ::API::MlModelPackages
         mount ::API::Namespaces
         mount ::API::NpmGroupPackages
         mount ::API::NpmInstancePackages
diff --git a/lib/api/ml_model_packages.rb b/lib/api/ml_model_packages.rb
new file mode 100644
index 0000000000000000000000000000000000000000..fec72b03ffd419e4eb5db9df610fbe7d02a4826c
--- /dev/null
+++ b/lib/api/ml_model_packages.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+module API
+  class MlModelPackages < ::API::Base
+    include APIGuard
+    include ::API::Helpers::Authentication
+
+    ML_MODEL_PACKAGES_REQUIREMENTS = {
+      package_name: API::NO_SLASH_URL_PART_REGEX,
+      file_name: API::NO_SLASH_URL_PART_REGEX
+    }.freeze
+
+    ALLOWED_STATUSES = %w[default hidden].freeze
+
+    feature_category :mlops
+    urgency :low
+
+    after_validation do
+      require_packages_enabled!
+      authenticate_non_get!
+
+      not_found! unless can?(current_user, :read_model_registry, user_project)
+    end
+
+    authenticate_with do |accept|
+      accept.token_types(:personal_access_token, :deploy_token, :job_token)
+            .sent_through(:http_token)
+    end
+
+    helpers do
+      include ::API::Helpers::PackagesHelpers
+      include ::API::Helpers::Packages::BasicAuthHelpers
+
+      def project
+        authorized_user_project
+      end
+
+      def max_file_size_exceeded?
+        project.actual_limits.exceeded?(:ml_model_max_file_size, params[:file].size)
+      end
+    end
+
+    params do
+      requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project'
+    end
+
+    resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
+      namespace ':id/packages/ml_models' do
+        params do
+          requires :package_name, type: String, desc: 'Package name', regexp: Gitlab::Regex.ml_model_name_regex,
+            file_path: true
+          requires :package_version, type: String, desc: 'Package version',
+            regexp: Gitlab::Regex.ml_model_version_regex
+          requires :file_name, type: String, desc: 'Package file name',
+            regexp: Gitlab::Regex.ml_model_file_name_regex, file_path: true
+          optional :status, type: String, values: ALLOWED_STATUSES, desc: 'Package status'
+        end
+        namespace ':package_name/*package_version/:file_name', requirements: ML_MODEL_PACKAGES_REQUIREMENTS do
+          desc 'Workhorse authorize model package file' do
+            detail 'Introduced in GitLab 16.1'
+            success code: 200
+            failure [
+              { code: 401, message: 'Unauthorized' },
+              { code: 403, message: 'Forbidden' },
+              { code: 404, message: 'Not Found' }
+            ]
+            tags %w[ml_model_registry]
+          end
+          put 'authorize' do
+            authorize_workhorse!(subject: project, maximum_size: project.actual_limits.ml_model_max_file_size)
+          end
+
+          desc 'Workhorse upload model package file' do
+            detail 'Introduced in GitLab 16.1'
+            success code: 201
+            failure [
+              { code: 401, message: 'Unauthorized' },
+              { code: 403, message: 'Forbidden' },
+              { code: 404, message: 'Not Found' }
+            ]
+            tags %w[ml_model_registry]
+          end
+          params do
+            requires :file,
+              type: ::API::Validations::Types::WorkhorseFile,
+              desc: 'The package file to be published (generated by Multipart middleware)',
+              documentation: { type: 'file' }
+          end
+          put do
+            authorize_upload!(project)
+
+            bad_request!('File is too large') if max_file_size_exceeded?
+
+            create_package_file_params = declared(params).merge(build: current_authenticated_job)
+            package_file = ::Packages::MlModel::CreatePackageFileService
+                             .new(project, current_user, create_package_file_params)
+                             .execute
+
+            bad_request!('Package creation failed') unless package_file
+
+            created!
+          rescue ObjectStorage::RemoteStoreError => e
+            Gitlab::ErrorTracking.track_exception(e, extra: { file_name: params[:file_name], project_id: project.id })
+
+            forbidden!
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb
index 79f12ee13f78d82cdc313adcfdfe4c2600ee684a..26ca9d2547c862f629789ad6a500709516ead425 100644
--- a/lib/gitlab/regex.rb
+++ b/lib/gitlab/regex.rb
@@ -623,6 +623,18 @@ def sep_by_1(separator, part)
     def x509_subject_key_identifier_regex
       @x509_subject_key_identifier_regex ||= /\A(?:\h{2}:)*\h{2}\z/.freeze
     end
+
+    def ml_model_version_regex
+      maven_version_regex
+    end
+
+    def ml_model_name_regex
+      package_name_regex
+    end
+
+    def ml_model_file_name_regex
+      maven_file_name_regex
+    end
   end
 end
 
diff --git a/spec/requests/api/ml_model_packages_spec.rb b/spec/requests/api/ml_model_packages_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9c19f522e46cc2be675bd430772948594edc9312
--- /dev/null
+++ b/spec/requests/api/ml_model_packages_spec.rb
@@ -0,0 +1,200 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::API::MlModelPackages, feature_category: :mlops do
+  include HttpBasicAuthHelpers
+  include PackagesManagerApiSpecHelpers
+  include WorkhorseHelpers
+  using RSpec::Parameterized::TableSyntax
+
+  include_context 'workhorse headers'
+
+  let_it_be(:project, reload: true) { create(:project) }
+  let_it_be(:personal_access_token) { create(:personal_access_token) }
+  let_it_be(:job) { create(:ci_build, :running, user: personal_access_token.user, project: project) }
+  let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
+  let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
+  let_it_be(:another_project, reload: true) { create(:project) }
+
+  let_it_be(:tokens) do
+    {
+      personal_access_token: personal_access_token.token,
+      deploy_token: deploy_token.token,
+      job_token: job.token
+    }
+  end
+
+  let(:user) { personal_access_token.user }
+  let(:user_role) { :developer }
+  let(:member) { true }
+  let(:ci_build) { create(:ci_build, :running, user: user, project: project) }
+  let(:project_to_enable_ff) { project }
+  let(:headers) { {} }
+
+  shared_context 'ml model authorize permissions table' do # rubocop:disable RSpec/ContextWording
+    # rubocop:disable Metrics/AbcSize
+    # :visibility, :user_role, :member, :token_type, :valid_token, :expected_status
+    def authorize_permissions_table
+      :public  | :developer  | true  | :personal_access_token | true  | :success
+      :public  | :guest      | true  | :personal_access_token | true  | :forbidden
+      :public  | :developer  | true  | :personal_access_token | false | :unauthorized
+      :public  | :guest      | true  | :personal_access_token | false | :unauthorized
+      :public  | :developer  | false | :personal_access_token | true  | :forbidden
+      :public  | :guest      | false | :personal_access_token | true  | :forbidden
+      :public  | :developer  | false | :personal_access_token | false | :unauthorized
+      :public  | :guest      | false | :personal_access_token | false | :unauthorized
+      :public  | :anonymous  | false | :personal_access_token | true  | :unauthorized
+      :private | :developer  | true  | :personal_access_token | true  | :success
+      :private | :guest      | true  | :personal_access_token | true  | :forbidden
+      :private | :developer  | true  | :personal_access_token | false | :unauthorized
+      :private | :guest      | true  | :personal_access_token | false | :unauthorized
+      :private | :developer  | false | :personal_access_token | true  | :not_found
+      :private | :guest      | false | :personal_access_token | true  | :not_found
+      :private | :developer  | false | :personal_access_token | false | :unauthorized
+      :private | :guest      | false | :personal_access_token | false | :unauthorized
+      :private | :anonymous  | false | :personal_access_token | true  | :unauthorized
+      :public  | :developer  | true  | :job_token             | true  | :success
+      :public  | :guest      | true  | :job_token             | true  | :forbidden
+      :public  | :developer  | true  | :job_token             | false | :unauthorized
+      :public  | :guest      | true  | :job_token             | false | :unauthorized
+      :public  | :developer  | false | :job_token             | true  | :forbidden
+      :public  | :guest      | false | :job_token             | true  | :forbidden
+      :public  | :developer  | false | :job_token             | false | :unauthorized
+      :public  | :guest      | false | :job_token             | false | :unauthorized
+      :private | :developer  | true  | :job_token             | true  | :success
+      :private | :guest      | true  | :job_token             | true  | :forbidden
+      :private | :developer  | true  | :job_token             | false | :unauthorized
+      :private | :guest      | true  | :job_token             | false | :unauthorized
+      :private | :developer  | false | :job_token             | true  | :not_found
+      :private | :guest      | false | :job_token             | true  | :not_found
+      :private | :developer  | false | :job_token             | false | :unauthorized
+      :private | :guest      | false | :job_token             | false | :unauthorized
+      :public  | :developer  | true  | :deploy_token          | true  | :success
+      :public  | :developer  | true  | :deploy_token          | false | :unauthorized
+      :private | :developer  | true  | :deploy_token          | true  | :success
+      :private | :developer  | true  | :deploy_token          | false | :unauthorized
+    end
+    # rubocop:enable Metrics/AbcSize
+  end
+
+  before do
+    project.send("add_#{user_role}", user) if member && user_role != :anonymous
+  end
+
+  subject(:api_response) do
+    request
+    response
+  end
+
+  describe 'PUT /api/v4/projects/:id/packages/ml_models/:package_name/:package_version/:file_name/authorize' do
+    include_context 'ml model authorize permissions table'
+
+    let(:token) { tokens[:personal_access_token] }
+    let(:user_headers) { { 'HTTP_AUTHORIZATION' => token } }
+    let(:headers) { user_headers.merge(workhorse_headers) }
+    let(:request) { authorize_upload_file(headers) }
+
+    describe 'user access' do
+      where(:visibility, :user_role, :member, :token_type, :valid_token, :expected_status) do
+        authorize_permissions_table
+      end
+
+      with_them do
+        let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' }
+        let(:user_headers) { user_role == :anonymous ? {} : { 'HTTP_AUTHORIZATION' => token } }
+
+        before do
+          project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility.to_s))
+        end
+
+        it { is_expected.to have_gitlab_http_status(expected_status) }
+      end
+
+      it_behaves_like 'Endpoint not found if read_model_registry not available'
+    end
+
+    describe 'application security' do
+      where(:param_name, :param_value) do
+        :package_name | 'my-package/../'
+        :package_name | 'my-package%2f%2e%2e%2f'
+        :file_name    | '../.ssh%2fauthorized_keys'
+        :file_name    | '%2e%2e%2f.ssh%2fauthorized_keys'
+      end
+
+      with_them do
+        let(:request) { authorize_upload_file(headers, param_name => param_value) }
+
+        it 'rejects malicious request' do
+          is_expected.to have_gitlab_http_status(:bad_request)
+        end
+      end
+    end
+  end
+
+  describe 'PUT /api/v4/projects/:id/packages/ml_models/:package_name/:package_version/:file_name' do
+    include_context 'ml model authorize permissions table'
+
+    let_it_be(:file_name) { 'model.md5' }
+
+    let(:token) { tokens[:personal_access_token] }
+    let(:user_headers) { { 'HTTP_AUTHORIZATION' => token } }
+    let(:headers) { user_headers.merge(workhorse_headers) }
+    let(:params) { { file: temp_file(file_name) } }
+    let(:file_key) { :file }
+    let(:send_rewritten_field) { true }
+
+    let(:request) do
+      upload_file(headers)
+    end
+
+    describe 'success' do
+      it 'creates a new package' do
+        expect { api_response }.to change { Packages::PackageFile.count }.by(1)
+        expect(api_response).to have_gitlab_http_status(:created)
+      end
+    end
+
+    describe 'user access' do
+      where(:visibility, :user_role, :member, :token_type, :valid_token, :expected_status) do
+        authorize_permissions_table
+      end
+
+      with_them do
+        let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' }
+        let(:user_headers) { user_role == :anonymous ? {} : { 'HTTP_AUTHORIZATION' => token } }
+
+        before do
+          project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility.to_s))
+        end
+
+        if params[:expected_status] == :success
+          it_behaves_like 'process ml model package upload'
+        else
+          it { is_expected.to have_gitlab_http_status(expected_status) }
+        end
+      end
+
+      it_behaves_like 'Endpoint not found if read_model_registry not available'
+    end
+  end
+
+  def authorize_upload_file(request_headers, package_name: 'mypackage', file_name: 'myfile.tar.gz')
+    url = "/projects/#{project.id}/packages/ml_models/#{package_name}/0.0.1/#{file_name}/authorize"
+
+    put api(url), headers: request_headers
+  end
+
+  def upload_file(request_headers, package_name: 'mypackage')
+    url = "/projects/#{project.id}/packages/ml_models/#{package_name}/0.0.1/#{file_name}"
+
+    workhorse_finalize(
+      api(url),
+      method: :put,
+      file_key: file_key,
+      params: params,
+      headers: request_headers,
+      send_rewritten_field: send_rewritten_field
+    )
+  end
+end
diff --git a/spec/support/shared_examples/requests/api/ml_model_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/ml_model_packages_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..81ff004779af2405a4d2d5c58a7baf0c96fd17bf
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/ml_model_packages_shared_examples.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'Endpoint not found if read_model_registry not available' do
+  context 'when read_model_registry disabled for current project' do
+    before do
+      allow(Ability).to receive(:allowed?).and_call_original
+      allow(Ability).to receive(:allowed?)
+                          .with(user, :read_model_registry, project)
+                          .and_return(false)
+    end
+
+    it "is not found" do
+      is_expected.to have_gitlab_http_status(:not_found)
+    end
+  end
+end
+
+RSpec.shared_examples 'creates model experiments package files' do
+  it 'creates package files', :aggregate_failures do
+    expect { api_response }
+      .to change { project.packages.count }.by(1)
+                                           .and change { Packages::PackageFile.count }.by(1)
+    expect(api_response).to have_gitlab_http_status(:created)
+
+    package_file = project.packages.last.package_files.reload.last
+    expect(package_file.file_name).to eq(file_name)
+  end
+
+  it 'returns bad request if package creation fails' do
+    allow_next_instance_of(::Packages::MlModel::CreatePackageFileService) do |instance|
+      expect(instance).to receive(:execute).and_return(nil)
+    end
+
+    expect(api_response).to have_gitlab_http_status(:bad_request)
+  end
+
+  context 'when file is too large' do
+    it 'is bad request', :aggregate_failures do
+      allow_next_instance_of(UploadedFile) do |uploaded_file|
+        allow(uploaded_file).to receive(:size).and_return(project.actual_limits.ml_model_max_file_size + 1)
+      end
+
+      expect(api_response).to have_gitlab_http_status(:bad_request)
+    end
+  end
+end
+
+RSpec.shared_examples 'process ml model package upload' do
+  context 'with object storage disabled' do
+    before do
+      stub_package_file_object_storage(enabled: false)
+    end
+
+    context 'without a file from workhorse' do
+      let(:send_rewritten_field) { false }
+
+      it_behaves_like 'returning response status', :bad_request
+    end
+
+    context 'with correct params' do
+      it_behaves_like 'package workhorse uploads'
+      it_behaves_like 'creates model experiments package files'
+      # To be reactivated with https://gitlab.com/gitlab-org/gitlab/-/issues/414270
+      # it_behaves_like 'a package tracking event', '::API::MlModelPackages', 'push_package'
+    end
+  end
+
+  context 'with object storage enabled' do
+    let(:tmp_object) do
+      fog_connection.directories.new(key: 'packages').files.create( # rubocop:disable Rails/SaveBang
+        key: "tmp/uploads/#{file_name}",
+        body: 'content'
+      )
+    end
+
+    let(:fog_file) { fog_to_uploaded_file(tmp_object) }
+    let(:params) { { file: fog_file, 'file.remote_id' => file_name } }
+
+    context 'and direct upload enabled' do
+      let(:fog_connection) do
+        stub_package_file_object_storage(direct_upload: true)
+      end
+
+      it_behaves_like 'creates model experiments package files'
+
+      ['123123', '../../123123'].each do |remote_id|
+        context "with invalid remote_id: #{remote_id}" do
+          let(:params) do
+            {
+              file: fog_file,
+              'file.remote_id' => remote_id
+            }
+          end
+
+          it { is_expected.to have_gitlab_http_status(:forbidden) }
+        end
+      end
+    end
+
+    context 'and direct upload disabled' do
+      let(:fog_connection) do
+        stub_package_file_object_storage(direct_upload: false)
+      end
+
+      it_behaves_like 'creates model experiments package files'
+    end
+  end
+end
diff --git a/workhorse/internal/upstream/routes.go b/workhorse/internal/upstream/routes.go
index c5af9dc3f006fa67611e09dc9b79af1fd2367a97..dd1725a723e515310e4a1127cc8b9bee0999e1a4 100644
--- a/workhorse/internal/upstream/routes.go
+++ b/workhorse/internal/upstream/routes.go
@@ -279,6 +279,9 @@ func configureRoutes(u *upstream) {
 		// Generic Packages Repository
 		u.route("PUT", apiProjectPattern+`/packages/generic/`, requestBodyUploader),
 
+		// Ml Model Packages Repository
+		u.route("PUT", apiProjectPattern+`/packages/ml_models/`, requestBodyUploader),
+
 		// NuGet Artifact Repository
 		u.route("PUT", apiProjectPattern+`/packages/nuget/`, mimeMultipartUploader),
 
diff --git a/workhorse/upload_test.go b/workhorse/upload_test.go
index c05af7317973b4e752a55f28cc6ad8fe051554ae..8effe29197930a712b70de2627a325dc93d69af6 100644
--- a/workhorse/upload_test.go
+++ b/workhorse/upload_test.go
@@ -580,6 +580,7 @@ func TestPackageFilesUpload(t *testing.T) {
 		{"PUT", "/api/v4/projects/group%2Fproject/packages/conan/v1/files"},
 		{"PUT", "/api/v4/projects/group%2Fproject/packages/maven/v1/files"},
 		{"PUT", "/api/v4/projects/group%2Fproject/packages/generic/mypackage/0.0.1/myfile.tar.gz"},
+		{"PUT", "/api/v4/projects/group%2Fproject/packages/ml_models/mymodel/0.0.1/myfile.tar.gz"},
 		{"PUT", "/api/v4/projects/group%2Fproject/packages/debian/libsample0_1.2.3~alpha2-1_amd64.deb"},
 		{"POST", "/api/v4/projects/group%2Fproject/packages/rubygems/api/v1/gems/sample.gem"},
 		{"POST", "/api/v4/projects/group%2Fproject/packages/rpm/sample-4.23.fc21.x86_64.rpm"},