diff --git a/app/models/achievements/achievement.rb b/app/models/achievements/achievement.rb
new file mode 100644
index 0000000000000000000000000000000000000000..904961491b54225d130414d0bb08f95ef574e9b1
--- /dev/null
+++ b/app/models/achievements/achievement.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Achievements
+  class Achievement < ApplicationRecord
+    include Avatarable
+    include StripAttribute
+
+    belongs_to :namespace, inverse_of: :achievements, optional: false
+
+    strip_attributes! :name, :description
+
+    validates :name,
+              presence: true,
+              length: { maximum: 255 },
+              uniqueness: { case_sensitive: false, scope: [:namespace_id] }
+    validates :description, length: { maximum: 1024 }
+  end
+end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 5ee4fbedd5f8dafbd1259472a8a47760f261f284..54cd97253cf06be8c6c03d693fffaefd0a37fa4d 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -86,6 +86,7 @@ class Namespace < ApplicationRecord
   has_many :issues, inverse_of: :namespace
 
   has_many :timelog_categories, class_name: 'TimeTracking::TimelogCategory'
+  has_many :achievements, class_name: 'Achievements::Achievement'
 
   validates :owner, presence: true, if: ->(n) { n.owner_required? }
   validates :name,
diff --git a/db/docs/achievements.yml b/db/docs/achievements.yml
new file mode 100644
index 0000000000000000000000000000000000000000..20f9d1616b321ec27aa248a76cbed70281e64aef
--- /dev/null
+++ b/db/docs/achievements.yml
@@ -0,0 +1,10 @@
+---
+table_name: achievements
+classes:
+- Achievements::Achivement
+feature_categories:
+- users
+description: Achievements which can be created by namespaces to award them to users
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/105871
+milestone: '15.7'
+gitlab_schema: gitlab_main
diff --git a/db/migrate/20221202144210_create_achievements.rb b/db/migrate/20221202144210_create_achievements.rb
new file mode 100644
index 0000000000000000000000000000000000000000..30b2fd528eebe310f1272ae610c55600e8f6fc21
--- /dev/null
+++ b/db/migrate/20221202144210_create_achievements.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class CreateAchievements < Gitlab::Database::Migration[2.1]
+  enable_lock_retries!
+
+  def up
+    create_table :achievements do |t|
+      t.references :namespace,
+                   null: false,
+                   index: false,
+                   foreign_key: { on_delete: :cascade }
+      t.timestamps_with_timezone null: false
+      t.text :name, null: false, limit: 255
+      t.text :avatar, limit: 255
+      t.text :description, limit: 1024
+      t.boolean :revokeable, default: false, null: false
+      t.index 'namespace_id, LOWER(name)', unique: true
+    end
+  end
+
+  def down
+    drop_table :achievements
+  end
+end
diff --git a/db/schema_migrations/20221202144210 b/db/schema_migrations/20221202144210
new file mode 100644
index 0000000000000000000000000000000000000000..3b37793b1a96a883ba43e39b82a597f894886fda
--- /dev/null
+++ b/db/schema_migrations/20221202144210
@@ -0,0 +1 @@
+5e29c2ebe99ef811cac0f894b3a77d2d158ba43070fb924c663db4622b8e79d7
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 2465f70c59d35da5772ccbdbe879e60f47a62d6a..74c2a23ce4d1fa4e7046673468f1775eb7750b29 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -10614,6 +10614,29 @@ CREATE SEQUENCE abuse_reports_id_seq
 
 ALTER SEQUENCE abuse_reports_id_seq OWNED BY abuse_reports.id;
 
