diff --git a/contrib/pr/checkout.go b/contrib/pr/checkout.go
index 85476afd0..09510ac2c 100644
--- a/contrib/pr/checkout.go
+++ b/contrib/pr/checkout.go
@@ -14,7 +14,6 @@ import (
 	"fmt"
 	"log"
 	"net/http"
-	"net/url"
 	"os"
 	"os/exec"
 	"os/user"
@@ -62,11 +61,7 @@ func runPR() {
 	}
 	setting.AppWorkPath = curDir
 	setting.StaticRootPath = curDir
-	setting.GravatarSourceURL, err = url.Parse("https://secure.gravatar.com/avatar/")
-	if err != nil {
-		log.Fatalf("url.Parse: %v\n", err)
-	}
-
+	setting.GravatarSource = "https://secure.gravatar.com/avatar/"
 	setting.AppURL = "http://localhost:8080/"
 	setting.HTTPPort = "8080"
 	setting.SSH.Domain = "localhost"
diff --git a/models/admin/notice_test.go b/models/admin/notice_test.go
deleted file mode 100644
index f3767392d..000000000
--- a/models/admin/notice_test.go
+++ /dev/null
@@ -1,117 +0,0 @@
-// Copyright 2017 The Gitea Authors. All rights reserved.
-// Use of this source code is governed by a MIT-style
-// license that can be found in the LICENSE file.
-
-package admin_test
-
-import (
-	"testing"
-
-	"code.gitea.io/gitea/models/admin"
-	"code.gitea.io/gitea/models/db"
-	"code.gitea.io/gitea/models/unittest"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestNotice_TrStr(t *testing.T) {
-	notice := &admin.Notice{
-		Type:        admin.NoticeRepository,
-		Description: "test description",
-	}
-	assert.Equal(t, "admin.notices.type_1", notice.TrStr())
-}
-
-func TestCreateNotice(t *testing.T) {
-	assert.NoError(t, unittest.PrepareTestDatabase())
-
-	noticeBean := &admin.Notice{
-		Type:        admin.NoticeRepository,
-		Description: "test description",
-	}
-	unittest.AssertNotExistsBean(t, noticeBean)
-	assert.NoError(t, admin.CreateNotice(db.DefaultContext, noticeBean.Type, noticeBean.Description))
-	unittest.AssertExistsAndLoadBean(t, noticeBean)
-}
-
-func TestCreateRepositoryNotice(t *testing.T) {
-	assert.NoError(t, unittest.PrepareTestDatabase())
-
-	noticeBean := &admin.Notice{
-		Type:        admin.NoticeRepository,
-		Description: "test description",
-	}
-	unittest.AssertNotExistsBean(t, noticeBean)
-	assert.NoError(t, admin.CreateRepositoryNotice(noticeBean.Description))
-	unittest.AssertExistsAndLoadBean(t, noticeBean)
-}
-
-// TODO TestRemoveAllWithNotice
-
-func TestCountNotices(t *testing.T) {
-	assert.NoError(t, unittest.PrepareTestDatabase())
-	assert.Equal(t, int64(3), admin.CountNotices())
-}
-
-func TestNotices(t *testing.T) {
-	assert.NoError(t, unittest.PrepareTestDatabase())
-
-	notices, err := admin.Notices(1, 2)
-	assert.NoError(t, err)
-	if assert.Len(t, notices, 2) {
-		assert.Equal(t, int64(3), notices[0].ID)
-		assert.Equal(t, int64(2), notices[1].ID)
-	}
-
-	notices, err = admin.Notices(2, 2)
-	assert.NoError(t, err)
-	if assert.Len(t, notices, 1) {
-		assert.Equal(t, int64(1), notices[0].ID)
-	}
-}
-
-func TestDeleteNotice(t *testing.T) {
-	assert.NoError(t, unittest.PrepareTestDatabase())
-
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 3})
-	assert.NoError(t, admin.DeleteNotice(3))
-	unittest.AssertNotExistsBean(t, &admin.Notice{ID: 3})
-}
-
-func TestDeleteNotices(t *testing.T) {
-	// delete a non-empty range
-	assert.NoError(t, unittest.PrepareTestDatabase())
-
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 1})
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 2})
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 3})
-	assert.NoError(t, admin.DeleteNotices(1, 2))
-	unittest.AssertNotExistsBean(t, &admin.Notice{ID: 1})
-	unittest.AssertNotExistsBean(t, &admin.Notice{ID: 2})
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 3})
-}
-
-func TestDeleteNotices2(t *testing.T) {
-	// delete an empty range
-	assert.NoError(t, unittest.PrepareTestDatabase())
-
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 1})
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 2})
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 3})
-	assert.NoError(t, admin.DeleteNotices(3, 2))
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 1})
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 2})
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 3})
-}
-
-func TestDeleteNoticesByIDs(t *testing.T) {
-	assert.NoError(t, unittest.PrepareTestDatabase())
-
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 1})
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 2})
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 3})
-	assert.NoError(t, admin.DeleteNoticesByIDs([]int64{1, 3}))
-	unittest.AssertNotExistsBean(t, &admin.Notice{ID: 1})
-	unittest.AssertExistsAndLoadBean(t, &admin.Notice{ID: 2})
-	unittest.AssertNotExistsBean(t, &admin.Notice{ID: 3})
-}
diff --git a/models/avatars/avatar.go b/models/avatars/avatar.go
index 9f7b0c474..418e9b9cc 100644
--- a/models/avatars/avatar.go
+++ b/models/avatars/avatar.go
@@ -13,6 +13,7 @@ import (
 	"sync"
 
 	"code.gitea.io/gitea/models/db"
+	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/cache"
 	"code.gitea.io/gitea/modules/log"
@@ -72,7 +73,7 @@ func GetEmailForHash(md5Sum string) (string, error) {
 // LibravatarURL returns the URL for the given email. Slow due to the DNS lookup.
 // This function should only be called if a federated avatar service is enabled.
 func LibravatarURL(email string) (*url.URL, error) {
-	urlStr, err := setting.LibravatarService.FromEmail(email)
+	urlStr, err := system_model.LibravatarService.FromEmail(email)
 	if err != nil {
 		log.Error("LibravatarService.FromEmail(email=%s): error %v", email, err)
 		return nil, err
@@ -149,8 +150,10 @@ func generateEmailAvatarLink(email string, size int, final bool) string {
 		return DefaultAvatarLink()
 	}
 
+	enableFederatedAvatar, _ := system_model.GetSetting(system_model.KeyPictureEnableFederatedAvatar)
+
 	var err error
-	if setting.EnableFederatedAvatar && setting.LibravatarService != nil {
+	if enableFederatedAvatar != nil && enableFederatedAvatar.GetValueBool() && system_model.LibravatarService != nil {
 		emailHash := saveEmailHash(email)
 		if final {
 			// for final link, we can spend more time on slow external query
@@ -166,12 +169,16 @@ func generateEmailAvatarLink(email string, size int, final bool) string {
 			urlStr += "?size=" + strconv.Itoa(size)
 		}
 		return urlStr
-	} else if !setting.DisableGravatar {
+	}
+
+	disableGravatar, _ := system_model.GetSetting(system_model.KeyPictureDisableGravatar)
+	if disableGravatar != nil && !disableGravatar.GetValueBool() {
 		// copy GravatarSourceURL, because we will modify its Path.
-		avatarURLCopy := *setting.GravatarSourceURL
+		avatarURLCopy := *system_model.GravatarSourceURL
 		avatarURLCopy.Path = path.Join(avatarURLCopy.Path, HashEmail(email))
 		return generateRecognizedAvatarURL(avatarURLCopy, size)
 	}
+
 	return DefaultAvatarLink()
 }
 
diff --git a/models/avatars/avatar_test.go b/models/avatars/avatar_test.go
index 4d6255ca5..ace5445fc 100644
--- a/models/avatars/avatar_test.go
+++ b/models/avatars/avatar_test.go
@@ -2,12 +2,13 @@
 // Use of this source code is governed by a MIT-style
 // license that can be found in the LICENSE file.
 
-package avatars
+package avatars_test
 
 import (
-	"net/url"
 	"testing"
 
+	avatars_model "code.gitea.io/gitea/models/avatars"
+	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/modules/setting"
 
 	"github.com/stretchr/testify/assert"
@@ -15,40 +16,43 @@ import (
 
 const gravatarSource = "https://secure.gravatar.com/avatar/"
 
-func disableGravatar() {
-	setting.EnableFederatedAvatar = false
-	setting.LibravatarService = nil
-	setting.DisableGravatar = true
+func disableGravatar(t *testing.T) {
+	err := system_model.SetSettingNoVersion(system_model.KeyPictureEnableFederatedAvatar, "false")
+	assert.NoError(t, err)
+	err = system_model.SetSettingNoVersion(system_model.KeyPictureDisableGravatar, "true")
+	assert.NoError(t, err)
+	system_model.LibravatarService = nil
 }
 
 func enableGravatar(t *testing.T) {
-	setting.DisableGravatar = false
-	var err error
-	setting.GravatarSourceURL, err = url.Parse(gravatarSource)
+	err := system_model.SetSettingNoVersion(system_model.KeyPictureDisableGravatar, "false")
+	assert.NoError(t, err)
+	setting.GravatarSource = gravatarSource
+	err = system_model.Init()
 	assert.NoError(t, err)
 }
 
 func TestHashEmail(t *testing.T) {
 	assert.Equal(t,
 		"d41d8cd98f00b204e9800998ecf8427e",
-		HashEmail(""),
+		avatars_model.HashEmail(""),
 	)
 	assert.Equal(t,
 		"353cbad9b58e69c96154ad99f92bedc7",
-		HashEmail("gitea@example.com"),
+		avatars_model.HashEmail("gitea@example.com"),
 	)
 }
 
 func TestSizedAvatarLink(t *testing.T) {
 	setting.AppSubURL = "/testsuburl"
 
-	disableGravatar()
+	disableGravatar(t)
 	assert.Equal(t, "/testsuburl/assets/img/avatar_default.png",
-		GenerateEmailAvatarFastLink("gitea@example.com", 100))
+		avatars_model.GenerateEmailAvatarFastLink("gitea@example.com", 100))
 
 	enableGravatar(t)
 	assert.Equal(t,
 		"https://secure.gravatar.com/avatar/353cbad9b58e69c96154ad99f92bedc7?d=identicon&s=100",
-		GenerateEmailAvatarFastLink("gitea@example.com", 100),
+		avatars_model.GenerateEmailAvatarFastLink("gitea@example.com", 100),
 	)
 }
diff --git a/models/admin/main_test.go b/models/avatars/main_test.go
similarity index 95%
rename from models/admin/main_test.go
rename to models/avatars/main_test.go
index 23277fe37..0e98d8f64 100644
--- a/models/admin/main_test.go
+++ b/models/avatars/main_test.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a MIT-style
 // license that can be found in the LICENSE file.
 
-package admin_test
+package avatars_test
 
 import (
 	"path/filepath"
diff --git a/models/fixtures/system_setting.yml b/models/fixtures/system_setting.yml
new file mode 100644
index 000000000..6c960168f
--- /dev/null
+++ b/models/fixtures/system_setting.yml
@@ -0,0 +1,15 @@
+-
+  id: 1
+  setting_key: 'disable_gravatar'
+  setting_value: 'false'
+  version: 1
+  created: 1653533198
+  updated: 1653533198
+
+-
+  id: 2
+  setting_key: 'enable_federated_avatar'
+  setting_value: 'false'
+  version: 1
+  created: 1653533198
+  updated: 1653533198
diff --git a/models/issues/issue.go b/models/issues/issue.go
index 737b625ab..786c96952 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -13,7 +13,6 @@ import (
 	"strconv"
 	"strings"
 
-	admin_model "code.gitea.io/gitea/models/admin"
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/models/foreignreference"
 	"code.gitea.io/gitea/models/organization"
@@ -21,6 +20,7 @@ import (
 	access_model "code.gitea.io/gitea/models/perm/access"
 	project_model "code.gitea.io/gitea/models/project"
 	repo_model "code.gitea.io/gitea/models/repo"
+	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/base"
@@ -2470,7 +2470,7 @@ func DeleteOrphanedIssues() error {
 
 	// Remove issue attachment files.
 	for i := range attachmentPaths {
-		admin_model.RemoveAllWithNotice(db.DefaultContext, "Delete issue attachment", attachmentPaths[i])
+		system_model.RemoveAllWithNotice(db.DefaultContext, "Delete issue attachment", attachmentPaths[i])
 	}
 	return nil
 }
diff --git a/models/main_test.go b/models/main_test.go
index 49b6e3e56..358400156 100644
--- a/models/main_test.go
+++ b/models/main_test.go
@@ -14,6 +14,8 @@ import (
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/setting"
 
+	_ "code.gitea.io/gitea/models/system"
+
 	"github.com/stretchr/testify/assert"
 )
 
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index 2a3877218..afe1445a2 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -415,6 +415,8 @@ var migrations = []Migration{
 	NewMigration("Alter gpg_key/public_key content TEXT fields to MEDIUMTEXT", alterPublicGPGKeyContentFieldsToMediumText),
 	// v226 -> v227
 	NewMigration("Conan and generic packages do not need to be semantically versioned", fixPackageSemverField),
+	// v227 -> v228
+	NewMigration("Create key/value table for system settings", createSystemSettingsTable),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v227.go b/models/migrations/v227.go
new file mode 100644
index 000000000..8a3a9c877
--- /dev/null
+++ b/models/migrations/v227.go
@@ -0,0 +1,64 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package migrations
+
+import (
+	"fmt"
+	"strconv"
+
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/xorm"
+)
+
+type SystemSetting struct {
+	ID           int64              `xorm:"pk autoincr"`
+	SettingKey   string             `xorm:"varchar(255) unique"` // ensure key is always lowercase
+	SettingValue string             `xorm:"text"`
+	Version      int                `xorm:"version"` // prevent to override
+	Created      timeutil.TimeStamp `xorm:"created"`
+	Updated      timeutil.TimeStamp `xorm:"updated"`
+}
+
+func insertSettingsIfNotExist(x *xorm.Engine, sysSettings []*SystemSetting) error {
+	sess := x.NewSession()
+	defer sess.Close()
+	if err := sess.Begin(); err != nil {
+		return err
+	}
+	for _, setting := range sysSettings {
+		exist, err := sess.Table("system_setting").Where("setting_key=?", setting.SettingKey).Exist()
+		if err != nil {
+			return err
+		}
+		if !exist {
+			if _, err := sess.Insert(setting); err != nil {
+				return err
+			}
+		}
+	}
+	return sess.Commit()
+}
+
+func createSystemSettingsTable(x *xorm.Engine) error {
+	if err := x.Sync2(new(SystemSetting)); err != nil {
+		return fmt.Errorf("sync2: %v", err)
+	}
+
+	// migrate xx to database
+	sysSettings := []*SystemSetting{
+		{
+			SettingKey:   "picture.disable_gravatar",
+			SettingValue: strconv.FormatBool(setting.DisableGravatar),
+		},
+		{
+			SettingKey:   "picture.enable_federated_avatar",
+			SettingValue: strconv.FormatBool(setting.EnableFederatedAvatar),
+		},
+	}
+
+	return insertSettingsIfNotExist(x, sysSettings)
+}
diff --git a/models/repo.go b/models/repo.go
index 94e624984..65159f14a 100644
--- a/models/repo.go
+++ b/models/repo.go
@@ -22,6 +22,7 @@ import (
 	access_model "code.gitea.io/gitea/models/perm/access"
 	project_model "code.gitea.io/gitea/models/project"
 	repo_model "code.gitea.io/gitea/models/repo"
+	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/models/webhook"
@@ -32,9 +33,13 @@ import (
 	"xorm.io/builder"
 )
 
-// NewRepoContext creates a new repository context
-func NewRepoContext() {
+// ItemsPerPage maximum items per page in forks, watchers and stars of a repo
+var ItemsPerPage = 40
+
+// Init initialize model
+func Init() error {
 	unit.LoadUnitConfig()
+	return system_model.Init()
 }
 
 // DeleteRepository deletes a repository for a user or organization.
@@ -267,36 +272,36 @@ func DeleteRepository(doer *user_model.User, uid, repoID int64) error {
 
 	// Remove repository files.
 	repoPath := repo.RepoPath()
-	admin_model.RemoveAllWithNotice(db.DefaultContext, "Delete repository files", repoPath)
+	system_model.RemoveAllWithNotice(db.DefaultContext, "Delete repository files", repoPath)
 
 	// Remove wiki files
 	if repo.HasWiki() {
-		admin_model.RemoveAllWithNotice(db.DefaultContext, "Delete repository wiki", repo.WikiPath())
+		system_model.RemoveAllWithNotice(db.DefaultContext, "Delete repository wiki", repo.WikiPath())
 	}
 
 	// Remove archives
 	for _, archive := range archivePaths {
-		admin_model.RemoveStorageWithNotice(db.DefaultContext, storage.RepoArchives, "Delete repo archive file", archive)
+		system_model.RemoveStorageWithNotice(db.DefaultContext, storage.RepoArchives, "Delete repo archive file", archive)
 	}
 
 	// Remove lfs objects
 	for _, lfsObj := range lfsPaths {
-		admin_model.RemoveStorageWithNotice(db.DefaultContext, storage.LFS, "Delete orphaned LFS file", lfsObj)
+		system_model.RemoveStorageWithNotice(db.DefaultContext, storage.LFS, "Delete orphaned LFS file", lfsObj)
 	}
 
 	// Remove issue attachment files.
 	for _, attachment := range attachmentPaths {
-		admin_model.RemoveStorageWithNotice(db.DefaultContext, storage.Attachments, "Delete issue attachment", attachment)
+		system_model.RemoveStorageWithNotice(db.DefaultContext, storage.Attachments, "Delete issue attachment", attachment)
 	}
 
 	// Remove release attachment files.
 	for _, releaseAttachment := range releaseAttachments {
-		admin_model.RemoveStorageWithNotice(db.DefaultContext, storage.Attachments, "Delete release attachment", releaseAttachment)
+		system_model.RemoveStorageWithNotice(db.DefaultContext, storage.Attachments, "Delete release attachment", releaseAttachment)
 	}
 
 	// Remove attachment with no issue_id and release_id.
 	for _, newAttachment := range newAttachmentPaths {
-		admin_model.RemoveStorageWithNotice(db.DefaultContext, storage.Attachments, "Delete issue attachment", newAttachment)
+		system_model.RemoveStorageWithNotice(db.DefaultContext, storage.Attachments, "Delete issue attachment", newAttachment)
 	}
 
 	if len(repo.Avatar) > 0 {
diff --git a/models/appstate/appstate.go b/models/system/appstate.go
similarity index 98%
rename from models/appstate/appstate.go
rename to models/system/appstate.go
index aa5a59e1a..c11a2512a 100644
--- a/models/appstate/appstate.go
+++ b/models/system/appstate.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a MIT-style
 // license that can be found in the LICENSE file.
 
-package appstate
+package system
 
 import (
 	"context"
diff --git a/models/system/main_test.go b/models/system/main_test.go
new file mode 100644
index 000000000..a56c76aed
--- /dev/null
+++ b/models/system/main_test.go
@@ -0,0 +1,21 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package system_test
+
+import (
+	"path/filepath"
+	"testing"
+
+	"code.gitea.io/gitea/models/unittest"
+
+	_ "code.gitea.io/gitea/models"        // register models
+	_ "code.gitea.io/gitea/models/system" // register models of system
+)
+
+func TestMain(m *testing.M) {
+	unittest.MainTest(m, &unittest.TestOptions{
+		GiteaRootPath: filepath.Join("..", ".."),
+	})
+}
diff --git a/models/admin/notice.go b/models/system/notice.go
similarity index 99%
rename from models/admin/notice.go
rename to models/system/notice.go
index 4d385cf95..3276fa3ff 100644
--- a/models/admin/notice.go
+++ b/models/system/notice.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a MIT-style
 // license that can be found in the LICENSE file.
 
-package admin
+package system
 
 import (
 	"context"
diff --git a/models/system/notice_test.go b/models/system/notice_test.go
new file mode 100644
index 000000000..768bcca66
--- /dev/null
+++ b/models/system/notice_test.go
@@ -0,0 +1,117 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package system_test
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/system"
+	"code.gitea.io/gitea/models/unittest"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNotice_TrStr(t *testing.T) {
+	notice := &system.Notice{
+		Type:        system.NoticeRepository,
+		Description: "test description",
+	}
+	assert.Equal(t, "admin.notices.type_1", notice.TrStr())
+}
+
+func TestCreateNotice(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	noticeBean := &system.Notice{
+		Type:        system.NoticeRepository,
+		Description: "test description",
+	}
+	unittest.AssertNotExistsBean(t, noticeBean)
+	assert.NoError(t, system.CreateNotice(db.DefaultContext, noticeBean.Type, noticeBean.Description))
+	unittest.AssertExistsAndLoadBean(t, noticeBean)
+}
+
+func TestCreateRepositoryNotice(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	noticeBean := &system.Notice{
+		Type:        system.NoticeRepository,
+		Description: "test description",
+	}
+	unittest.AssertNotExistsBean(t, noticeBean)
+	assert.NoError(t, system.CreateRepositoryNotice(noticeBean.Description))
+	unittest.AssertExistsAndLoadBean(t, noticeBean)
+}
+
+// TODO TestRemoveAllWithNotice
+
+func TestCountNotices(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+	assert.Equal(t, int64(3), system.CountNotices())
+}
+
+func TestNotices(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	notices, err := system.Notices(1, 2)
+	assert.NoError(t, err)
+	if assert.Len(t, notices, 2) {
+		assert.Equal(t, int64(3), notices[0].ID)
+		assert.Equal(t, int64(2), notices[1].ID)
+	}
+
+	notices, err = system.Notices(2, 2)
+	assert.NoError(t, err)
+	if assert.Len(t, notices, 1) {
+		assert.Equal(t, int64(1), notices[0].ID)
+	}
+}
+
+func TestDeleteNotice(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 3})
+	assert.NoError(t, system.DeleteNotice(3))
+	unittest.AssertNotExistsBean(t, &system.Notice{ID: 3})
+}
+
+func TestDeleteNotices(t *testing.T) {
+	// delete a non-empty range
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 1})
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 2})
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 3})
+	assert.NoError(t, system.DeleteNotices(1, 2))
+	unittest.AssertNotExistsBean(t, &system.Notice{ID: 1})
+	unittest.AssertNotExistsBean(t, &system.Notice{ID: 2})
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 3})
+}
+
+func TestDeleteNotices2(t *testing.T) {
+	// delete an empty range
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 1})
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 2})
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 3})
+	assert.NoError(t, system.DeleteNotices(3, 2))
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 1})
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 2})
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 3})
+}
+
+func TestDeleteNoticesByIDs(t *testing.T) {
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 1})
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 2})
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 3})
+	assert.NoError(t, system.DeleteNoticesByIDs([]int64{1, 3}))
+	unittest.AssertNotExistsBean(t, &system.Notice{ID: 1})
+	unittest.AssertExistsAndLoadBean(t, &system.Notice{ID: 2})
+	unittest.AssertNotExistsBean(t, &system.Notice{ID: 3})
+}
diff --git a/models/system/setting.go b/models/system/setting.go
new file mode 100644
index 000000000..ff8b48e61
--- /dev/null
+++ b/models/system/setting.go
@@ -0,0 +1,261 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package system
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"strconv"
+	"strings"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"strk.kbt.io/projects/go/libravatar"
+	"xorm.io/builder"
+)
+
+// Setting is a key value store of user settings
+type Setting struct {
+	ID           int64              `xorm:"pk autoincr"`
+	SettingKey   string             `xorm:"varchar(255) unique"` // ensure key is always lowercase
+	SettingValue string             `xorm:"text"`
+	Version      int                `xorm:"version"` // prevent to override
+	Created      timeutil.TimeStamp `xorm:"created"`
+	Updated      timeutil.TimeStamp `xorm:"updated"`
+}
+
+// TableName sets the table name for the settings struct
+func (s *Setting) TableName() string {
+	return "system_setting"
+}
+
+func (s *Setting) GetValueBool() bool {
+	b, _ := strconv.ParseBool(s.SettingValue)
+	return b
+}
+
+func init() {
+	db.RegisterModel(new(Setting))
+}
+
+// ErrSettingIsNotExist represents an error that a setting is not exist with special key
+type ErrSettingIsNotExist struct {
+	Key string
+}
+
+// Error implements error
+func (err ErrSettingIsNotExist) Error() string {
+	return fmt.Sprintf("System setting[%s] is not exist", err.Key)
+}
+
+// IsErrSettingIsNotExist return true if err is ErrSettingIsNotExist
+func IsErrSettingIsNotExist(err error) bool {
+	_, ok := err.(ErrSettingIsNotExist)
+	return ok
+}
+
+// ErrDataExpired represents an error that update a record which has been updated by another thread
+type ErrDataExpired struct {
+	Key string
+}
+
+// Error implements error
+func (err ErrDataExpired) Error() string {
+	return fmt.Sprintf("System setting[%s] has been updated by another thread", err.Key)
+}
+
+// IsErrDataExpired return true if err is ErrDataExpired
+func IsErrDataExpired(err error) bool {
+	_, ok := err.(ErrDataExpired)
+	return ok
+}
+
+// GetSetting returns specific setting
+func GetSetting(key string) (*Setting, error) {
+	v, err := GetSettings([]string{key})
+	if err != nil {
+		return nil, err
+	}
+	if len(v) == 0 {
+		return nil, ErrSettingIsNotExist{key}
+	}
+	return v[key], nil
+}
+
+// GetSettings returns specific settings
+func GetSettings(keys []string) (map[string]*Setting, error) {
+	for i := 0; i < len(keys); i++ {
+		keys[i] = strings.ToLower(keys[i])
+	}
+	settings := make([]*Setting, 0, len(keys))
+	if err := db.GetEngine(db.DefaultContext).
+		Where(builder.In("setting_key", keys)).
+		Find(&settings); err != nil {
+		return nil, err
+	}
+	settingsMap := make(map[string]*Setting)
+	for _, s := range settings {
+		settingsMap[s.SettingKey] = s
+	}
+	return settingsMap, nil
+}
+
+type AllSettings map[string]*Setting
+
+func (settings AllSettings) Get(key string) Setting {
+	if v, ok := settings[key]; ok {
+		return *v
+	}
+	return Setting{}
+}
+
+func (settings AllSettings) GetBool(key string) bool {
+	b, _ := strconv.ParseBool(settings.Get(key).SettingValue)
+	return b
+}
+
+func (settings AllSettings) GetVersion(key string) int {
+	return settings.Get(key).Version
+}
+
+// GetAllSettings returns all settings from user
+func GetAllSettings() (AllSettings, error) {
+	settings := make([]*Setting, 0, 5)
+	if err := db.GetEngine(db.DefaultContext).
+		Find(&settings); err != nil {
+		return nil, err
+	}
+	settingsMap := make(map[string]*Setting)
+	for _, s := range settings {
+		settingsMap[s.SettingKey] = s
+	}
+	return settingsMap, nil
+}
+
+// DeleteSetting deletes a specific setting for a user
+func DeleteSetting(setting *Setting) error {
+	_, err := db.GetEngine(db.DefaultContext).Delete(setting)
+	return err
+}
+
+func SetSettingNoVersion(key, value string) error {
+	s, err := GetSetting(key)
+	if IsErrSettingIsNotExist(err) {
+		return SetSetting(&Setting{
+			SettingKey:   key,
+			SettingValue: value,
+		})
+	}
+	if err != nil {
+		return err
+	}
+	s.SettingValue = value
+	return SetSetting(s)
+}
+
+// SetSetting updates a users' setting for a specific key
+func SetSetting(setting *Setting) error {
+	if err := upsertSettingValue(strings.ToLower(setting.SettingKey), setting.SettingValue, setting.Version); err != nil {
+		return err
+	}
+	setting.Version++
+	return nil
+}
+
+func upsertSettingValue(key, value string, version int) error {
+	return db.WithTx(func(ctx context.Context) error {
+		e := db.GetEngine(ctx)
+
+		// here we use a general method to do a safe upsert for different databases (and most transaction levels)
+		// 1. try to UPDATE the record and acquire the transaction write lock
+		//    if UPDATE returns non-zero rows are changed, OK, the setting is saved correctly
+		//    if UPDATE returns "0 rows changed", two possibilities: (a) record doesn't exist  (b) value is not changed
+		// 2. do a SELECT to check if the row exists or not (we already have the transaction lock)
+		// 3. if the row doesn't exist, do an INSERT (we are still protected by the transaction lock, so it's safe)
+		//
+		// to optimize the SELECT in step 2, we can use an extra column like `revision=revision+1`
+		//    to make sure the UPDATE always returns a non-zero value for existing (unchanged) records.
+
+		res, err := e.Exec("UPDATE system_setting SET setting_value=?, version = version+1 WHERE setting_key=? AND version=?", value, key, version)
+		if err != nil {
+			return err
+		}
+		rows, _ := res.RowsAffected()
+		if rows > 0 {
+			// the existing row is updated, so we can return
+			return nil
+		}
+
+		// in case the value isn't changed, update would return 0 rows changed, so we need this check
+		has, err := e.Exist(&Setting{SettingKey: key})
+		if err != nil {
+			return err
+		}
+		if has {
+			return ErrDataExpired{Key: key}
+		}
+
+		// if no existing row, insert a new row
+		_, err = e.Insert(&Setting{SettingKey: key, SettingValue: value})
+		return err
+	})
+}
+
+var (
+	GravatarSourceURL *url.URL
+	LibravatarService *libravatar.Libravatar
+)
+
+func Init() error {
+	var disableGravatar bool
+	disableGravatarSetting, err := GetSetting(KeyPictureDisableGravatar)
+	if IsErrSettingIsNotExist(err) {
+		disableGravatar = setting.GetDefaultDisableGravatar()
+		disableGravatarSetting = &Setting{SettingValue: strconv.FormatBool(disableGravatar)}
+	} else if err != nil {
+		return err
+	} else {
+		disableGravatar = disableGravatarSetting.GetValueBool()
+	}
+
+	var enableFederatedAvatar bool
+	enableFederatedAvatarSetting, err := GetSetting(KeyPictureEnableFederatedAvatar)
+	if IsErrSettingIsNotExist(err) {
+		enableFederatedAvatar = setting.GetDefaultEnableFederatedAvatar(disableGravatar)
+		enableFederatedAvatarSetting = &Setting{SettingValue: strconv.FormatBool(enableFederatedAvatar)}
+	} else if err != nil {
+		return err
+	} else {
+		enableFederatedAvatar = disableGravatarSetting.GetValueBool()
+	}
+
+	if setting.OfflineMode {
+		disableGravatar = true
+		enableFederatedAvatar = false
+	}
+
+	if disableGravatar || !enableFederatedAvatar {
+		var err error
+		GravatarSourceURL, err = url.Parse(setting.GravatarSource)
+		if err != nil {
+			return fmt.Errorf("Failed to parse Gravatar URL(%s): %v", setting.GravatarSource, err)
+		}
+	}
+
+	if enableFederatedAvatarSetting.GetValueBool() {
+		LibravatarService = libravatar.New()
+		if GravatarSourceURL.Scheme == "https" {
+			LibravatarService.SetUseHTTPS(true)
+			LibravatarService.SetSecureFallbackHost(GravatarSourceURL.Host)
+		} else {
+			LibravatarService.SetUseHTTPS(false)
+			LibravatarService.SetFallbackHost(GravatarSourceURL.Host)
+		}
+	}
+	return nil
+}
diff --git a/models/system/setting_key.go b/models/system/setting_key.go
new file mode 100644
index 000000000..5a6ea6ed7
--- /dev/null
+++ b/models/system/setting_key.go
@@ -0,0 +1,11 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package system
+
+// enumerate all system setting keys
+const (
+	KeyPictureDisableGravatar       = "picture.disable_gravatar"
+	KeyPictureEnableFederatedAvatar = "picture.enable_federated_avatar"
+)
diff --git a/models/system/setting_test.go b/models/system/setting_test.go
new file mode 100644
index 000000000..d25fc05f3
--- /dev/null
+++ b/models/system/setting_test.go
@@ -0,0 +1,53 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package system_test
+
+import (
+	"strings"
+	"testing"
+
+	"code.gitea.io/gitea/models/system"
+	"code.gitea.io/gitea/models/unittest"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSettings(t *testing.T) {
+	keyName := "server.LFS_LOCKS_PAGING_NUM"
+	assert.NoError(t, unittest.PrepareTestDatabase())
+
+	newSetting := &system.Setting{SettingKey: keyName, SettingValue: "50"}
+
+	// create setting
+	err := system.SetSetting(newSetting)
+	assert.NoError(t, err)
+	// test about saving unchanged values
+	err = system.SetSetting(newSetting)
+	assert.NoError(t, err)
+
+	// get specific setting
+	settings, err := system.GetSettings([]string{keyName})
+	assert.NoError(t, err)
+	assert.Len(t, settings, 1)
+	assert.EqualValues(t, newSetting.SettingValue, settings[strings.ToLower(keyName)].SettingValue)
+
+	// updated setting
+	updatedSetting := &system.Setting{SettingKey: keyName, SettingValue: "100", Version: newSetting.Version}
+	err = system.SetSetting(updatedSetting)
+	assert.NoError(t, err)
+
+	// get all settings
+	settings, err = system.GetAllSettings()
+	assert.NoError(t, err)
+	assert.Len(t, settings, 3)
+	assert.EqualValues(t, updatedSetting.SettingValue, settings[strings.ToLower(updatedSetting.SettingKey)].SettingValue)
+
+	// delete setting
+	err = system.DeleteSetting(&system.Setting{SettingKey: strings.ToLower(keyName)})
+	assert.NoError(t, err)
+	settings, err = system.GetAllSettings()
+	assert.NoError(t, err)
+	assert.Len(t, settings, 2)
+}
diff --git a/models/unittest/testdb.go b/models/unittest/testdb.go
index 25129137f..2e6c25ae4 100644
--- a/models/unittest/testdb.go
+++ b/models/unittest/testdb.go
@@ -7,12 +7,12 @@ package unittest
 import (
 	"context"
 	"fmt"
-	"net/url"
 	"os"
 	"path/filepath"
 	"testing"
 
 	"code.gitea.io/gitea/models/db"
+	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/setting"
@@ -91,10 +91,8 @@ func MainTest(m *testing.M, testOpts *TestOptions) {
 	setting.AppDataPath = appDataPath
 	setting.AppWorkPath = testOpts.GiteaRootPath
 	setting.StaticRootPath = testOpts.GiteaRootPath
-	setting.GravatarSourceURL, err = url.Parse("https://secure.gravatar.com/avatar/")
-	if err != nil {
-		fatalTestError("url.Parse: %v\n", err)
-	}
+	setting.GravatarSource = "https://secure.gravatar.com/avatar/"
+
 	setting.Attachment.Storage.Path = filepath.Join(setting.AppDataPath, "attachments")
 
 	setting.LFS.Storage.Path = filepath.Join(setting.AppDataPath, "lfs")
@@ -112,6 +110,9 @@ func MainTest(m *testing.M, testOpts *TestOptions) {
 	if err = storage.Init(); err != nil {
 		fatalTestError("storage.Init: %v\n", err)
 	}
+	if err = system_model.Init(); err != nil {
+		fatalTestError("models.Init: %v\n", err)
+	}
 
 	if err = util.RemoveAll(repoRootPath); err != nil {
 		fatalTestError("util.RemoveAll: %v\n", err)
diff --git a/models/user/avatar.go b/models/user/avatar.go
index 6a44a3bcb..1c75c7406 100644
--- a/models/user/avatar.go
+++ b/models/user/avatar.go
@@ -14,6 +14,7 @@ import (
 
 	"code.gitea.io/gitea/models/avatars"
 	"code.gitea.io/gitea/models/db"
+	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/modules/avatar"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
@@ -67,10 +68,16 @@ func (u *User) AvatarLinkWithSize(size int) string {
 	useLocalAvatar := false
 	autoGenerateAvatar := false
 
+	var disableGravatar bool
+	disableGravatarSetting, _ := system_model.GetSetting(system_model.KeyPictureDisableGravatar)
+	if disableGravatarSetting != nil {
+		disableGravatar = disableGravatarSetting.GetValueBool()
+	}
+
 	switch {
 	case u.UseCustomAvatar:
 		useLocalAvatar = true
-	case setting.DisableGravatar, setting.OfflineMode:
+	case disableGravatar, setting.OfflineMode:
 		useLocalAvatar = true
 		autoGenerateAvatar = true
 	}
diff --git a/models/user/setting.go b/models/user/setting.go
index fbb6fbab3..5fe7c2ec2 100644
--- a/models/user/setting.go
+++ b/models/user/setting.go
@@ -31,6 +31,34 @@ func init() {
 	db.RegisterModel(new(Setting))
 }
 
+// ErrUserSettingIsNotExist represents an error that a setting is not exist with special key
+type ErrUserSettingIsNotExist struct {
+	Key string
+}
+
+// Error implements error
+func (err ErrUserSettingIsNotExist) Error() string {
+	return fmt.Sprintf("Setting[%s] is not exist", err.Key)
+}
+
+// IsErrUserSettingIsNotExist return true if err is ErrSettingIsNotExist
+func IsErrUserSettingIsNotExist(err error) bool {
+	_, ok := err.(ErrUserSettingIsNotExist)
+	return ok
+}
+
+// GetSetting returns specific setting
+func GetSetting(uid int64, key string) (*Setting, error) {
+	v, err := GetUserSettings(uid, []string{key})
+	if err != nil {
+		return nil, err
+	}
+	if len(v) == 0 {
+		return nil, ErrUserSettingIsNotExist{key}
+	}
+	return v[key], nil
+}
+
 // GetUserSettings returns specific settings from user
 func GetUserSettings(uid int64, keys []string) (map[string]*Setting, error) {
 	settings := make([]*Setting, 0, len(keys))
diff --git a/modules/repository/commits_test.go b/modules/repository/commits_test.go
index c62e324b6..7bd741d0c 100644
--- a/modules/repository/commits_test.go
+++ b/modules/repository/commits_test.go
@@ -11,8 +11,10 @@ import (
 	"time"
 
 	repo_model "code.gitea.io/gitea/models/repo"
+	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/models/unittest"
 	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/setting"
 
 	"github.com/stretchr/testify/assert"
 )
@@ -100,6 +102,14 @@ func TestPushCommits_ToAPIPayloadCommits(t *testing.T) {
 	assert.EqualValues(t, []string{"readme.md"}, headCommit.Modified)
 }
 
+func enableGravatar(t *testing.T) {
+	err := system_model.SetSettingNoVersion(system_model.KeyPictureDisableGravatar, "false")
+	assert.NoError(t, err)
+	setting.GravatarSource = "https://secure.gravatar.com/avatar"
+	err = system_model.Init()
+	assert.NoError(t, err)
+}
+
 func TestPushCommits_AvatarLink(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
@@ -123,6 +133,8 @@ func TestPushCommits_AvatarLink(t *testing.T) {
 		},
 	}
 
+	enableGravatar(t)
+
 	assert.Equal(t,
 		"https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon&s=84",
 		pushCommits.AvatarLink("user2@example.com"))
diff --git a/modules/setting/picture.go b/modules/setting/picture.go
index a6d3447dc..af9041ade 100644
--- a/modules/setting/picture.go
+++ b/modules/setting/picture.go
@@ -4,14 +4,6 @@
 
 package setting
 
-import (
-	"net/url"
-
-	"code.gitea.io/gitea/modules/log"
-
-	"strk.kbt.io/projects/go/libravatar"
-)
-
 // settings
 var (
 	// Picture settings
@@ -30,10 +22,8 @@ var (
 	}
 
 	GravatarSource        string
-	GravatarSourceURL     *url.URL
-	DisableGravatar       bool
-	EnableFederatedAvatar bool
-	LibravatarService     *libravatar.Libravatar
+	DisableGravatar       bool // Depreciated: migrated to database
+	EnableFederatedAvatar bool // Depreciated: migrated to database
 
 	RepoAvatar = struct {
 		Storage
@@ -69,38 +59,30 @@ func newPictureService() {
 	default:
 		GravatarSource = source
 	}
-	DisableGravatar = sec.Key("DISABLE_GRAVATAR").MustBool()
-	EnableFederatedAvatar = sec.Key("ENABLE_FEDERATED_AVATAR").MustBool(!InstallLock)
-	if OfflineMode {
-		DisableGravatar = true
-		EnableFederatedAvatar = false
-	}
-	if DisableGravatar {
-		EnableFederatedAvatar = false
-	}
-	if EnableFederatedAvatar || !DisableGravatar {
-		var err error
-		GravatarSourceURL, err = url.Parse(GravatarSource)
-		if err != nil {
-			log.Fatal("Failed to parse Gravatar URL(%s): %v",
-				GravatarSource, err)
-		}
-	}
 
-	if EnableFederatedAvatar {
-		LibravatarService = libravatar.New()
-		if GravatarSourceURL.Scheme == "https" {
-			LibravatarService.SetUseHTTPS(true)
-			LibravatarService.SetSecureFallbackHost(GravatarSourceURL.Host)
-		} else {
-			LibravatarService.SetUseHTTPS(false)
-			LibravatarService.SetFallbackHost(GravatarSourceURL.Host)
-		}
-	}
+	DisableGravatar = sec.Key("DISABLE_GRAVATAR").MustBool(GetDefaultDisableGravatar())
+	deprecatedSettingDB("", "DISABLE_GRAVATAR")
+	EnableFederatedAvatar = sec.Key("ENABLE_FEDERATED_AVATAR").MustBool(GetDefaultEnableFederatedAvatar(DisableGravatar))
+	deprecatedSettingDB("", "ENABLE_FEDERATED_AVATAR")
 
 	newRepoAvatarService()
 }
 
+func GetDefaultDisableGravatar() bool {
+	return !OfflineMode
+}
+
+func GetDefaultEnableFederatedAvatar(disableGravatar bool) bool {
+	v := !InstallLock
+	if OfflineMode {
+		v = false
+	}
+	if disableGravatar {
+		v = false
+	}
+	return v
+}
+
 func newRepoAvatarService() {
 	sec := Cfg.Section("picture")
 
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index 007e3ef61..f93be2fbd 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -606,6 +606,13 @@ func deprecatedSetting(oldSection, oldKey, newSection, newKey string) {
 	}
 }
 
+// deprecatedSettingDB add a hint that the configuration has been moved to database but still kept in app.ini
+func deprecatedSettingDB(oldSection, oldKey string) {
+	if Cfg.Section(oldSection).HasKey(oldKey) {
+		log.Error("Deprecated `[%s]` `%s` present which has been copied to database table sys_setting", oldSection, oldKey)
+	}
+}
+
 // loadFromConf initializes configuration context.
 // NOTE: do not print any log except error.
 func loadFromConf(allowEmpty bool, extraConfig string) {
diff --git a/modules/appstate/appstate.go b/modules/system/appstate.go
similarity index 97%
rename from modules/appstate/appstate.go
rename to modules/system/appstate.go
index f65f5367e..deee8cd02 100644
--- a/modules/appstate/appstate.go
+++ b/modules/system/appstate.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a MIT-style
 // license that can be found in the LICENSE file.
 
-package appstate
+package system
 
 // StateStore is the interface to get/set app state items
 type StateStore interface {
diff --git a/modules/appstate/appstate_test.go b/modules/system/appstate_test.go
similarity index 98%
rename from modules/appstate/appstate_test.go
rename to modules/system/appstate_test.go
index e4a0d7285..fb0c2aaf9 100644
--- a/modules/appstate/appstate_test.go
+++ b/modules/system/appstate_test.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a MIT-style
 // license that can be found in the LICENSE file.
 
-package appstate
+package system
 
 import (
 	"path/filepath"
diff --git a/modules/appstate/db.go b/modules/system/db.go
similarity index 77%
rename from modules/appstate/db.go
rename to modules/system/db.go
index 2538d1b5c..b1c283c48 100644
--- a/modules/appstate/db.go
+++ b/modules/system/db.go
@@ -2,10 +2,10 @@
 // Use of this source code is governed by a MIT-style
 // license that can be found in the LICENSE file.
 
-package appstate
+package system
 
 import (
-	"code.gitea.io/gitea/models/appstate"
+	"code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/modules/json"
 
 	"github.com/yuin/goldmark/util"
@@ -16,7 +16,7 @@ type DBStore struct{}
 
 // Get reads the state item
 func (f *DBStore) Get(item StateItem) error {
-	content, err := appstate.GetAppStateContent(item.Name())
+	content, err := system.GetAppStateContent(item.Name())
 	if err != nil {
 		return err
 	}
@@ -32,5 +32,5 @@ func (f *DBStore) Set(item StateItem) error {
 	if err != nil {
 		return err
 	}
-	return appstate.SaveAppStateContent(item.Name(), util.BytesToReadOnlyString(b))
+	return system.SaveAppStateContent(item.Name(), util.BytesToReadOnlyString(b))
 }
diff --git a/modules/appstate/item_runtime.go b/modules/system/item_runtime.go
similarity index 96%
rename from modules/appstate/item_runtime.go
rename to modules/system/item_runtime.go
index 7fdc53f64..ef758a567 100644
--- a/modules/appstate/item_runtime.go
+++ b/modules/system/item_runtime.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a MIT-style
 // license that can be found in the LICENSE file.
 
-package appstate
+package system
 
 // RuntimeState contains app state for runtime, and we can save remote version for update checker here in future
 type RuntimeState struct {
diff --git a/modules/system/setting.go b/modules/system/setting.go
new file mode 100644
index 000000000..aebf24a50
--- /dev/null
+++ b/modules/system/setting.go
@@ -0,0 +1,46 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package system
+
+import (
+	"strconv"
+
+	"code.gitea.io/gitea/models/system"
+	"code.gitea.io/gitea/modules/cache"
+)
+
+func genKey(key string) string {
+	return "system.setting." + key
+}
+
+// GetSetting returns the setting value via the key
+func GetSetting(key string) (string, error) {
+	return cache.GetString(genKey(key), func() (string, error) {
+		res, err := system.GetSetting(key)
+		if err != nil {
+			return "", err
+		}
+		return res.SettingValue, nil
+	})
+}
+
+// GetSettingBool return bool value of setting,
+// none existing keys and errors are ignored and result in false
+func GetSettingBool(key string) bool {
+	s, _ := GetSetting(key)
+	b, _ := strconv.ParseBool(s)
+	return b
+}
+
+// SetSetting sets the setting value
+func SetSetting(key, value string, version int) error {
+	cache.Remove(genKey(key))
+
+	return system.SetSetting(&system.Setting{
+		SettingKey:   key,
+		SettingValue: value,
+		Version:      version,
+	})
+}
diff --git a/modules/system/user_setting.go b/modules/system/user_setting.go
new file mode 100644
index 000000000..eaf146c08
--- /dev/null
+++ b/modules/system/user_setting.go
@@ -0,0 +1,34 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package system
+
+import (
+	"fmt"
+
+	"code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/cache"
+)
+
+func genUserKey(userID int64, key string) string {
+	return fmt.Sprintf("user_%d.setting.%s", userID, key)
+}
+
+// GetUserSetting returns the user setting value via the key
+func GetUserSetting(userID int64, key string) (string, error) {
+	return cache.GetString(genUserKey(userID, key), func() (string, error) {
+		res, err := user.GetSetting(userID, key)
+		if err != nil {
+			return "", err
+		}
+		return res.SettingValue, nil
+	})
+}
+
+// SetUserSetting sets the user setting value
+func SetUserSetting(userID int64, key, value string) error {
+	cache.Remove(genUserKey(userID, key))
+
+	return user.SetUserSetting(userID, key, value)
+}
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index e10beae1d..7bd2bc0a1 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -29,6 +29,7 @@ import (
 	issues_model "code.gitea.io/gitea/models/issues"
 	"code.gitea.io/gitea/models/organization"
 	repo_model "code.gitea.io/gitea/models/repo"
+	system_model "code.gitea.io/gitea/models/system"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/emoji"
@@ -41,6 +42,7 @@ import (
 	"code.gitea.io/gitea/modules/repository"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/svg"
+	system_module "code.gitea.io/gitea/modules/system"
 	"code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/services/gitdiff"
@@ -85,7 +87,7 @@ func NewFuncMap() []template.FuncMap {
 			return setting.AssetVersion
 		},
 		"DisableGravatar": func() bool {
-			return setting.DisableGravatar
+			return system_module.GetSettingBool(system_model.KeyPictureDisableGravatar)
 		},
 		"DefaultShowFullName": func() bool {
 			return setting.UI.DefaultShowFullName
diff --git a/modules/updatechecker/update_checker.go b/modules/updatechecker/update_checker.go
index 9c1569b15..816fb3764 100644
--- a/modules/updatechecker/update_checker.go
+++ b/modules/updatechecker/update_checker.go
@@ -8,10 +8,10 @@ import (
 	"io"
 	"net/http"
 
-	"code.gitea.io/gitea/modules/appstate"
 	"code.gitea.io/gitea/modules/json"
 	"code.gitea.io/gitea/modules/proxy"
 	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/system"
 
 	"github.com/hashicorp/go-version"
 )
@@ -64,13 +64,13 @@ func GiteaUpdateChecker(httpEndpoint string) error {
 
 // UpdateRemoteVersion updates the latest available version of Gitea
 func UpdateRemoteVersion(version string) (err error) {
-	return appstate.AppState.Set(&CheckerState{LatestVersion: version})
+	return system.AppState.Set(&CheckerState{LatestVersion: version})
 }
 
 // GetRemoteVersion returns the current remote version (or currently installed version if fail to fetch from DB)
 func GetRemoteVersion() string {
 	item := new(CheckerState)
-	if err := appstate.AppState.Get(item); err != nil {
+	if err := system.AppState.Get(item); err != nil {
 		return ""
 	}
 	return item.LatestVersion
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index fbf9b7064..e5da074f6 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -2879,6 +2879,9 @@ config.access_log_template = Template
 config.xorm_log_mode = XORM Log Mode
 config.xorm_log_sql = Log SQL
 
+config.get_setting_failed = Get setting %s failed
+config.set_setting_failed = Set setting %s failed
+
 monitor.cron = Cron Tasks
 monitor.name = Name
 monitor.schedule = Schedule
diff --git a/routers/init.go b/routers/init.go
index 85a38899e..0f2e99341 100644
--- a/routers/init.go
+++ b/routers/init.go
@@ -11,7 +11,6 @@ import (
 
 	"code.gitea.io/gitea/models"
 	asymkey_model "code.gitea.io/gitea/models/asymkey"
-	"code.gitea.io/gitea/modules/appstate"
 	"code.gitea.io/gitea/modules/cache"
 	"code.gitea.io/gitea/modules/eventsource"
 	"code.gitea.io/gitea/modules/git"
@@ -27,6 +26,7 @@ import (
 	"code.gitea.io/gitea/modules/ssh"
 	"code.gitea.io/gitea/modules/storage"
 	"code.gitea.io/gitea/modules/svg"
+	"code.gitea.io/gitea/modules/system"
 	"code.gitea.io/gitea/modules/templates"
 	"code.gitea.io/gitea/modules/translation"
 	"code.gitea.io/gitea/modules/util"
@@ -76,8 +76,8 @@ func InitGitServices() {
 }
 
 func syncAppPathForGit(ctx context.Context) error {
-	runtimeState := new(appstate.RuntimeState)
-	if err := appstate.AppState.Get(runtimeState); err != nil {
+	runtimeState := new(system.RuntimeState)
+	if err := system.AppState.Get(runtimeState); err != nil {
 		return err
 	}
 	if runtimeState.LastAppPath != setting.AppPath {
@@ -90,7 +90,7 @@ func syncAppPathForGit(ctx context.Context) error {
 		mustInit(asymkey_model.RewriteAllPublicKeys)
 
 		runtimeState.LastAppPath = setting.AppPath
-		return appstate.AppState.Set(runtimeState)
+		return system.AppState.Set(runtimeState)
 	}
 	return nil
 }
@@ -133,10 +133,10 @@ func GlobalInitInstalled(ctx context.Context) {
 
 	mustInitCtx(ctx, common.InitDBEngine)
 	log.Info("ORM engine initialization successful!")
-	mustInit(appstate.Init)
+	mustInit(system.Init)
 	mustInit(oauth2.Init)
 
-	models.NewRepoContext()
+	mustInit(models.Init)
 	mustInit(repo_service.Init)
 
 	// Booting long running goroutines.
diff --git a/routers/install/install.go b/routers/install/install.go
index 890725b9a..8a0d34d97 100644
--- a/routers/install/install.go
+++ b/routers/install/install.go
@@ -12,12 +12,14 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"time"
 
 	"code.gitea.io/gitea/models/db"
 	db_install "code.gitea.io/gitea/models/db/install"
 	"code.gitea.io/gitea/models/migrations"
+	system_model "code.gitea.io/gitea/models/system"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/context"
@@ -147,8 +149,19 @@ func Install(ctx *context.Context) {
 
 	// Server and other services settings
 	form.OfflineMode = setting.OfflineMode
-	form.DisableGravatar = setting.DisableGravatar
-	form.EnableFederatedAvatar = setting.EnableFederatedAvatar
+	disableGravatarSetting, _ := system_model.GetSetting(system_model.KeyPictureDisableGravatar)
+	if disableGravatarSetting != nil {
+		form.DisableGravatar = disableGravatarSetting.GetValueBool()
+	} else {
+		form.DisableGravatar = false
+	}
+
+	enableFederatedAvatarSetting, _ := system_model.GetSetting(system_model.KeyPictureEnableFederatedAvatar)
+	if enableFederatedAvatarSetting != nil {
+		form.EnableFederatedAvatar = enableFederatedAvatarSetting.GetValueBool()
+	} else {
+		form.EnableFederatedAvatar = false
+	}
 	form.EnableOpenIDSignIn = setting.Service.EnableOpenIDSignIn
 	form.EnableOpenIDSignUp = setting.Service.EnableOpenIDSignUp
 	form.DisableRegistration = setting.Service.DisableRegistration
@@ -439,7 +452,11 @@ func SubmitInstall(ctx *context.Context) {
 	cfg.Section("service").Key("ENABLE_NOTIFY_MAIL").SetValue(fmt.Sprint(form.MailNotify))
 
 	cfg.Section("server").Key("OFFLINE_MODE").SetValue(fmt.Sprint(form.OfflineMode))
-	cfg.Section("picture").Key("DISABLE_GRAVATAR").SetValue(fmt.Sprint(form.DisableGravatar))
+	// if you are reinstalling, this maybe not right because of missing version
+	if err := system_model.SetSettingNoVersion(system_model.KeyPictureDisableGravatar, strconv.FormatBool(form.DisableGravatar)); err != nil {
+		ctx.RenderWithErr(ctx.Tr("install.secret_key_failed", err), tplInstall, &form)
+		return
+	}
 	cfg.Section("picture").Key("ENABLE_FEDERATED_AVATAR").SetValue(fmt.Sprint(form.EnableFederatedAvatar))
 	cfg.Section("openid").Key("ENABLE_OPENID_SIGNIN").SetValue(fmt.Sprint(form.EnableOpenIDSignIn))
 	cfg.Section("openid").Key("ENABLE_OPENID_SIGNUP").SetValue(fmt.Sprint(form.EnableOpenIDSignUp))
diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go
index 17471ef8a..d0664eb78 100644
--- a/routers/web/admin/admin.go
+++ b/routers/web/admin/admin.go
@@ -8,18 +8,13 @@ package admin
 import (
 	"fmt"
 	"net/http"
-	"net/url"
-	"os"
 	"runtime"
 	"strconv"
-	"strings"
 	"time"
 
 	activities_model "code.gitea.io/gitea/models/activities"
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/context"
-	"code.gitea.io/gitea/modules/git"
-	"code.gitea.io/gitea/modules/json"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/process"
 	"code.gitea.io/gitea/modules/queue"
@@ -27,18 +22,13 @@ import (
 	"code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/modules/translation"
 	"code.gitea.io/gitea/modules/updatechecker"
-	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/services/cron"
 	"code.gitea.io/gitea/services/forms"
-	"code.gitea.io/gitea/services/mailer"
-
-	"gitea.com/go-chi/session"
 )
 
 const (
 	tplDashboard  base.TplName = "admin/dashboard"
-	tplConfig     base.TplName = "admin/config"
 	tplMonitor    base.TplName = "admin/monitor"
 	tplStacktrace base.TplName = "admin/stacktrace"
 	tplQueue      base.TplName = "admin/queue"
@@ -165,165 +155,6 @@ func DashboardPost(ctx *context.Context) {
 	}
 }
 
-// SendTestMail send test mail to confirm mail service is OK
-func SendTestMail(ctx *context.Context) {
-	email := ctx.FormString("email")
-	// Send a test email to the user's email address and redirect back to Config
-	if err := mailer.SendTestMail(email); err != nil {
-		ctx.Flash.Error(ctx.Tr("admin.config.test_mail_failed", email, err))
-	} else {
-		ctx.Flash.Info(ctx.Tr("admin.config.test_mail_sent", email))
-	}
-
-	ctx.Redirect(setting.AppSubURL + "/admin/config")
-}
-
-func shadowPasswordKV(cfgItem, splitter string) string {
-	fields := strings.Split(cfgItem, splitter)
-	for i := 0; i < len(fields); i++ {
-		if strings.HasPrefix(fields[i], "password=") {
-			fields[i] = "password=******"
-			break
-		}
-	}
-	return strings.Join(fields, splitter)
-}
-
-func shadowURL(provider, cfgItem string) string {
-	u, err := url.Parse(cfgItem)
-	if err != nil {
-		log.Error("Shadowing Password for %v failed: %v", provider, err)
-		return cfgItem
-	}
-	if u.User != nil {
-		atIdx := strings.Index(cfgItem, "@")
-		if atIdx > 0 {
-			colonIdx := strings.LastIndex(cfgItem[:atIdx], ":")
-			if colonIdx > 0 {
-				return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:]
-			}
-		}
-	}
-	return cfgItem
-}
-
-func shadowPassword(provider, cfgItem string) string {
-	switch provider {
-	case "redis":
-		return shadowPasswordKV(cfgItem, ",")
-	case "mysql":
-		// root:@tcp(localhost:3306)/macaron?charset=utf8
-		atIdx := strings.Index(cfgItem, "@")
-		if atIdx > 0 {
-			colonIdx := strings.Index(cfgItem[:atIdx], ":")
-			if colonIdx > 0 {
-				return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:]
-			}
-		}
-		return cfgItem
-	case "postgres":
-		// user=jiahuachen dbname=macaron port=5432 sslmode=disable
-		if !strings.HasPrefix(cfgItem, "postgres://") {
-			return shadowPasswordKV(cfgItem, " ")
-		}
-		fallthrough
-	case "couchbase":
-		return shadowURL(provider, cfgItem)
-		// postgres://pqgotest:password@localhost/pqgotest?sslmode=verify-full
-		// Notice: use shadowURL
-	}
-	return cfgItem
-}
-
-// Config show admin config page
-func Config(ctx *context.Context) {
-	ctx.Data["Title"] = ctx.Tr("admin.config")
-	ctx.Data["PageIsAdmin"] = true
-	ctx.Data["PageIsAdminConfig"] = true
-
-	ctx.Data["CustomConf"] = setting.CustomConf
-	ctx.Data["AppUrl"] = setting.AppURL
-	ctx.Data["Domain"] = setting.Domain
-	ctx.Data["OfflineMode"] = setting.OfflineMode
-	ctx.Data["DisableRouterLog"] = setting.DisableRouterLog
-	ctx.Data["RunUser"] = setting.RunUser
-	ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode)
-	ctx.Data["GitVersion"] = git.VersionInfo()
-
-	ctx.Data["RepoRootPath"] = setting.RepoRootPath
-	ctx.Data["CustomRootPath"] = setting.CustomPath
-	ctx.Data["StaticRootPath"] = setting.StaticRootPath
-	ctx.Data["LogRootPath"] = setting.LogRootPath
-	ctx.Data["ScriptType"] = setting.ScriptType
-	ctx.Data["ReverseProxyAuthUser"] = setting.ReverseProxyAuthUser
-	ctx.Data["ReverseProxyAuthEmail"] = setting.ReverseProxyAuthEmail
-	ctx.Data["ReverseProxyAuthFullName"] = setting.ReverseProxyAuthFullName
-
-	ctx.Data["SSH"] = setting.SSH
-	ctx.Data["LFS"] = setting.LFS
-
-	ctx.Data["Service"] = setting.Service
-	ctx.Data["DbCfg"] = setting.Database
-	ctx.Data["Webhook"] = setting.Webhook
-
-	ctx.Data["MailerEnabled"] = false
-	if setting.MailService != nil {
-		ctx.Data["MailerEnabled"] = true
-		ctx.Data["Mailer"] = setting.MailService
-	}
-
-	ctx.Data["CacheAdapter"] = setting.CacheService.Adapter
-	ctx.Data["CacheInterval"] = setting.CacheService.Interval
-
-	ctx.Data["CacheConn"] = shadowPassword(setting.CacheService.Adapter, setting.CacheService.Conn)
-	ctx.Data["CacheItemTTL"] = setting.CacheService.TTL
-
-	sessionCfg := setting.SessionConfig
-	if sessionCfg.Provider == "VirtualSession" {
-		var realSession session.Options
-		if err := json.Unmarshal([]byte(sessionCfg.ProviderConfig), &realSession); err != nil {
-			log.Error("Unable to unmarshall session config for virtualed provider config: %s\nError: %v", sessionCfg.ProviderConfig, err)
-		}
-		sessionCfg.Provider = realSession.Provider
-		sessionCfg.ProviderConfig = realSession.ProviderConfig
-		sessionCfg.CookieName = realSession.CookieName
-		sessionCfg.CookiePath = realSession.CookiePath
-		sessionCfg.Gclifetime = realSession.Gclifetime
-		sessionCfg.Maxlifetime = realSession.Maxlifetime
-		sessionCfg.Secure = realSession.Secure
-		sessionCfg.Domain = realSession.Domain
-	}
-	sessionCfg.ProviderConfig = shadowPassword(sessionCfg.Provider, sessionCfg.ProviderConfig)
-	ctx.Data["SessionConfig"] = sessionCfg
-
-	ctx.Data["DisableGravatar"] = setting.DisableGravatar
-	ctx.Data["EnableFederatedAvatar"] = setting.EnableFederatedAvatar
-
-	ctx.Data["Git"] = setting.Git
-
-	type envVar struct {
-		Name, Value string
-	}
-
-	envVars := map[string]*envVar{}
-	if len(os.Getenv("GITEA_WORK_DIR")) > 0 {
-		envVars["GITEA_WORK_DIR"] = &envVar{"GITEA_WORK_DIR", os.Getenv("GITEA_WORK_DIR")}
-	}
-	if len(os.Getenv("GITEA_CUSTOM")) > 0 {
-		envVars["GITEA_CUSTOM"] = &envVar{"GITEA_CUSTOM", os.Getenv("GITEA_CUSTOM")}
-	}
-
-	ctx.Data["EnvVars"] = envVars
-	ctx.Data["Loggers"] = setting.GetLogDescriptions()
-	ctx.Data["EnableAccessLog"] = setting.EnableAccessLog
-	ctx.Data["AccessLogTemplate"] = setting.AccessLogTemplate
-	ctx.Data["DisableRouterLog"] = setting.DisableRouterLog
-	ctx.Data["EnableXORMLog"] = setting.EnableXORMLog
-	ctx.Data["LogSQL"] = setting.Database.LogSQL
-
-	ctx.HTML(http.StatusOK, tplConfig)
-}
-
 // Monitor show admin monitor page
 func Monitor(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("admin.monitor")
diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go
new file mode 100644
index 000000000..614d3d4f6
--- /dev/null
+++ b/routers/web/admin/config.go
@@ -0,0 +1,217 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package admin
+
+import (
+	"net/http"
+	"net/url"
+	"os"
+	"strings"
+
+	system_model "code.gitea.io/gitea/models/system"
+	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/json"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
+	system_module "code.gitea.io/gitea/modules/system"
+	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/services/mailer"
+
+	"gitea.com/go-chi/session"
+)
+
+const tplConfig base.TplName = "admin/config"
+
+// SendTestMail send test mail to confirm mail service is OK
+func SendTestMail(ctx *context.Context) {
+	email := ctx.FormString("email")
+	// Send a test email to the user's email address and redirect back to Config
+	if err := mailer.SendTestMail(email); err != nil {
+		ctx.Flash.Error(ctx.Tr("admin.config.test_mail_failed", email, err))
+	} else {
+		ctx.Flash.Info(ctx.Tr("admin.config.test_mail_sent", email))
+	}
+
+	ctx.Redirect(setting.AppSubURL + "/admin/config")
+}
+
+func shadowPasswordKV(cfgItem, splitter string) string {
+	fields := strings.Split(cfgItem, splitter)
+	for i := 0; i < len(fields); i++ {
+		if strings.HasPrefix(fields[i], "password=") {
+			fields[i] = "password=******"
+			break
+		}
+	}
+	return strings.Join(fields, splitter)
+}
+
+func shadowURL(provider, cfgItem string) string {
+	u, err := url.Parse(cfgItem)
+	if err != nil {
+		log.Error("Shadowing Password for %v failed: %v", provider, err)
+		return cfgItem
+	}
+	if u.User != nil {
+		atIdx := strings.Index(cfgItem, "@")
+		if atIdx > 0 {
+			colonIdx := strings.LastIndex(cfgItem[:atIdx], ":")
+			if colonIdx > 0 {
+				return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:]
+			}
+		}
+	}
+	return cfgItem
+}
+
+func shadowPassword(provider, cfgItem string) string {
+	switch provider {
+	case "redis":
+		return shadowPasswordKV(cfgItem, ",")
+	case "mysql":
+		// root:@tcp(localhost:3306)/macaron?charset=utf8
+		atIdx := strings.Index(cfgItem, "@")
+		if atIdx > 0 {
+			colonIdx := strings.Index(cfgItem[:atIdx], ":")
+			if colonIdx > 0 {
+				return cfgItem[:colonIdx+1] + "******" + cfgItem[atIdx:]
+			}
+		}
+		return cfgItem
+	case "postgres":
+		// user=jiahuachen dbname=macaron port=5432 sslmode=disable
+		if !strings.HasPrefix(cfgItem, "postgres://") {
+			return shadowPasswordKV(cfgItem, " ")
+		}
+		fallthrough
+	case "couchbase":
+		return shadowURL(provider, cfgItem)
+		// postgres://pqgotest:password@localhost/pqgotest?sslmode=verify-full
+		// Notice: use shadowURL
+	}
+	return cfgItem
+}
+
+// Config show admin config page
+func Config(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("admin.config")
+	ctx.Data["PageIsAdmin"] = true
+	ctx.Data["PageIsAdminConfig"] = true
+
+	systemSettings, err := system_model.GetAllSettings()
+	if err != nil {
+		ctx.ServerError("system_model.GetAllSettings", err)
+		return
+	}
+
+	// All editable settings from UI
+	ctx.Data["SystemSettings"] = systemSettings
+	ctx.PageData["adminConfigPage"] = true
+
+	ctx.Data["CustomConf"] = setting.CustomConf
+	ctx.Data["AppUrl"] = setting.AppURL
+	ctx.Data["Domain"] = setting.Domain
+	ctx.Data["OfflineMode"] = setting.OfflineMode
+	ctx.Data["DisableRouterLog"] = setting.DisableRouterLog
+	ctx.Data["RunUser"] = setting.RunUser
+	ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode)
+	ctx.Data["GitVersion"] = git.VersionInfo()
+
+	ctx.Data["RepoRootPath"] = setting.RepoRootPath
+	ctx.Data["CustomRootPath"] = setting.CustomPath
+	ctx.Data["StaticRootPath"] = setting.StaticRootPath
+	ctx.Data["LogRootPath"] = setting.LogRootPath
+	ctx.Data["ScriptType"] = setting.ScriptType
+	ctx.Data["ReverseProxyAuthUser"] = setting.ReverseProxyAuthUser
+	ctx.Data["ReverseProxyAuthEmail"] = setting.ReverseProxyAuthEmail
+
+	ctx.Data["SSH"] = setting.SSH
+	ctx.Data["LFS"] = setting.LFS
+
+	ctx.Data["Service"] = setting.Service
+	ctx.Data["DbCfg"] = setting.Database
+	ctx.Data["Webhook"] = setting.Webhook
+
+	ctx.Data["MailerEnabled"] = false
+	if setting.MailService != nil {
+		ctx.Data["MailerEnabled"] = true
+		ctx.Data["Mailer"] = setting.MailService
+	}
+
+	ctx.Data["CacheAdapter"] = setting.CacheService.Adapter
+	ctx.Data["CacheInterval"] = setting.CacheService.Interval
+
+	ctx.Data["CacheConn"] = shadowPassword(setting.CacheService.Adapter, setting.CacheService.Conn)
+	ctx.Data["CacheItemTTL"] = setting.CacheService.TTL
+
+	sessionCfg := setting.SessionConfig
+	if sessionCfg.Provider == "VirtualSession" {
+		var realSession session.Options
+		if err := json.Unmarshal([]byte(sessionCfg.ProviderConfig), &realSession); err != nil {
+			log.Error("Unable to unmarshall session config for virtual provider config: %s\nError: %v", sessionCfg.ProviderConfig, err)
+		}
+		sessionCfg.Provider = realSession.Provider
+		sessionCfg.ProviderConfig = realSession.ProviderConfig
+		sessionCfg.CookieName = realSession.CookieName
+		sessionCfg.CookiePath = realSession.CookiePath
+		sessionCfg.Gclifetime = realSession.Gclifetime
+		sessionCfg.Maxlifetime = realSession.Maxlifetime
+		sessionCfg.Secure = realSession.Secure
+		sessionCfg.Domain = realSession.Domain
+	}
+	sessionCfg.ProviderConfig = shadowPassword(sessionCfg.Provider, sessionCfg.ProviderConfig)
+	ctx.Data["SessionConfig"] = sessionCfg
+
+	ctx.Data["Git"] = setting.Git
+
+	type envVar struct {
+		Name, Value string
+	}
+
+	envVars := map[string]*envVar{}
+	if len(os.Getenv("GITEA_WORK_DIR")) > 0 {
+		envVars["GITEA_WORK_DIR"] = &envVar{"GITEA_WORK_DIR", os.Getenv("GITEA_WORK_DIR")}
+	}
+	if len(os.Getenv("GITEA_CUSTOM")) > 0 {
+		envVars["GITEA_CUSTOM"] = &envVar{"GITEA_CUSTOM", os.Getenv("GITEA_CUSTOM")}
+	}
+
+	ctx.Data["EnvVars"] = envVars
+	ctx.Data["Loggers"] = setting.GetLogDescriptions()
+	ctx.Data["EnableAccessLog"] = setting.EnableAccessLog
+	ctx.Data["AccessLogTemplate"] = setting.AccessLogTemplate
+	ctx.Data["DisableRouterLog"] = setting.DisableRouterLog
+	ctx.Data["EnableXORMLog"] = setting.EnableXORMLog
+	ctx.Data["LogSQL"] = setting.Database.LogSQL
+
+	ctx.HTML(http.StatusOK, tplConfig)
+}
+
+func ChangeConfig(ctx *context.Context) {
+	key := strings.TrimSpace(ctx.FormString("key"))
+	if key == "" {
+		ctx.JSON(http.StatusOK, map[string]string{
+			"redirect": ctx.Req.URL.String(),
+		})
+		return
+	}
+	value := ctx.FormString("value")
+	version := ctx.FormInt("version")
+
+	if err := system_module.SetSetting(key, value, version); err != nil {
+		log.Error("set setting failed: %v", err)
+		ctx.JSON(http.StatusOK, map[string]string{
+			"err": ctx.Tr("admin.config.set_setting_failed", key),
+		})
+		return
+	}
+
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"version": version + 1,
+	})
+}
diff --git a/routers/web/admin/notice.go b/routers/web/admin/notice.go
index b50549b80..f5ec294cc 100644
--- a/routers/web/admin/notice.go
+++ b/routers/web/admin/notice.go
@@ -9,7 +9,7 @@ import (
 	"net/http"
 	"strconv"
 
-	admin_model "code.gitea.io/gitea/models/admin"
+	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/context"
 	"code.gitea.io/gitea/modules/log"
@@ -26,13 +26,13 @@ func Notices(ctx *context.Context) {
 	ctx.Data["PageIsAdmin"] = true
 	ctx.Data["PageIsAdminNotices"] = true
 
-	total := admin_model.CountNotices()
+	total := system_model.CountNotices()
 	page := ctx.FormInt("page")
 	if page <= 1 {
 		page = 1
 	}
 
-	notices, err := admin_model.Notices(page, setting.UI.Admin.NoticePagingNum)
+	notices, err := system_model.Notices(page, setting.UI.Admin.NoticePagingNum)
 	if err != nil {
 		ctx.ServerError("Notices", err)
 		return
@@ -57,7 +57,7 @@ func DeleteNotices(ctx *context.Context) {
 		}
 	}
 
-	if err := admin_model.DeleteNoticesByIDs(ids); err != nil {
+	if err := system_model.DeleteNoticesByIDs(ids); err != nil {
 		ctx.Flash.Error("DeleteNoticesByIDs: " + err.Error())
 		ctx.Status(http.StatusInternalServerError)
 	} else {
@@ -68,7 +68,7 @@ func DeleteNotices(ctx *context.Context) {
 
 // EmptyNotices delete all the notices
 func EmptyNotices(ctx *context.Context) {
-	if err := admin_model.DeleteNotices(0, 0); err != nil {
+	if err := system_model.DeleteNotices(0, 0); err != nil {
 		ctx.ServerError("DeleteNotices", err)
 		return
 	}
diff --git a/routers/web/repo/middlewares.go b/routers/web/repo/middlewares.go
index ae4177cf1..c9e8eb4a8 100644
--- a/routers/web/repo/middlewares.go
+++ b/routers/web/repo/middlewares.go
@@ -7,7 +7,7 @@ package repo
 import (
 	"fmt"
 
-	admin_model "code.gitea.io/gitea/models/admin"
+	system_model "code.gitea.io/gitea/models/system"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/context"
 	"code.gitea.io/gitea/modules/git"
@@ -24,7 +24,7 @@ func SetEditorconfigIfExists(ctx *context.Context) {
 
 	if err != nil && !git.IsErrNotExist(err) {
 		description := fmt.Sprintf("Error while getting .editorconfig file: %v", err)
-		if err := admin_model.CreateRepositoryNotice(description); err != nil {
+		if err := system_model.CreateRepositoryNotice(description); err != nil {
 			ctx.ServerError("ErrCreatingReporitoryNotice", err)
 		}
 		return
diff --git a/routers/web/web.go b/routers/web/web.go
index c01a2bce4..8859ec585 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -473,8 +473,13 @@ func RegisterRoutes(m *web.Route) {
 	m.Group("/admin", func() {
 		m.Get("", adminReq, admin.Dashboard)
 		m.Post("", adminReq, bindIgnErr(forms.AdminDashboardForm{}), admin.DashboardPost)
-		m.Get("/config", admin.Config)
-		m.Post("/config/test_mail", admin.SendTestMail)
+
+		m.Group("/config", func() {
+			m.Get("", admin.Config)
+			m.Post("", admin.ChangeConfig)
+			m.Post("/test_mail", admin.SendTestMail)
+		})
+
 		m.Group("/monitor", func() {
 			m.Get("", admin.Monitor)
 			m.Get("/stacktrace", admin.GoroutineStacktrace)
diff --git a/services/cron/tasks.go b/services/cron/tasks.go
index c26e47e0c..6ff5964d1 100644
--- a/services/cron/tasks.go
+++ b/services/cron/tasks.go
@@ -10,8 +10,8 @@ import (
 	"reflect"
 	"sync"
 
-	admin_model "code.gitea.io/gitea/models/admin"
 	"code.gitea.io/gitea/models/db"
+	system_model "code.gitea.io/gitea/models/system"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/graceful"
 	"code.gitea.io/gitea/modules/log"
@@ -114,7 +114,7 @@ func (t *Task) RunWithUser(doer *user_model.User, config Config) {
 			t.LastDoer = doerName
 			t.lock.Unlock()
 
-			if err := admin_model.CreateNotice(ctx, admin_model.NoticeTask, config.FormatMessage(translation.NewLocale("en-US"), t.Name, "cancelled", doerName, message)); err != nil {
+			if err := system_model.CreateNotice(ctx, system_model.NoticeTask, config.FormatMessage(translation.NewLocale("en-US"), t.Name, "cancelled", doerName, message)); err != nil {
 				log.Error("CreateNotice: %v", err)
 			}
 			return
@@ -127,7 +127,7 @@ func (t *Task) RunWithUser(doer *user_model.User, config Config) {
 		t.lock.Unlock()
 
 		if config.DoNoticeOnSuccess() {
-			if err := admin_model.CreateNotice(ctx, admin_model.NoticeTask, config.FormatMessage(translation.NewLocale("en-US"), t.Name, "finished", doerName)); err != nil {
+			if err := system_model.CreateNotice(ctx, system_model.NoticeTask, config.FormatMessage(translation.NewLocale("en-US"), t.Name, "finished", doerName)); err != nil {
 				log.Error("CreateNotice: %v", err)
 			}
 		}
diff --git a/services/cron/tasks_extended.go b/services/cron/tasks_extended.go
index c3455ec32..04b9560be 100644
--- a/services/cron/tasks_extended.go
+++ b/services/cron/tasks_extended.go
@@ -9,9 +9,9 @@ import (
 	"time"
 
 	activities_model "code.gitea.io/gitea/models/activities"
-	"code.gitea.io/gitea/models/admin"
 	asymkey_model "code.gitea.io/gitea/models/asymkey"
 	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/system"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/updatechecker"
@@ -166,7 +166,7 @@ func registerDeleteOldSystemNotices() {
 		OlderThan: 365 * 24 * time.Hour,
 	}, func(ctx context.Context, _ *user_model.User, config Config) error {
 		olderThanConfig := config.(*OlderThanConfig)
-		return admin.DeleteOldSystemNotices(olderThanConfig.OlderThan)
+		return system.DeleteOldSystemNotices(olderThanConfig.OlderThan)
 	})
 }
 
diff --git a/services/issue/issue.go b/services/issue/issue.go
index bbd027879..69b87686c 100644
--- a/services/issue/issue.go
+++ b/services/issue/issue.go
@@ -8,12 +8,12 @@ import (
 	"fmt"
 
 	activities_model "code.gitea.io/gitea/models/activities"
-	admin_model "code.gitea.io/gitea/models/admin"
 	"code.gitea.io/gitea/models/db"
 	issues_model "code.gitea.io/gitea/models/issues"
 	access_model "code.gitea.io/gitea/models/perm/access"
 	project_model "code.gitea.io/gitea/models/project"
 	repo_model "code.gitea.io/gitea/models/repo"
+	system_model "code.gitea.io/gitea/models/system"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/notification"
@@ -234,7 +234,7 @@ func deleteIssue(issue *issues_model.Issue) error {
 	}
 
 	for i := range issue.Attachments {
-		admin_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete issue attachment", issue.Attachments[i].RelativePath())
+		system_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete issue attachment", issue.Attachments[i].RelativePath())
 	}
 
 	// delete all database data still assigned to this issue
diff --git a/services/migrations/common.go b/services/migrations/common.go
index 305ae89b2..052975c9e 100644
--- a/services/migrations/common.go
+++ b/services/migrations/common.go
@@ -8,7 +8,7 @@ import (
 	"fmt"
 	"strings"
 
-	admin_model "code.gitea.io/gitea/models/admin"
+	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
 	base "code.gitea.io/gitea/modules/migration"
@@ -17,7 +17,7 @@ import (
 // WarnAndNotice will log the provided message and send a repository notice
 func WarnAndNotice(fmtStr string, args ...interface{}) {
 	log.Warn(fmtStr, args...)
-	if err := admin_model.CreateRepositoryNotice(fmt.Sprintf(fmtStr, args...)); err != nil {
+	if err := system_model.CreateRepositoryNotice(fmt.Sprintf(fmtStr, args...)); err != nil {
 		log.Error("create repository notice failed: ", err)
 	}
 }
diff --git a/services/migrations/migrate.go b/services/migrations/migrate.go
index 040f0aebb..dfb21b884 100644
--- a/services/migrations/migrate.go
+++ b/services/migrations/migrate.go
@@ -14,8 +14,8 @@ import (
 	"strings"
 
 	"code.gitea.io/gitea/models"
-	admin_model "code.gitea.io/gitea/models/admin"
 	repo_model "code.gitea.io/gitea/models/repo"
+	system_model "code.gitea.io/gitea/models/system"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/hostmatcher"
 	"code.gitea.io/gitea/modules/log"
@@ -132,7 +132,7 @@ func MigrateRepository(ctx context.Context, doer *user_model.User, ownerName str
 		if err1 := uploader.Rollback(); err1 != nil {
 			log.Error("rollback failed: %v", err1)
 		}
-		if err2 := admin_model.CreateRepositoryNotice(fmt.Sprintf("Migrate repository from %s failed: %v", opts.OriginalURL, err)); err2 != nil {
+		if err2 := system_model.CreateRepositoryNotice(fmt.Sprintf("Migrate repository from %s failed: %v", opts.OriginalURL, err)); err2 != nil {
 			log.Error("create respotiry notice failed: ", err2)
 		}
 		return nil, err
diff --git a/services/mirror/mirror_pull.go b/services/mirror/mirror_pull.go
index f4c527bbd..a72e7f72e 100644
--- a/services/mirror/mirror_pull.go
+++ b/services/mirror/mirror_pull.go
@@ -10,9 +10,9 @@ import (
 	"strings"
 	"time"
 
-	admin_model "code.gitea.io/gitea/models/admin"
 	"code.gitea.io/gitea/models/db"
 	repo_model "code.gitea.io/gitea/models/repo"
+	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/modules/cache"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/lfs"
@@ -188,7 +188,7 @@ func pruneBrokenReferences(ctx context.Context,
 
 		log.Error("Failed to prune mirror repository %s%-v references:\nStdout: %s\nStderr: %s\nErr: %v", wiki, m.Repo, stdoutMessage, stderrMessage, pruneErr)
 		desc := fmt.Sprintf("Failed to prune mirror repository %s'%s' references: %s", wiki, repoPath, stderrMessage)
-		if err := admin_model.CreateRepositoryNotice(desc); err != nil {
+		if err := system_model.CreateRepositoryNotice(desc); err != nil {
 			log.Error("CreateRepositoryNotice: %v", err)
 		}
 		// this if will only be reached on a successful prune so try to get the mirror again
@@ -267,7 +267,7 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*mirrorSyncResult, bo
 		if err != nil {
 			log.Error("SyncMirrors [repo: %-v]: failed to update mirror repository:\nStdout: %s\nStderr: %s\nErr: %v", m.Repo, stdoutMessage, stderrMessage, err)
 			desc := fmt.Sprintf("Failed to update mirror repository '%s': %s", repoPath, stderrMessage)
-			if err = admin_model.CreateRepositoryNotice(desc); err != nil {
+			if err = system_model.CreateRepositoryNotice(desc); err != nil {
 				log.Error("CreateRepositoryNotice: %v", err)
 			}
 			return nil, false
@@ -356,7 +356,7 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*mirrorSyncResult, bo
 			if err != nil {
 				log.Error("SyncMirrors [repo: %-v Wiki]: failed to update mirror repository wiki:\nStdout: %s\nStderr: %s\nErr: %v", m.Repo, stdoutMessage, stderrMessage, err)
 				desc := fmt.Sprintf("Failed to update mirror repository wiki '%s': %s", wikiPath, stderrMessage)
-				if err = admin_model.CreateRepositoryNotice(desc); err != nil {
+				if err = system_model.CreateRepositoryNotice(desc); err != nil {
 					log.Error("CreateRepositoryNotice: %v", err)
 				}
 				return nil, false
@@ -568,7 +568,7 @@ func checkAndUpdateEmptyRepository(m *repo_model.Mirror, gitRepo *git.Repository
 			if !git.IsErrUnsupportedVersion(err) {
 				log.Error("Failed to update default branch of underlying git repository %-v. Error: %v", m.Repo, err)
 				desc := fmt.Sprintf("Failed to uupdate default branch of underlying git repository '%s': %v", m.Repo.RepoPath(), err)
-				if err = admin_model.CreateRepositoryNotice(desc); err != nil {
+				if err = system_model.CreateRepositoryNotice(desc); err != nil {
 					log.Error("CreateRepositoryNotice: %v", err)
 				}
 				return false
@@ -579,7 +579,7 @@ func checkAndUpdateEmptyRepository(m *repo_model.Mirror, gitRepo *git.Repository
 		if err := repo_model.UpdateRepositoryCols(db.DefaultContext, m.Repo, "default_branch", "is_empty"); err != nil {
 			log.Error("Failed to update default branch of repository %-v. Error: %v", m.Repo, err)
 			desc := fmt.Sprintf("Failed to uupdate default branch of repository '%s': %v", m.Repo.RepoPath(), err)
-			if err = admin_model.CreateRepositoryNotice(desc); err != nil {
+			if err = system_model.CreateRepositoryNotice(desc); err != nil {
 				log.Error("CreateRepositoryNotice: %v", err)
 			}
 			return false
diff --git a/services/repository/check.go b/services/repository/check.go
index 17bdf2fac..2417db6a2 100644
--- a/services/repository/check.go
+++ b/services/repository/check.go
@@ -11,9 +11,9 @@ import (
 	"time"
 
 	"code.gitea.io/gitea/models"
-	admin_model "code.gitea.io/gitea/models/admin"
 	"code.gitea.io/gitea/models/db"
 	repo_model "code.gitea.io/gitea/models/repo"
+	system_model "code.gitea.io/gitea/models/system"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
@@ -42,7 +42,7 @@ func GitFsck(ctx context.Context, timeout time.Duration, args []string) error {
 			repoPath := repo.RepoPath()
 			if err := git.Fsck(ctx, repoPath, timeout, args...); err != nil {
 				log.Warn("Failed to health check repository (%v): %v", repo, err)
-				if err = admin_model.CreateRepositoryNotice("Failed to health check repository (%s): %v", repo.FullName(), err); err != nil {
+				if err = system_model.CreateRepositoryNotice("Failed to health check repository (%s): %v", repo.FullName(), err); err != nil {
 					log.Error("CreateRepositoryNotice: %v", err)
 				}
 			}
@@ -83,7 +83,7 @@ func GitGcRepos(ctx context.Context, timeout time.Duration, args ...string) erro
 			if err != nil {
 				log.Error("Repository garbage collection failed for %v. Stdout: %s\nError: %v", repo, stdout, err)
 				desc := fmt.Sprintf("Repository garbage collection failed for %s. Stdout: %s\nError: %v", repo.RepoPath(), stdout, err)
-				if err = admin_model.CreateRepositoryNotice(desc); err != nil {
+				if err = system_model.CreateRepositoryNotice(desc); err != nil {
 					log.Error("CreateRepositoryNotice: %v", err)
 				}
 				return fmt.Errorf("Repository garbage collection failed in repo: %s: Error: %v", repo.FullName(), err)
@@ -93,7 +93,7 @@ func GitGcRepos(ctx context.Context, timeout time.Duration, args ...string) erro
 			if err := repo_module.UpdateRepoSize(ctx, repo); err != nil {
 				log.Error("Updating size as part of garbage collection failed for %v. Stdout: %s\nError: %v", repo, stdout, err)
 				desc := fmt.Sprintf("Updating size as part of garbage collection failed for %s. Stdout: %s\nError: %v", repo.RepoPath(), stdout, err)
-				if err = admin_model.CreateRepositoryNotice(desc); err != nil {
+				if err = system_model.CreateRepositoryNotice(desc); err != nil {
 					log.Error("CreateRepositoryNotice: %v", err)
 				}
 				return fmt.Errorf("Updating size as part of garbage collection failed in repo: %s: Error: %v", repo.FullName(), err)
@@ -135,7 +135,7 @@ func gatherMissingRepoRecords(ctx context.Context) ([]*repo_model.Repository, er
 		if strings.HasPrefix(err.Error(), "Aborted gathering missing repo") {
 			return nil, err
 		}
-		if err2 := admin_model.CreateRepositoryNotice("gatherMissingRepoRecords: %v", err); err2 != nil {
+		if err2 := system_model.CreateRepositoryNotice("gatherMissingRepoRecords: %v", err); err2 != nil {
 			log.Error("CreateRepositoryNotice: %v", err2)
 		}
 		return nil, err
@@ -163,7 +163,7 @@ func DeleteMissingRepositories(ctx context.Context, doer *user_model.User) error
 		log.Trace("Deleting %d/%d...", repo.OwnerID, repo.ID)
 		if err := models.DeleteRepository(doer, repo.OwnerID, repo.ID); err != nil {
 			log.Error("Failed to DeleteRepository %s [%d]: Error: %v", repo.FullName(), repo.ID, err)
-			if err2 := admin_model.CreateRepositoryNotice("Failed to DeleteRepository %s [%d]: Error: %v", repo.FullName(), repo.ID, err); err2 != nil {
+			if err2 := system_model.CreateRepositoryNotice("Failed to DeleteRepository %s [%d]: Error: %v", repo.FullName(), repo.ID, err); err2 != nil {
 				log.Error("CreateRepositoryNotice: %v", err)
 			}
 		}
@@ -191,7 +191,7 @@ func ReinitMissingRepositories(ctx context.Context) error {
 		log.Trace("Initializing %d/%d...", repo.OwnerID, repo.ID)
 		if err := git.InitRepository(ctx, repo.RepoPath(), true); err != nil {
 			log.Error("Unable (re)initialize repository %d at %s. Error: %v", repo.ID, repo.RepoPath(), err)
-			if err2 := admin_model.CreateRepositoryNotice("InitRepository [%d]: %v", repo.ID, err); err2 != nil {
+			if err2 := system_model.CreateRepositoryNotice("InitRepository [%d]: %v", repo.ID, err); err2 != nil {
 				log.Error("CreateRepositoryNotice: %v", err2)
 			}
 		}
diff --git a/services/repository/repository.go b/services/repository/repository.go
index d53035836..47687d9a7 100644
--- a/services/repository/repository.go
+++ b/services/repository/repository.go
@@ -9,12 +9,12 @@ import (
 	"fmt"
 
 	"code.gitea.io/gitea/models"
-	admin_model "code.gitea.io/gitea/models/admin"
 	"code.gitea.io/gitea/models/db"
 	issues_model "code.gitea.io/gitea/models/issues"
 	"code.gitea.io/gitea/models/organization"
 	packages_model "code.gitea.io/gitea/models/packages"
 	repo_model "code.gitea.io/gitea/models/repo"
+	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/log"
@@ -83,8 +83,8 @@ func PushCreateRepo(authUser, owner *user_model.User, repoName string) (*repo_mo
 // Init start repository service
 func Init() error {
 	repo_module.LoadRepoConfig()
-	admin_model.RemoveAllWithNotice(db.DefaultContext, "Clean up temporary repository uploads", setting.Repository.Upload.TempPath)
-	admin_model.RemoveAllWithNotice(db.DefaultContext, "Clean up temporary repositories", repo_module.LocalCopyPath())
+	system_model.RemoveAllWithNotice(db.DefaultContext, "Clean up temporary repository uploads", setting.Repository.Upload.TempPath)
+	system_model.RemoveAllWithNotice(db.DefaultContext, "Clean up temporary repositories", repo_module.LocalCopyPath())
 	return initPushQueue()
 }
 
diff --git a/services/user/user.go b/services/user/user.go
index 1edd9294c..dab9ac61a 100644
--- a/services/user/user.go
+++ b/services/user/user.go
@@ -13,12 +13,12 @@ import (
 	"time"
 
 	"code.gitea.io/gitea/models"
-	admin_model "code.gitea.io/gitea/models/admin"
 	asymkey_model "code.gitea.io/gitea/models/asymkey"
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/models/organization"
 	packages_model "code.gitea.io/gitea/models/packages"
 	repo_model "code.gitea.io/gitea/models/repo"
+	system_model "code.gitea.io/gitea/models/system"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/avatar"
 	"code.gitea.io/gitea/modules/eventsource"
@@ -186,7 +186,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error {
 	path := user_model.UserPath(u.Name)
 	if err := util.RemoveAll(path); err != nil {
 		err = fmt.Errorf("Failed to RemoveAll %s: %v", path, err)
-		_ = admin_model.CreateNotice(ctx, admin_model.NoticeTask, fmt.Sprintf("delete user '%s': %v", u.Name, err))
+		_ = system_model.CreateNotice(ctx, system_model.NoticeTask, fmt.Sprintf("delete user '%s': %v", u.Name, err))
 		return err
 	}
 
@@ -194,7 +194,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error {
 		avatarPath := u.CustomAvatarRelativePath()
 		if err := storage.Avatars.Delete(avatarPath); err != nil {
 			err = fmt.Errorf("Failed to remove %s: %v", avatarPath, err)
-			_ = admin_model.CreateNotice(ctx, admin_model.NoticeTask, fmt.Sprintf("delete user '%s': %v", u.Name, err))
+			_ = system_model.CreateNotice(ctx, system_model.NoticeTask, fmt.Sprintf("delete user '%s': %v", u.Name, err))
 			return err
 		}
 	}
diff --git a/services/wiki/wiki.go b/services/wiki/wiki.go
index d2374ade5..8faada4d5 100644
--- a/services/wiki/wiki.go
+++ b/services/wiki/wiki.go
@@ -12,8 +12,8 @@ import (
 	"os"
 	"strings"
 
-	admin_model "code.gitea.io/gitea/models/admin"
 	repo_model "code.gitea.io/gitea/models/repo"
+	system_model "code.gitea.io/gitea/models/system"
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/git"
@@ -384,6 +384,6 @@ func DeleteWiki(ctx context.Context, repo *repo_model.Repository) error {
 		return err
 	}
 
-	admin_model.RemoveAllWithNotice(ctx, "Delete repository wiki", repo.WikiPath())
+	system_model.RemoveAllWithNotice(ctx, "Delete repository wiki", repo.WikiPath())
 	return nil
 }
diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl
index 0d9432b39..982cfb280 100644
--- a/templates/admin/config.tmpl
+++ b/templates/admin/config.tmpl
@@ -301,10 +301,18 @@
 		<div class="ui attached table segment">
 			<dl class="dl-horizontal admin-dl-horizontal">
 				<dt>{{.locale.Tr "admin.config.disable_gravatar"}}</dt>
-				<dd>{{if .DisableGravatar}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}</dd>
+				<dd>
+					<div class="ui toggle checkbox">
+						<input type="checkbox" name="picture.disable_gravatar" version="{{.SystemSettings.GetVersion "picture.disable_gravatar"}}"{{if .SystemSettings.GetBool "picture.disable_gravatar"}} checked{{end}}>
+					</div>
+				</dd>
 				<div class="ui divider"></div>
 				<dt>{{.locale.Tr "admin.config.enable_federated_avatar"}}</dt>
-				<dd>{{if .EnableFederatedAvatar}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}</dd>
+				<dd>
+					<div class="ui toggle checkbox">
+						<input type="checkbox" name="picture.enable_federated_avatar" version="{{.SystemSettings.GetVersion "picture.enable_federated_avatar"}}"{{if .SystemSettings.GetBool "picture.enable_federated_avatar"}} checked{{end}}>
+					</div>
+				</dd>
 			</dl>
 		</div>
 
diff --git a/web_src/js/features/admin-common.js b/web_src/js/features/admin/common.js
similarity index 100%
rename from web_src/js/features/admin-common.js
rename to web_src/js/features/admin/common.js
diff --git a/web_src/js/features/admin/config.js b/web_src/js/features/admin/config.js
new file mode 100644
index 000000000..f5d8fae8f
--- /dev/null
+++ b/web_src/js/features/admin/config.js
@@ -0,0 +1,37 @@
+import $ from 'jquery';
+import {showTemporaryTooltip} from '../../modules/tippy.js';
+
+const {appSubUrl, csrfToken, pageData} = window.config;
+
+export function initAdminConfigs() {
+  const isAdminConfigPage = pageData?.adminConfigPage;
+  if (!isAdminConfigPage) return;
+
+  $("input[type='checkbox']").on('change', (e) => {
+    const $this = $(e.currentTarget);
+    $.ajax({
+      url: `${appSubUrl}/admin/config`,
+      type: 'POST',
+      data: {
+        _csrf: csrfToken,
+        key: $this.attr('name'),
+        value: $this.is(':checked'),
+        version: $this.attr('version'),
+      }
+    }).done((resp) => {
+      if (resp) {
+        if (resp.redirect) {
+          window.location.href = resp.redirect;
+        } else if (resp.version) {
+          $this.attr('version', resp.version);
+        } else if (resp.err) {
+          showTemporaryTooltip(e.currentTarget, resp.err);
+          $this.prop('checked', !$this.is(':checked'));
+        }
+      }
+    });
+
+    e.preventDefault();
+    return false;
+  });
+}
diff --git a/web_src/js/features/admin-emails.js b/web_src/js/features/admin/emails.js
similarity index 100%
rename from web_src/js/features/admin-emails.js
rename to web_src/js/features/admin/emails.js
diff --git a/web_src/js/features/admin-users.js b/web_src/js/features/admin/users.js
similarity index 100%
rename from web_src/js/features/admin-users.js
rename to web_src/js/features/admin/users.js
diff --git a/web_src/js/index.js b/web_src/js/index.js
index b13ad0e13..a829deaf1 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -16,7 +16,8 @@ import initRepoMigration from './features/repo-migration.js';
 import initRepoProject from './features/repo-projects.js';
 import initServiceWorker from './features/serviceworker.js';
 import initTableSort from './features/tablesort.js';
-import {initAdminUserListSearchForm} from './features/admin-users.js';
+import {initAdminUserListSearchForm} from './features/admin/users.js';
+import {initAdminConfigs} from './features/admin/config.js';
 import {initMarkupAnchors} from './markup/anchors.js';
 import {initNotificationCount, initNotificationsTable} from './features/notification.js';
 import {initRepoIssueContentHistory} from './features/repo-issue-content.js';
@@ -60,8 +61,8 @@ import {
   initGlobalTooltips,
 } from './features/common-global.js';
 import {initRepoTopicBar} from './features/repo-home.js';
-import {initAdminEmails} from './features/admin-emails.js';
-import {initAdminCommon} from './features/admin-common.js';
+import {initAdminEmails} from './features/admin/emails.js';
+import {initAdminCommon} from './features/admin/common.js';
 import {initRepoTemplateSearch} from './features/repo-template.js';
 import {initRepoCodeView} from './features/repo-code.js';
 import {initSshKeyFormParser} from './features/sshkey-helper.js';
@@ -139,6 +140,7 @@ $(document).ready(() => {
   initAdminCommon();
   initAdminEmails();
   initAdminUserListSearchForm();
+  initAdminConfigs();
 
   initDashboardRepoList();
 
diff --git a/web_src/less/_base.less b/web_src/less/_base.less
index 2abebe5c7..bfc6e0cf9 100644
--- a/web_src/less/_base.less
+++ b/web_src/less/_base.less
@@ -131,6 +131,7 @@
   --color-timeline: #ececec;
   --color-input-text: #212121;
   --color-input-background: #ffffff;
+  --color-input-toggle-background: #dedede;
   --color-input-border: #dedede;
   --color-input-border-hover: #cecece;
   --color-navbar: #f8f8f8;
diff --git a/web_src/less/_form.less b/web_src/less/_form.less
index 08e1f324b..3d2ec9fb8 100644
--- a/web_src/less/_form.less
+++ b/web_src/less/_form.less
@@ -113,7 +113,7 @@ textarea:focus,
 }
 
 .ui.toggle.checkbox label::before {
-  background: var(--color-input-background);
+  background: var(--color-input-toggle-background);
 }
 
 .ui.toggle.checkbox label,
diff --git a/web_src/less/themes/theme-arc-green.less b/web_src/less/themes/theme-arc-green.less
index 12dba7926..fe8316215 100644
--- a/web_src/less/themes/theme-arc-green.less
+++ b/web_src/less/themes/theme-arc-green.less
@@ -106,6 +106,7 @@
   --color-timeline: #4c525e;
   --color-input-text: #d5dbe6;
   --color-input-background: #232933;
+  --color-input-toggle-background: #454a57;
   --color-input-border: #454a57;
   --color-input-border-hover: #505667;
   --color-navbar: #2a2e3a;