diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 470fe27598..be5b306b49 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -912,6 +912,14 @@ LEVEL = Info
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;[badges]
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Enable repository badges (via shields.io or a similar generator)
+;ENABLED = true
+;; Template for the badge generator.
+;GENERATOR_URL_TEMPLATE = https://img.shields.io/badge/{{.label}}-{{.text}}-{{.color}}
+
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 ;[repository]
 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/models/actions/run.go b/models/actions/run.go
index db0f380049..5d4e3b74dd 100644
--- a/models/actions/run.go
+++ b/models/actions/run.go
@@ -323,6 +323,21 @@ func GetLatestRun(ctx context.Context, repoID int64) (*ActionRun, error) {
 	return &run, nil
 }
 
+func GetLatestRunForBranchAndWorkflow(ctx context.Context, repoID int64, branch, workflowFile, event string) (*ActionRun, error) {
+	var run ActionRun
+	q := db.GetEngine(ctx).Where("repo_id=?", repoID).And("ref=?", branch).And("workflow_id=?", workflowFile)
+	if event != "" {
+		q = q.And("event=?", event)
+	}
+	has, err := q.Desc("id").Get(&run)
+	if err != nil {
+		return nil, err
+	} else if !has {
+		return nil, util.NewNotExistErrorf("run with repo_id %d, ref %s, workflow_id %s", repoID, branch, workflowFile)
+	}
+	return &run, nil
+}
+
 func GetRunByID(ctx context.Context, id int64) (*ActionRun, error) {
 	var run ActionRun
 	has, err := db.GetEngine(ctx).Where("id=?", id).Get(&run)
diff --git a/modules/setting/badges.go b/modules/setting/badges.go
new file mode 100644
index 0000000000..e0c1cb55ec
--- /dev/null
+++ b/modules/setting/badges.go
@@ -0,0 +1,24 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+import (
+	"text/template"
+)
+
+// Badges settings
+var Badges = struct {
+	Enabled                      bool               `ini:"ENABLED"`
+	GeneratorURLTemplate         string             `ini:"GENERATOR_URL_TEMPLATE"`
+	GeneratorURLTemplateTemplate *template.Template `ini:"-"`
+}{
+	Enabled:              true,
+	GeneratorURLTemplate: "https://img.shields.io/badge/{{.label}}-{{.text}}-{{.color}}",
+}
+
+func loadBadgesFrom(rootCfg ConfigProvider) {
+	mustMapSetting(rootCfg, "badges", &Badges)
+
+	Badges.GeneratorURLTemplateTemplate = template.Must(template.New("").Parse(Badges.GeneratorURLTemplate))
+}
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index ebfd3b27be..c0d8d0ee23 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -147,6 +147,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
 	loadUIFrom(cfg)
 	loadAdminFrom(cfg)
 	loadAPIFrom(cfg)
+	loadBadgesFrom(cfg)
 	loadMetricsFrom(cfg)
 	loadCamoFrom(cfg)
 	loadI18nFrom(cfg)
diff --git a/routers/web/repo/badges/badges.go b/routers/web/repo/badges/badges.go
new file mode 100644
index 0000000000..8fe99c7fc1
--- /dev/null
+++ b/routers/web/repo/badges/badges.go
@@ -0,0 +1,165 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package badges
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	actions_model "code.gitea.io/gitea/models/actions"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unit"
+	context_module "code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/setting"
+)
+
+func getBadgeURL(ctx *context_module.Context, label, text, color string) string {
+	sb := &strings.Builder{}
+	_ = setting.Badges.GeneratorURLTemplateTemplate.Execute(sb, map[string]string{
+		"label": url.PathEscape(label),
+		"text":  url.PathEscape(text),
+		"color": url.PathEscape(color),
+	})
+
+	badgeURL := sb.String()
+	q := ctx.Req.URL.Query()
+	// Remove any `branch` or `event` query parameters. They're used by the
+	// workflow badge route, and do not need forwarding to the badge generator.
+	delete(q, "branch")
+	delete(q, "event")
+	if len(q) > 0 {
+		return fmt.Sprintf("%s?%s", badgeURL, q.Encode())
+	}
+	return badgeURL
+}
+
+func redirectToBadge(ctx *context_module.Context, label, text, color string) {
+	ctx.Redirect(getBadgeURL(ctx, label, text, color))
+}
+
+func errorBadge(ctx *context_module.Context, label, text string) {
+	ctx.Redirect(getBadgeURL(ctx, label, text, "crimson"))
+}
+
+func GetWorkflowBadge(ctx *context_module.Context) {
+	branch := ctx.Req.URL.Query().Get("branch")
+	if branch == "" {
+		branch = ctx.Repo.Repository.DefaultBranch
+	}
+	branch = fmt.Sprintf("refs/heads/%s", branch)
+	event := ctx.Req.URL.Query().Get("event")
+
+	workflowFile := ctx.Params("workflow_name")
+	run, err := actions_model.GetLatestRunForBranchAndWorkflow(ctx, ctx.Repo.Repository.ID, branch, workflowFile, event)
+	if err != nil {
+		errorBadge(ctx, workflowFile, "Not found")
+		return
+	}
+
+	var color string
+	switch run.Status {
+	case actions_model.StatusUnknown:
+		color = "lightgrey"
+	case actions_model.StatusWaiting:
+		color = "lightgrey"
+	case actions_model.StatusRunning:
+		color = "gold"
+	case actions_model.StatusSuccess:
+		color = "brightgreen"
+	case actions_model.StatusFailure:
+		color = "crimson"
+	case actions_model.StatusCancelled:
+		color = "orange"
+	case actions_model.StatusSkipped:
+		color = "blue"
+	case actions_model.StatusBlocked:
+		color = "yellow"
+	default:
+		color = "lightgrey"
+	}
+
+	redirectToBadge(ctx, workflowFile, run.Status.String(), color)
+}
+
+func getIssueOrPullBadge(ctx *context_module.Context, label, variant string, num int) {
+	var text string
+	if len(variant) > 0 {
+		text = fmt.Sprintf("%d %s", num, variant)
+	} else {
+		text = fmt.Sprintf("%d", num)
+	}
+	redirectToBadge(ctx, label, text, "blue")
+}
+
+func getIssueBadge(ctx *context_module.Context, variant string, num int) {
+	if !ctx.Repo.CanRead(unit.TypeIssues) &&
+		!ctx.Repo.CanRead(unit.TypeExternalTracker) {
+		errorBadge(ctx, "issues", "Not found")
+		return
+	}
+
+	_, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker)
+	if err == nil {
+		errorBadge(ctx, "issues", "Not found")
+		return
+	}
+
+	getIssueOrPullBadge(ctx, "issues", variant, num)
+}
+
+func getPullBadge(ctx *context_module.Context, variant string, num int) {
+	if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) {
+		errorBadge(ctx, "pulls", "Not found")
+		return
+	}
+
+	getIssueOrPullBadge(ctx, "pulls", variant, num)
+}
+
+func GetOpenIssuesBadge(ctx *context_module.Context) {
+	getIssueBadge(ctx, "open", ctx.Repo.Repository.NumOpenIssues)
+}
+
+func GetClosedIssuesBadge(ctx *context_module.Context) {
+	getIssueBadge(ctx, "closed", ctx.Repo.Repository.NumClosedIssues)
+}
+
+func GetTotalIssuesBadge(ctx *context_module.Context) {
+	getIssueBadge(ctx, "", ctx.Repo.Repository.NumIssues)
+}
+
+func GetOpenPullsBadge(ctx *context_module.Context) {
+	getPullBadge(ctx, "open", ctx.Repo.Repository.NumOpenPulls)
+}
+
+func GetClosedPullsBadge(ctx *context_module.Context) {
+	getPullBadge(ctx, "closed", ctx.Repo.Repository.NumClosedPulls)
+}
+
+func GetTotalPullsBadge(ctx *context_module.Context) {
+	getPullBadge(ctx, "", ctx.Repo.Repository.NumPulls)
+}
+
+func GetStarsBadge(ctx *context_module.Context) {
+	redirectToBadge(ctx, "stars", fmt.Sprintf("%d", ctx.Repo.Repository.NumStars), "blue")
+}
+
+func GetLatestReleaseBadge(ctx *context_module.Context) {
+	release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID)
+	if err != nil {
+		if repo_model.IsErrReleaseNotExist(err) {
+			errorBadge(ctx, "release", "Not found")
+			return
+		}
+		ctx.ServerError("GetLatestReleaseByRepoID", err)
+	}
+
+	if err := release.LoadAttributes(ctx); err != nil {
+		ctx.ServerError("LoadAttributes", err)
+		return
+	}
+
+	redirectToBadge(ctx, "release", release.TagName, "blue")
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 4dbe348c54..9e48b8872e 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -37,6 +37,7 @@ import (
 	org_setting "code.gitea.io/gitea/routers/web/org/setting"
 	"code.gitea.io/gitea/routers/web/repo"
 	"code.gitea.io/gitea/routers/web/repo/actions"
+	"code.gitea.io/gitea/routers/web/repo/badges"
 	repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
 	"code.gitea.io/gitea/routers/web/user"
 	user_setting "code.gitea.io/gitea/routers/web/user/setting"
@@ -1316,6 +1317,24 @@ func registerRoutes(m *web.Route) {
 			m.Get("/packages", repo.Packages)
 		}
 
+		if setting.Badges.Enabled {
+			m.Group("/badges", func() {
+				m.Get("/workflows/{workflow_name}/badge.svg", badges.GetWorkflowBadge)
+				m.Group("/issues", func() {
+					m.Get(".svg", badges.GetTotalIssuesBadge)
+					m.Get("/open.svg", badges.GetOpenIssuesBadge)
+					m.Get("/closed.svg", badges.GetClosedIssuesBadge)
+				})
+				m.Group("/pulls", func() {
+					m.Get(".svg", badges.GetTotalPullsBadge)
+					m.Get("/open.svg", badges.GetOpenPullsBadge)
+					m.Get("/closed.svg", badges.GetClosedPullsBadge)
+				})
+				m.Get("/stars.svg", badges.GetStarsBadge)
+				m.Get("/release.svg", badges.GetLatestReleaseBadge)
+			})
+		}
+
 		m.Group("/projects", func() {
 			m.Get("", repo.Projects)
 			m.Get("/{id}", repo.ViewProject)
@@ -1367,6 +1386,8 @@ func registerRoutes(m *web.Route) {
 					m.Post("/rerun", reqRepoActionsWriter, actions.Rerun)
 				})
 			})