+CREATE TABLE achievements (
+    id bigint NOT NULL,
+    namespace_id bigint NOT NULL,
+    created_at timestamp with time zone NOT NULL,
+    updated_at timestamp with time zone NOT NULL,
+    name text NOT NULL,
+    avatar text,
+    description text,
+    revokeable boolean DEFAULT false NOT NULL,
+    CONSTRAINT check_5171b03f22 CHECK ((char_length(name) <= 255)),
+    CONSTRAINT check_a7a7b84a80 CHECK ((char_length(description) <= 1024)),
+    CONSTRAINT check_e174e93a9e CHECK ((char_length(avatar) <= 255))
+);
+
+CREATE SEQUENCE achievements_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+ALTER SEQUENCE achievements_id_seq OWNED BY achievements.id;
+
 CREATE TABLE agent_activity_events (
     id bigint NOT NULL,
     agent_id bigint NOT NULL,
@@ -23585,6 +23608,8 @@ ALTER SEQUENCE zoom_meetings_id_seq OWNED BY zoom_meetings.id;
 
 ALTER TABLE ONLY abuse_reports ALTER COLUMN id SET DEFAULT nextval('abuse_reports_id_seq'::regclass);
 
+ALTER TABLE ONLY achievements ALTER COLUMN id SET DEFAULT nextval('achievements_id_seq'::regclass);
+
 ALTER TABLE ONLY agent_activity_events ALTER COLUMN id SET DEFAULT nextval('agent_activity_events_id_seq'::regclass);
 
 ALTER TABLE ONLY agent_group_authorizations ALTER COLUMN id SET DEFAULT nextval('agent_group_authorizations_id_seq'::regclass);
@@ -25224,6 +25249,9 @@ ALTER TABLE ONLY gitlab_partitions_static.product_analytics_events_experimental_
 ALTER TABLE ONLY abuse_reports
     ADD CONSTRAINT abuse_reports_pkey PRIMARY KEY (id);
 
+ALTER TABLE ONLY achievements
+    ADD CONSTRAINT achievements_pkey PRIMARY KEY (id);
+
 ALTER TABLE ONLY agent_activity_events
     ADD CONSTRAINT agent_activity_events_pkey PRIMARY KEY (id);
 
@@ -28233,6 +28261,8 @@ CREATE UNIQUE INDEX idx_work_item_types_on_namespace_id_and_name_null_namespace
 
 CREATE INDEX index_abuse_reports_on_user_id ON abuse_reports USING btree (user_id);
 
+CREATE UNIQUE INDEX "index_achievements_on_namespace_id_LOWER_name" ON achievements USING btree (namespace_id, lower(name));
+
 CREATE INDEX index_agent_activity_events_on_agent_id_and_recorded_at_and_id ON agent_activity_events USING btree (agent_id, recorded_at, id);
 
 CREATE INDEX index_agent_activity_events_on_agent_token_id ON agent_activity_events USING btree (agent_token_id) WHERE (agent_token_id IS NOT NULL);
@@ -34753,6 +34783,9 @@ ALTER TABLE ONLY ci_runner_namespaces
 ALTER TABLE ONLY software_license_policies
     ADD CONSTRAINT fk_rails_87b2247ce5 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
 
+ALTER TABLE ONLY achievements
+    ADD CONSTRAINT fk_rails_87e990f752 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE;
+
 ALTER TABLE ONLY protected_environment_deploy_access_levels
     ADD CONSTRAINT fk_rails_898a13b650 FOREIGN KEY (protected_environment_id) REFERENCES protected_environments(id) ON DELETE CASCADE;
 
diff --git a/lib/gitlab/database/gitlab_schemas.yml b/lib/gitlab/database/gitlab_schemas.yml
index 96588c20bbfd0347274f0cb3e899c516154e5084..faafd01f24875b1991309dab5e6df8a2e7235cf6 100644
--- a/lib/gitlab/database/gitlab_schemas.yml
+++ b/lib/gitlab/database/gitlab_schemas.yml
@@ -1,4 +1,5 @@
 abuse_reports: :gitlab_main
+achievements: :gitlab_main
 agent_activity_events: :gitlab_main
 agent_group_authorizations: :gitlab_main
 agent_project_authorizations: :gitlab_main
diff --git a/spec/factories/achievements/achievements.rb b/spec/factories/achievements/achievements.rb
new file mode 100644
index 0000000000000000000000000000000000000000..080a0376999e4a4e33996d35bbc834d6de37ff5b
--- /dev/null
+++ b/spec/factories/achievements/achievements.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory :achievement, class: 'Achievements::Achievement' do
+    namespace
+
+    name { generate(:name) }
+  end
+end
diff --git a/spec/models/achievements/achievement_spec.rb b/spec/models/achievements/achievement_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..10c04d184af7813118c01c0ad6f9cf09692d833c
--- /dev/null
+++ b/spec/models/achievements/achievement_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Achievements::Achievement, type: :model, feature_category: :users do
+  describe 'associations' do
+    it { is_expected.to belong_to(:namespace).required }
+  end
+
+  describe 'modules' do
+    subject { described_class }
+
+    it { is_expected.to include_module(Avatarable) }
+  end
+
+  describe 'validations' do
+    subject { create(:achievement) }
+
+    it { is_expected.to validate_presence_of(:name) }
+    it { is_expected.to validate_uniqueness_of(:name).case_insensitive.scoped_to([:namespace_id]) }
+    it { is_expected.to validate_length_of(:name).is_at_most(255) }
+    it { is_expected.to validate_length_of(:description).is_at_most(1024) }
+  end
+
+  describe '#name' do
+    it 'strips name' do
+      achievement = described_class.new(name: '  AchievementTest  ')
+      achievement.valid?
+
+      expect(achievement.name).to eq('AchievementTest')
+    end
+  end
+end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index 24e10e11d3a28ae9137078fe80014260b3a4e586..80721e110498f5575696a34560c589c83edda5c7 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -34,6 +34,7 @@
     it { is_expected.to have_many :member_roles }
     it { is_expected.to have_one :cluster_enabled_grant }
     it { is_expected.to have_many(:work_items) }
+    it { is_expected.to have_many :achievements }
 
     it do
       is_expected.to have_one(:ci_cd_settings).class_name('NamespaceCiCdSetting').inverse_of(:namespace).autosave(true)