diff --git a/app/controllers/admin/rules_controller.rb b/app/controllers/admin/rules_controller.rb
index b9331e7e9..58d52e629 100644
--- a/app/controllers/admin/rules_controller.rb
+++ b/app/controllers/admin/rules_controller.rb
@@ -7,7 +7,7 @@ module Admin
     def index
       authorize :rule, :index?
 
-      @rules = Rule.ordered
+      @rules = Rule.ordered.includes(:translations)
     end
 
     def new
@@ -27,7 +27,6 @@ module Admin
       if @rule.save
         redirect_to admin_rules_path
       else
-        @rules = Rule.ordered
         render :new
       end
     end
@@ -74,7 +73,7 @@ module Admin
 
     def resource_params
       params
-        .expect(rule: [:text, :hint, :priority])
+        .expect(rule: [:text, :hint, :priority, translations_attributes: [[:id, :language, :text, :hint, :_destroy]]])
     end
   end
 end
diff --git a/app/controllers/api/v1/instances/rules_controller.rb b/app/controllers/api/v1/instances/rules_controller.rb
index 3930eec0d..2b6e53487 100644
--- a/app/controllers/api/v1/instances/rules_controller.rb
+++ b/app/controllers/api/v1/instances/rules_controller.rb
@@ -18,6 +18,6 @@ class Api::V1::Instances::RulesController < Api::V1::Instances::BaseController
   private
 
   def set_rules
-    @rules = Rule.ordered
+    @rules = Rule.ordered.includes(:translations)
   end
 end
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 0b6f5b3af..973724cf7 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -126,7 +126,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   end
 
   def set_rules
-    @rules = Rule.ordered
+    @rules = Rule.ordered.includes(:translations)
   end
 
   def require_rules_acceptance!
diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx
index 34e84506f..f2ea16a95 100644
--- a/app/javascript/mastodon/features/about/index.jsx
+++ b/app/javascript/mastodon/features/about/index.jsx
@@ -44,6 +44,7 @@ const severityMessages = {
 
 const mapStateToProps = state => ({
   server: state.getIn(['server', 'server']),
+  locale: state.getIn(['meta', 'locale']),
   extendedDescription: state.getIn(['server', 'extendedDescription']),
   domainBlocks: state.getIn(['server', 'domainBlocks']),
 });
@@ -91,6 +92,7 @@ class About extends PureComponent {
 
   static propTypes = {
     server: ImmutablePropTypes.map,
+    locale: ImmutablePropTypes.string,
     extendedDescription: ImmutablePropTypes.map,
     domainBlocks: ImmutablePropTypes.contains({
       isLoading: PropTypes.bool,
@@ -114,7 +116,7 @@ class About extends PureComponent {
   };
 
   render () {
-    const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
+    const { multiColumn, intl, server, extendedDescription, domainBlocks, locale } = this.props;
     const isLoading = server.get('isLoading');
 
     return (
@@ -168,12 +170,15 @@ class About extends PureComponent {
               
             ) : (
               
-                {server.get('rules').map(rule => (
-                  - 
-                    
{rule.get('text')}
-                    {rule.get('hint').length > 0 && ({rule.get('hint')}
)}
-                   
-                ))}
+                {server.get('rules').map(rule => {
+                  const text = rule.getIn(['translations', locale, 'text']) || rule.get('text');
+                  const hint = rule.getIn(['translations', locale, 'hint']) || rule.get('hint');
+                  return (
+                    - 
+                      
{text}
+                      {hint.length > 0 && ({hint}
)}
+                     
+                  )})}
               
             ))}
           
diff --git a/app/javascript/mastodon/features/report/rules.jsx b/app/javascript/mastodon/features/report/rules.jsx
index 621f140ad..dff376937 100644
--- a/app/javascript/mastodon/features/report/rules.jsx
+++ b/app/javascript/mastodon/features/report/rules.jsx
@@ -12,6 +12,7 @@ import Option from './components/option';
 
 const mapStateToProps = state => ({
   rules: state.getIn(['server', 'server', 'rules']),
+  locale: state.getIn(['meta', 'locale']),
 });
 
 class Rules extends PureComponent {
@@ -19,6 +20,7 @@ class Rules extends PureComponent {
   static propTypes = {
     onNextStep: PropTypes.func.isRequired,
     rules: ImmutablePropTypes.list,
+    locale: PropTypes.string,
     selectedRuleIds: ImmutablePropTypes.set.isRequired,
     onToggle: PropTypes.func.isRequired,
   };
@@ -34,7 +36,7 @@ class Rules extends PureComponent {
   };
 
   render () {
-    const { rules, selectedRuleIds } = this.props;
+    const { rules, locale, selectedRuleIds } = this.props;
 
     return (
       <>
@@ -49,7 +51,7 @@ class Rules extends PureComponent {
               value={item.get('id')}
               checked={selectedRuleIds.includes(item.get('id'))}
               onToggle={this.handleRulesToggle}
-              label={item.get('text')}
+              label={item.getIn(['translations', locale, 'text']) || item.get('text')}
               multiple
             />
           ))}
diff --git a/app/models/rule.rb b/app/models/rule.rb
index 3033a2b03..8f36f11ab 100644
--- a/app/models/rule.rb
+++ b/app/models/rule.rb
@@ -19,6 +19,9 @@ class Rule < ApplicationRecord
 
   self.discard_column = :deleted_at
 
+  has_many :translations, inverse_of: :rule, class_name: 'RuleTranslation', dependent: :destroy
+  accepts_nested_attributes_for :translations, reject_if: :all_blank, allow_destroy: true
+
   validates :text, presence: true, length: { maximum: TEXT_SIZE_LIMIT }
 
   scope :ordered, -> { kept.order(priority: :asc, id: :asc) }
@@ -36,4 +39,9 @@ class Rule < ApplicationRecord
       end
     end
   end
+
+  def translation_for(locale)
+    @cached_translations ||= {}
+    @cached_translations[locale] ||= translations.find_by(language: locale) || RuleTranslation.new(language: locale, text: text, hint: hint)
+  end
 end
diff --git a/app/models/rule_translation.rb b/app/models/rule_translation.rb
new file mode 100644
index 000000000..99991b2ee
--- /dev/null
+++ b/app/models/rule_translation.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: rule_translations
+#
+#  id         :bigint(8)        not null, primary key
+#  hint       :text             default(""), not null
+#  language   :string           not null
+#  text       :text             default(""), not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#  rule_id    :bigint(8)        not null
+#
+class RuleTranslation < ApplicationRecord
+  belongs_to :rule
+
+  validates :language, presence: true, uniqueness: { scope: :rule_id }
+  validates :text, presence: true, length: { maximum: Rule::TEXT_SIZE_LIMIT }
+end
diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb
index 92415a690..6923f565e 100644
--- a/app/presenters/instance_presenter.rb
+++ b/app/presenters/instance_presenter.rb
@@ -47,7 +47,7 @@ class InstancePresenter < ActiveModelSerializers::Model
   end
 
   def rules
-    Rule.ordered
+    Rule.ordered.includes(:translations)
   end
 
   def user_count
diff --git a/app/serializers/rest/rule_serializer.rb b/app/serializers/rest/rule_serializer.rb
index 9e2bcda15..3ce2d02e6 100644
--- a/app/serializers/rest/rule_serializer.rb
+++ b/app/serializers/rest/rule_serializer.rb
@@ -1,9 +1,15 @@
 # frozen_string_literal: true
 
 class REST::RuleSerializer < ActiveModel::Serializer
-  attributes :id, :text, :hint
+  attributes :id, :text, :hint, :translations
 
   def id
     object.id.to_s
   end
+
+  def translations
+    object.translations.to_h do |translation|
+      [translation.language, { text: translation.text, hint: translation.hint }]
+    end
+  end
 end
diff --git a/app/views/admin/rules/_translation_fields.html.haml b/app/views/admin/rules/_translation_fields.html.haml
new file mode 100644
index 000000000..bf8d81722
--- /dev/null
+++ b/app/views/admin/rules/_translation_fields.html.haml
@@ -0,0 +1,27 @@
+%tr.nested-fields
+  %td
+    .fields-row
+      .fields-row__column.fields-group
+        = f.input :language,
+                  collection: ui_languages,
+                  include_blank: false,
+                  label_method: ->(locale) { native_locale_name(locale) }
+
+      .fields-row__column.fields-group
+        = f.hidden_field :id if f.object&.persisted? # Required so Rails doesn't put the field outside of the 
+        = link_to_remove_association(f, class: 'table-action-link') do
+          = safe_join([material_symbol('close'), t('filters.index.delete')])
+
+    .fields-group
+      = f.input :text,
+                label: I18n.t('simple_form.labels.rule.text'),
+                hint: I18n.t('simple_form.hints.rule.text'),
+                input_html: { lang: f.object&.language },
+                wrapper: :with_block_label
+
+    .fields-group
+      = f.input :hint,
+                label: I18n.t('simple_form.labels.rule.hint'),
+                hint: I18n.t('simple_form.hints.rule.hint'),
+                input_html: { lang: f.object&.language },
+                wrapper: :with_block_label
diff --git a/app/views/admin/rules/edit.html.haml b/app/views/admin/rules/edit.html.haml
index 9e3c91581..b64a27d75 100644
--- a/app/views/admin/rules/edit.html.haml
+++ b/app/views/admin/rules/edit.html.haml
@@ -6,5 +6,26 @@
 
   = render form
 
+  %hr.spacer/
+
+  %h4= t('admin.rules.translations')
+
+  %p.hint= t('admin.rules.translations_explanation')
+
+  .table-wrapper
+    %table.table.keywords-table
+      %thead
+        %tr
+          %th= t('admin.rules.translation')
+          %th
+      %tbody
+        = form.simple_fields_for :translations do |translation|
+          = render 'translation_fields', f: translation
+      %tfoot
+        %tr
+          %td{ colspan: 3 }
+            = link_to_add_association form, :translations, class: 'table-action-link', partial: 'translation_fields', 'data-association-insertion-node': '.keywords-table tbody', 'data-association-insertion-method': 'append' do
+              = safe_join([material_symbol('add'), t('admin.rules.add_translation')])
+
   .actions
     = form.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/auth/registrations/rules.html.haml b/app/views/auth/registrations/rules.html.haml
index 4b0159e86..59e7c9072 100644
--- a/app/views/auth/registrations/rules.html.haml
+++ b/app/views/auth/registrations/rules.html.haml
@@ -18,10 +18,11 @@
 
   %ol.rules-list
     - @rules.each do |rule|
+      - translation = rule.translation_for(I18n.locale.to_s)
       %li
         %button{ type: 'button', aria: { expanded: 'false' } }
-          .rules-list__text= rule.text
-          .rules-list__hint= rule.hint
+          .rules-list__text= translation.text
+          .rules-list__hint= translation.hint
 
   .stacked-actions
     - accept_path = @invite_code.present? ? public_invite_url(invite_code: @invite_code, accept: @accept_token) : new_user_registration_path(accept: @accept_token)
diff --git a/config/locales/en.yml b/config/locales/en.yml
index dad46d9d5..2ab8f015d 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -786,6 +786,7 @@ en:
       title: Roles
     rules:
       add_new: Add rule
+      add_translation: Add translation
       delete: Delete
       description_html: While most claim to have read and agree to the terms of service, usually people do not read through until after a problem arises. Make it easier to see your server's rules at a glance by providing them in a flat bullet point list. Try to keep individual rules short and simple, but try not to split them up into many separate items either.
       edit: Edit rule
@@ -793,6 +794,9 @@ en:
       move_down: Move down
       move_up: Move up
       title: Server rules
+      translation: Translation
+      translations: Translations
+      translations_explanation: You can optionally add translations for the rules. The default value will be shown if no translated version is available. Please always ensure any provided translation is in sync with the default value.
     settings:
       about:
         manage_rules: Manage server rules
diff --git a/db/migrate/20250520204643_create_rule_translations.rb b/db/migrate/20250520204643_create_rule_translations.rb
new file mode 100644
index 000000000..cf696e769
--- /dev/null
+++ b/db/migrate/20250520204643_create_rule_translations.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class CreateRuleTranslations < ActiveRecord::Migration[8.0]
+  def change
+    create_table :rule_translations do |t|
+      t.text :text, null: false, default: ''
+      t.text :hint, null: false, default: ''
+      t.string :language, null: false
+      t.references :rule, null: false, foreign_key: { on_delete: :cascade }, index: false
+
+      t.timestamps
+    end
+
+    add_index :rule_translations, [:rule_id, :language], unique: true
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 6cb2a667a..6c97151bb 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema[8.0].define(version: 2025_05_20_192024) do
+ActiveRecord::Schema[8.0].define(version: 2025_05_20_204643) do
   # These are extensions that must be enabled in order to support this database
   enable_extension "pg_catalog.plpgsql"
 
@@ -962,6 +962,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_20_192024) do
     t.index ["target_account_id"], name: "index_reports_on_target_account_id"
   end
 
+  create_table "rule_translations", force: :cascade do |t|
+    t.text "text", default: "", null: false
+    t.text "hint", default: "", null: false
+    t.string "language", null: false
+    t.bigint "rule_id", null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["rule_id", "language"], name: "index_rule_translations_on_rule_id_and_language", unique: true
+  end
+
   create_table "rules", force: :cascade do |t|
     t.integer "priority", default: 0, null: false
     t.datetime "deleted_at", precision: nil
@@ -1406,6 +1416,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_20_192024) do
   add_foreign_key "reports", "accounts", column: "target_account_id", name: "fk_eb37af34f0", on_delete: :cascade
   add_foreign_key "reports", "accounts", name: "fk_4b81f7522c", on_delete: :cascade
   add_foreign_key "reports", "oauth_applications", column: "application_id", on_delete: :nullify
+  add_foreign_key "rule_translations", "rules", on_delete: :cascade
   add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade
   add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade
   add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade
diff --git a/spec/fabricators/rule_translation_fabricator.rb b/spec/fabricators/rule_translation_fabricator.rb
new file mode 100644
index 000000000..de29e47e7
--- /dev/null
+++ b/spec/fabricators/rule_translation_fabricator.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+Fabricator(:rule_translation) do
+  text     'MyText'
+  hint     'MyText'
+  language 'en'
+  rule     { Fabricate.build(:rule) }
+end
diff --git a/spec/serializers/rest/rule_serializer_spec.rb b/spec/serializers/rest/rule_serializer_spec.rb
index 4d801e77d..9d2889c9f 100644
--- a/spec/serializers/rest/rule_serializer_spec.rb
+++ b/spec/serializers/rest/rule_serializer_spec.rb
@@ -11,7 +11,8 @@ RSpec.describe REST::RuleSerializer do
     it 'returns expected values' do
       expect(subject)
         .to include(
-          'id' => be_a(String).and(eq('123'))
+          'id' => be_a(String).and(eq('123')),
+          'translations' => be_a(Hash)
         )
     end
   end