+
+			m.Get("/workflows/{workflow_name}/badge.svg", badges.GetWorkflowBadge)
 		}, reqRepoActionsReader, actions.MustEnableActions)
 
 		m.Group("/wiki", func() {
diff --git a/tests/integration/repo_badges_test.go b/tests/integration/repo_badges_test.go
new file mode 100644
index 0000000000..e4b634d1a8
--- /dev/null
+++ b/tests/integration/repo_badges_test.go
@@ -0,0 +1,237 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+
+	actions_model "code.gitea.io/gitea/models/actions"
+	"code.gitea.io/gitea/models/db"
+	repo_model "code.gitea.io/gitea/models/repo"
+	unit_model "code.gitea.io/gitea/models/unit"
+	"code.gitea.io/gitea/models/unittest"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/modules/test"
+	repo_service "code.gitea.io/gitea/services/repository"
+	files_service "code.gitea.io/gitea/services/repository/files"
+	"code.gitea.io/gitea/tests"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func assertBadge(t *testing.T, resp *httptest.ResponseRecorder, badge string) {
+	assert.Equal(t, fmt.Sprintf("https://img.shields.io/badge/%s", badge), test.RedirectURL(resp))
+}
+
+func createMinimalRepo(t *testing.T) func() {
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+	// Create a new repository
+	repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+		Name:          "minimal",
+		Description:   "minimal repo for badge testing",
+		AutoInit:      true,
+		Gitignores:    "Go",
+		License:       "MIT",
+		Readme:        "Default",
+		DefaultBranch: "main",
+		IsPrivate:     false,
+	})
+	assert.NoError(t, err)
+	assert.NotEmpty(t, repo)
+
+	// Enable Actions, and disable Issues, PRs and Releases
+	err = repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{
+		RepoID: repo.ID,
+		Type:   unit_model.TypeActions,
+	}}, []unit_model.Type{unit_model.TypeIssues, unit_model.TypePullRequests, unit_model.TypeReleases})
+	assert.NoError(t, err)
+
+	return func() {
+		repo_service.DeleteRepository(db.DefaultContext, user2, repo, false)
+	}
+}
+
+func addWorkflow(t *testing.T) {
+	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "minimal")
+	assert.NoError(t, err)
+
+	// Add a workflow file to the repo
+	addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+		Files: []*files_service.ChangeRepoFile{
+			{
+				Operation:     "create",
+				TreePath:      ".gitea/workflows/pr.yml",
+				ContentReader: strings.NewReader("name: test\non:\n  push:\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo helloworld\n"),
+			},
+		},
+		Message:   "add workflow",
+		OldBranch: "main",
+		NewBranch: "main",
+		Author: &files_service.IdentityOptions{
+			Name:  user2.Name,
+			Email: user2.Email,
+		},
+		Committer: &files_service.IdentityOptions{
+			Name:  user2.Name,
+			Email: user2.Email,
+		},
+		Dates: &files_service.CommitDateOptions{
+			Author:    time.Now(),
+			Committer: time.Now(),
+		},
+	})
+	assert.NoError(t, err)
+	assert.NotEmpty(t, addWorkflowToBaseResp)
+
+	assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
+}
+
+func TestWorkflowBadges(t *testing.T) {
+	onGiteaRun(t, func(t *testing.T, u *url.URL) {
+		defer tests.PrintCurrentTest(t)()
+		defer createMinimalRepo(t)()
+
+		addWorkflow(t)
+
+		// Actions disabled
+		req := NewRequest(t, "GET", "/user2/repo1/badges/workflows/test.yaml/badge.svg")
+		resp := MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "test.yaml-Not%20found-crimson")
+
+		req = NewRequest(t, "GET", "/user2/repo1/badges/workflows/test.yaml/badge.svg?branch=no-such-branch")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "test.yaml-Not%20found-crimson")
+
+		// Actions enabled
+		req = NewRequest(t, "GET", "/user2/minimal/badges/workflows/pr.yml/badge.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pr.yml-waiting-lightgrey")
+
+		req = NewRequest(t, "GET", "/user2/minimal/badges/workflows/pr.yml/badge.svg?branch=main")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pr.yml-waiting-lightgrey")
+
+		req = NewRequest(t, "GET", "/user2/minimal/badges/workflows/pr.yml/badge.svg?branch=no-such-branch")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pr.yml-Not%20found-crimson")
+
+		req = NewRequest(t, "GET", "/user2/minimal/badges/workflows/pr.yml/badge.svg?event=cron")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pr.yml-Not%20found-crimson")
+
+		// GitHub compatibility
+		req = NewRequest(t, "GET", "/user2/minimal/actions/workflows/pr.yml/badge.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pr.yml-waiting-lightgrey")
+
+		req = NewRequest(t, "GET", "/user2/minimal/actions/workflows/pr.yml/badge.svg?branch=main")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pr.yml-waiting-lightgrey")
+
+		req = NewRequest(t, "GET", "/user2/minimal/actions/workflows/pr.yml/badge.svg?branch=no-such-branch")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pr.yml-Not%20found-crimson")
+
+		req = NewRequest(t, "GET", "/user2/minimal/actions/workflows/pr.yml/badge.svg?event=cron")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pr.yml-Not%20found-crimson")
+	})
+}
+
+func TestBadges(t *testing.T) {
+	defer tests.PrepareTestEnv(t)()
+
+	t.Run("Stars", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+
+		req := NewRequest(t, "GET", "/user2/repo1/badges/stars.svg")
+		resp := MakeRequest(t, req, http.StatusSeeOther)
+
+		assertBadge(t, resp, "stars-0-blue")
+	})
+
+	t.Run("Issues", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		defer createMinimalRepo(t)()
+
+		// Issues enabled
+		req := NewRequest(t, "GET", "/user2/repo1/badges/issues.svg")
+		resp := MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "issues-2-blue")
+
+		req = NewRequest(t, "GET", "/user2/repo1/badges/issues/open.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "issues-1%20open-blue")
+
+		req = NewRequest(t, "GET", "/user2/repo1/badges/issues/closed.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "issues-1%20closed-blue")
+
+		// Issues disabled
+		req = NewRequest(t, "GET", "/user2/minimal/badges/issues.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "issues-Not%20found-crimson")
+
+		req = NewRequest(t, "GET", "/user2/minimal/badges/issues/open.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "issues-Not%20found-crimson")
+
+		req = NewRequest(t, "GET", "/user2/minimal/badges/issues/closed.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "issues-Not%20found-crimson")
+	})
+
+	t.Run("Pulls", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		defer createMinimalRepo(t)()
+
+		// Pull requests enabled
+		req := NewRequest(t, "GET", "/user2/repo1/badges/pulls.svg")
+		resp := MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pulls-3-blue")
+
+		req = NewRequest(t, "GET", "/user2/repo1/badges/pulls/open.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pulls-3%20open-blue")
+
+		req = NewRequest(t, "GET", "/user2/repo1/badges/pulls/closed.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pulls-0%20closed-blue")
+
+		// Pull requests disabled
+		req = NewRequest(t, "GET", "/user2/minimal/badges/pulls.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pulls-Not%20found-crimson")
+
+		req = NewRequest(t, "GET", "/user2/minimal/badges/pulls/open.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pulls-Not%20found-crimson")
+
+		req = NewRequest(t, "GET", "/user2/minimal/badges/pulls/closed.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "pulls-Not%20found-crimson")
+	})
+
+	t.Run("Release", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		defer createMinimalRepo(t)()
+
+		req := NewRequest(t, "GET", "/user2/repo1/badges/release.svg")
+		resp := MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "release-v1.1-blue")
+
+		req = NewRequest(t, "GET", "/user2/minimal/badges/release.svg")
+		resp = MakeRequest(t, req, http.StatusSeeOther)
+		assertBadge(t, resp, "release-Not%20found-crimson")
+	})
+}