diff --git a/db/docs/group_features.yml b/db/docs/group_features.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a7b80f586ca82c85a4a9dbe8d4a8698447efe484
--- /dev/null
+++ b/db/docs/group_features.yml
@@ -0,0 +1,9 @@
+---
+table_name: group_features
+classes:
+- Groups::FeatureSetting
+feature_categories:
+- subgroups
+description: TODO
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82017
+milestone: '14.10'
diff --git a/db/docs/project_build_artifacts_size_refreshes.yml b/db/docs/project_build_artifacts_size_refreshes.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8f07ab9b3e1c374b3f879844a748da17e1402747
--- /dev/null
+++ b/db/docs/project_build_artifacts_size_refreshes.yml
@@ -0,0 +1,9 @@
+---
+table_name: project_build_artifacts_size_refreshes
+classes:
+- Projects::BuildArtifactsSizeRefresh
+feature_categories:
+- build_artifacts
+description: TODO
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81306
+milestone: '14.9'
diff --git a/db/docs/protected_environment_approval_rules.yml b/db/docs/protected_environment_approval_rules.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ea7f0e1d05d16398eafda217c62dc60799d9d18a
--- /dev/null
+++ b/db/docs/protected_environment_approval_rules.yml
@@ -0,0 +1,9 @@
+---
+table_name: protected_environment_approval_rules
+classes:
+- ProtectedEnvironments::ApprovalRule
+feature_categories:
+- continuous_delivery
+description: TODO
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82800
+milestone: '14.10'
diff --git a/spec/db/docs_spec.rb b/spec/db/docs_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..20746e107fbb83cef5556d5b7d3267e84d5c079f
--- /dev/null
+++ b/spec/db/docs_spec.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Database Documentation' do
+  context 'for each table' do
+    let(:all_tables) do
+      Gitlab::Database.database_base_models.flat_map { |_, m| m.connection.tables }.sort.uniq
+    end
+
+    let(:metadata_required_fields) do
+      %i(
+        feature_categories
+        table_name
+      )
+    end
+
+    let(:metadata_allowed_fields) do
+      metadata_required_fields + %i(
+        classes
+        description
+        introduced_by_url
+        milestone
+      )
+    end
+
+    let(:metadata) do
+      all_tables.each_with_object({}) do |table_name, hash|
+        next unless File.exist?(table_metadata_file_path(table_name))
+
+        hash[table_name] ||= load_table_metadata(table_name)
+      end
+    end
+
+    let(:tables_without_metadata) do
+      all_tables.reject { |t| metadata.has_key?(t) }
+    end
+
+    let(:tables_without_valid_metadata) do
+      metadata.select { |_, t| t.has_key?(:error) }.keys
+    end
+
+    let(:tables_with_disallowed_fields) do
+      metadata.select { |_, t| t.has_key?(:disallowed_fields) }.keys
+    end
+
+    let(:tables_with_missing_required_fields) do
+      metadata.select { |_, t| t.has_key?(:missing_required_fields) }.keys
+    end
+
+    it 'has a metadata file' do
+      expect(tables_without_metadata).to be_empty, multiline_error(
+        'Missing metadata files',
+        tables_without_metadata.map { |t| "  #{table_metadata_file(t)}" }
+      )
+    end
+
+    it 'has a valid metadata file' do
+      expect(tables_without_valid_metadata).to be_empty, table_metadata_errors(
+        'Table metadata files with errors',
+        :error,
+        tables_without_valid_metadata
+      )
+    end
+
+    it 'has a valid metadata file with allowed fields' do
+      expect(tables_with_disallowed_fields).to be_empty, table_metadata_errors(
+        'Table metadata files with disallowed fields',
+        :disallowed_fields,
+        tables_with_disallowed_fields
+      )
+    end
+
+    it 'has a valid metadata file without missing fields' do
+      expect(tables_with_missing_required_fields).to be_empty, table_metadata_errors(
+        'Table metadata files with missing fields',
+        :missing_required_fields,
+        tables_with_missing_required_fields
+      )
+    end
+  end
+
+  private
+
+  def table_metadata_file(table_name)
+    File.join('db', 'docs', "#{table_name}.yml")
+  end
+
+  def table_metadata_file_path(table_name)
+    Rails.root.join(table_metadata_file(table_name))
+  end
+
+  def load_table_metadata(table_name)
+    result = {}
+    begin
+      result[:metadata] = YAML.safe_load(File.read(table_metadata_file_path(table_name))).deep_symbolize_keys
+
+      disallowed_fields = (result[:metadata].keys - metadata_allowed_fields)
+      unless disallowed_fields.empty?
+        result[:disallowed_fields] = "fields not allowed: #{disallowed_fields.join(', ')}"
+      end
+
+      missing_required_fields = (metadata_required_fields - result[:metadata].reject { |_, v| v.blank? }.keys)
+      unless missing_required_fields.empty?
+        result[:missing_required_fields] = "missing required fields: #{missing_required_fields.join(', ')}"
+      end
+    rescue Psych::SyntaxError => ex
+      result[:error] = ex.message
+    end
+    result
+  end
+
+  def table_metadata_errors(title, field, tables)
+    lines = tables.map do |table_name|
+      <<~EOM
+        #{table_metadata_file(table_name)}
+          #{metadata[table_name][field]}
+      EOM
+    end
+
+    multiline_error(title, lines)
+  end
+
+  def multiline_error(title, lines)
+    <<~EOM
+      #{title}:
+
+      #{lines.join("\n")}
+    EOM
+  end
+end