diff --git a/core/server/server.js b/core/server/server.js
index 8f92e4fd28fd9f27978977191ebc455a1df93890..fb3c5ef090d69fabd7bec8e4b83ab1a55e235e9c 100644
--- a/core/server/server.js
+++ b/core/server/server.js
@@ -128,6 +128,7 @@ const publicConfigSchema = Joi.object({
       },
       restApiVersion: Joi.date().raw().required(),
     },
+    gitea: defaultService,
     gitlab: defaultService,
     jira: defaultService,
     jenkins: Joi.object({
@@ -168,6 +169,7 @@ const privateConfigSchema = Joi.object({
   gh_client_id: Joi.string(),
   gh_client_secret: Joi.string(),
   gh_token: Joi.string(),
+  gitea_token: Joi.string(),
   gitlab_token: Joi.string(),
   jenkins_user: Joi.string(),
   jenkins_pass: Joi.string(),
diff --git a/doc/server-secrets.md b/doc/server-secrets.md
index 3ce330628b35c72e078106e148bdec98ff080852..636a7dcb47adee979df3d2b1fab069ff97e5e407 100644
--- a/doc/server-secrets.md
+++ b/doc/server-secrets.md
@@ -167,6 +167,15 @@ These settings are used by shields.io for GitHub OAuth app authorization
 but will not be necessary for most self-hosted installations. See
 [production-hosting.md](./production-hosting.md).
 
+### Gitea
+
+- `GITEA_ORIGINS` (yml: `public.services.gitea.authorizedOrigins`)
+- `GITEA_TOKEN` (yml: `private.gitea_token`)
+
+A Gitea [Personal Access Token][gitea-pat] is required for accessing private content. If you need a Gitea token for your self-hosted Shields server then we recommend limiting the scopes to the minimal set necessary for the badges you are using.
+
+[gitea-pat]: https://docs.gitea.com/development/api-usage#generating-and-listing-api-tokens
+
 ### GitLab
 
 - `GITLAB_ORIGINS` (yml: `public.services.gitlab.authorizedOrigins`)
diff --git a/services/gitea/gitea-base.js b/services/gitea/gitea-base.js
new file mode 100644
index 0000000000000000000000000000000000000000..2a14ba846ef01b39d9de2fef2cd4063004312e5b
--- /dev/null
+++ b/services/gitea/gitea-base.js
@@ -0,0 +1,19 @@
+import { BaseJsonService } from '../index.js'
+
+export default class GiteaBase extends BaseJsonService {
+  static auth = {
+    passKey: 'gitea_token',
+    serviceKey: 'gitea',
+  }
+
+  async fetch({ url, options, schema, httpErrors }) {
+    return this._requestJson(
+      this.authHelper.withBearerAuthHeader({
+        schema,
+        url,
+        options,
+        httpErrors,
+      }),
+    )
+  }
+}
diff --git a/services/gitea/gitea-base.spec.js b/services/gitea/gitea-base.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..868e964920080f64e60f8fb14551d58bba24dc52
--- /dev/null
+++ b/services/gitea/gitea-base.spec.js
@@ -0,0 +1,48 @@
+import Joi from 'joi'
+import { expect } from 'chai'
+import nock from 'nock'
+import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
+import GiteaBase from './gitea-base.js'
+
+class DummyGiteaService extends GiteaBase {
+  static route = { base: 'fake-base' }
+
+  async handle() {
+    const data = await this.fetch({
+      schema: Joi.any(),
+      url: 'https://codeberg.org/api/v1/repos/CanisHelix/shields-badge-test/releases',
+    })
+    return { message: data.message }
+  }
+}
+
+describe('GiteaBase', function () {
+  describe('auth', function () {
+    cleanUpNockAfterEach()
+
+    const config = {
+      public: {
+        services: {
+          gitea: {
+            authorizedOrigins: ['https://codeberg.org'],
+          },
+        },
+      },
+      private: {
+        gitea_token: 'fake-key',
+      },
+    }
+
+    it('sends the auth information as configured', async function () {
+      const scope = nock('https://codeberg.org')
+        .get('/api/v1/repos/CanisHelix/shields-badge-test/releases')
+        .matchHeader('Authorization', 'Bearer fake-key')
+        .reply(200, { message: 'fake message' })
+      expect(
+        await DummyGiteaService.invoke(defaultContext, config, {}),
+      ).to.not.have.property('isError')
+
+      scope.done()
+    })
+  })
+})
diff --git a/services/gitea/gitea-helper.js b/services/gitea/gitea-helper.js
new file mode 100644
index 0000000000000000000000000000000000000000..51242d819d50878329a54dbaf0dfacb6e52bf81b
--- /dev/null
+++ b/services/gitea/gitea-helper.js
@@ -0,0 +1,12 @@
+const documentation = `
+Note that the gitea_url parameter is required because there is canonical hosted gitea service provided by Gitea.
+`
+
+function httpErrorsFor() {
+  return {
+    403: 'private repo',
+    404: 'user or repo not found',
+  }
+}
+
+export { documentation, httpErrorsFor }
diff --git a/services/gitea/gitea-languages-count.service.js b/services/gitea/gitea-languages-count.service.js
new file mode 100644
index 0000000000000000000000000000000000000000..819198521b6476af68e9e024e8e5fe7a30a45074
--- /dev/null
+++ b/services/gitea/gitea-languages-count.service.js
@@ -0,0 +1,77 @@
+import Joi from 'joi'
+import { nonNegativeInteger, optionalUrl } from '../validators.js'
+import { metric } from '../text-formatters.js'
+import { pathParam, queryParam } from '../index.js'
+import { documentation, httpErrorsFor } from './gitea-helper.js'
+import GiteaBase from './gitea-base.js'
+
+/*
+We're expecting a response like { "Python": 39624, "Shell": 104 }
+The keys could be anything and {} is a valid response (e.g: for an empty repo)
+*/
+const schema = Joi.object().pattern(/./, nonNegativeInteger)
+
+const queryParamSchema = Joi.object({
+  gitea_url: optionalUrl.required(),
+}).required()
+
+export default class GiteaLanguageCount extends GiteaBase {
+  static category = 'analysis'
+
+  static route = {
+    base: 'gitea/languages/count',
+    pattern: ':user/:repo',
+    queryParamSchema,
+  }
+
+  static openApi = {
+    '/gitea/languages/count/{user}/{repo}': {
+      get: {
+        summary: 'Gitea language count',
+        description: documentation,
+        parameters: [
+          pathParam({
+            name: 'user',
+            example: 'forgejo',
+          }),
+          pathParam({
+            name: 'repo',
+            example: 'forgejo',
+          }),
+          queryParam({
+            name: 'gitea_url',
+            example: 'https://codeberg.org',
+            required: true,
+          }),
+        ],
+      },
+    },
+  }
+
+  static defaultBadgeData = { label: 'languages' }
+
+  static render({ languagesCount }) {
+    return {
+      message: metric(languagesCount),
+      color: 'blue',
+    }
+  }
+
+  async fetch({ user, repo, baseUrl }) {
+    // https://try.gitea.io/api/swagger#/repository/repoGetLanguages
+    return super.fetch({
+      schema,
+      url: `${baseUrl}/api/v1/repos/${user}/${repo}/languages`,
+      httpErrors: httpErrorsFor('user or repo not found'),
+    })
+  }
+
+  async handle({ user, repo }, { gitea_url: baseUrl }) {
+    const data = await this.fetch({
+      user,
+      repo,
+      baseUrl,
+    })
+    return this.constructor.render({ languagesCount: Object.keys(data).length })
+  }
+}
diff --git a/services/gitea/gitea-languages-count.tester.js b/services/gitea/gitea-languages-count.tester.js
new file mode 100644
index 0000000000000000000000000000000000000000..9d4f6a965e327fe119e6c0dbd78759595e272c21
--- /dev/null
+++ b/services/gitea/gitea-languages-count.tester.js
@@ -0,0 +1,27 @@
+import Joi from 'joi'
+import { createServiceTester } from '../tester.js'
+
+export const t = await createServiceTester()
+
+t.create('language count (empty repo)')
+  .get(
+    '/CanisHelix/shields-badge-test-empty.json?gitea_url=https://codeberg.org',
+  )
+  .expectBadge({
+    label: 'languages',
+    message: '0',
+  })
+
+t.create('language count')
+  .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org')
+  .expectBadge({
+    label: 'languages',
+    message: Joi.number().integer().positive(),
+  })
+
+t.create('language count (user or repo not found)')
+  .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org')
+  .expectBadge({
+    label: 'languages',
+    message: 'user or repo not found',
+  })
diff --git a/services/gitea/gitea-release.service.js b/services/gitea/gitea-release.service.js
new file mode 100644
index 0000000000000000000000000000000000000000..2188f0e135fcf3300602f90dc8b4d3ccb6739d76
--- /dev/null
+++ b/services/gitea/gitea-release.service.js
@@ -0,0 +1,147 @@
+import Joi from 'joi'
+import { optionalUrl } from '../validators.js'
+import { latest, renderVersionBadge } from '../version.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
+import { documentation, httpErrorsFor } from './gitea-helper.js'
+import GiteaBase from './gitea-base.js'
+
+const schema = Joi.array().items(
+  Joi.object({
+    name: Joi.string().required(),
+    tag_name: Joi.string().required(),
+    prerelease: Joi.boolean().required(),
+  }),
+)
+
+const sortEnum = ['date', 'semver']
+const displayNameEnum = ['tag', 'release']
+const dateOrderByEnum = ['created_at', 'published_at']
+
+const queryParamSchema = Joi.object({
+  gitea_url: optionalUrl.required(),
+  include_prereleases: Joi.equal(''),
+  sort: Joi.string()
+    .valid(...sortEnum)
+    .default('date'),
+  display_name: Joi.string()
+    .valid(...displayNameEnum)
+    .default('tag'),
+  date_order_by: Joi.string()
+    .valid(...dateOrderByEnum)
+    .default('created_at'),
+}).required()
+
+export default class GiteaRelease extends GiteaBase {
+  static category = 'version'
+
+  static route = {
+    base: 'gitea/v/release',
+    pattern: ':user/:repo',
+    queryParamSchema,
+  }
+
+  static openApi = {
+    '/gitea/v/release/{user}/{repo}': {
+      get: {
+        summary: 'Gitea Release',
+        description: documentation,
+        parameters: [
+          pathParam({
+            name: 'user',
+            example: 'forgejo',
+          }),
+          pathParam({
+            name: 'repo',
+            example: 'forgejo',
+          }),
+          queryParam({
+            name: 'gitea_url',
+            example: 'https://codeberg.org',
+            required: true,
+          }),
+          queryParam({
+            name: 'include_prereleases',
+            schema: { type: 'boolean' },
+            example: null,
+          }),
+          queryParam({
+            name: 'sort',
+            schema: { type: 'string', enum: sortEnum },
+            example: 'semver',
+          }),
+          queryParam({
+            name: 'display_name',
+            schema: { type: 'string', enum: displayNameEnum },
+            example: 'release',
+          }),
+          queryParam({
+            name: 'date_order_by',
+            schema: { type: 'string', enum: dateOrderByEnum },
+            example: 'created_at',
+          }),
+        ],
+      },
+    },
+  }
+
+  static defaultBadgeData = { label: 'release' }
+
+  async fetch({ user, repo, baseUrl }) {
+    // https://try.gitea.io/api/swagger#/repository/repoGetRelease
+    return super.fetch({
+      schema,
+      url: `${baseUrl}/api/v1/repos/${user}/${repo}/releases`,
+      httpErrors: httpErrorsFor(),
+    })
+  }
+
+  static transform({ releases, isSemver, includePrereleases, displayName }) {
+    if (releases.length === 0) {
+      throw new NotFound({ prettyMessage: 'no releases found' })
+    }
+
+    const displayKey = displayName === 'tag' ? 'tag_name' : 'name'
+
+    if (isSemver) {
+      return latest(
+        releases.map(t => t[displayKey]),
+        { pre: includePrereleases },
+      )
+    }
+
+    if (!includePrereleases) {
+      const stableReleases = releases.filter(release => !release.prerelease)
+      if (stableReleases.length > 0) {
+        return stableReleases[0][displayKey]
+      }
+    }
+
+    return releases[0][displayKey]
+  }
+
+  async handle(
+    { user, repo },
+    {
+      gitea_url: baseUrl,
+      include_prereleases: pre,
+      sort,
+      display_name: displayName,
+      date_order_by: orderBy,
+    },
+  ) {
+    const isSemver = sort === 'semver'
+    const releases = await this.fetch({
+      user,
+      repo,
+      baseUrl,
+      isSemver,
+    })
+    const version = this.constructor.transform({
+      releases,
+      isSemver,
+      includePrereleases: pre !== undefined,
+      displayName,
+    })
+    return renderVersionBadge({ version })
+  }
+}
diff --git a/services/gitea/gitea-release.tester.js b/services/gitea/gitea-release.tester.js
new file mode 100644
index 0000000000000000000000000000000000000000..1f6894e6215cf4ecb3fd43d1e9eae19b49c43b0b
--- /dev/null
+++ b/services/gitea/gitea-release.tester.js
@@ -0,0 +1,40 @@
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('Release (latest by date)')
+  .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org')
+  .expectBadge({ label: 'release', message: 'v3.0.0', color: 'blue' })
+
+t.create('Release (latest by date, order by created_at)')
+  .get(
+    '/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&date_order_by=created_at',
+  )
+  .expectBadge({ label: 'release', message: 'v3.0.0', color: 'blue' })
+
+t.create('Release (latest by date, order by published_at)')
+  .get(
+    '/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&date_order_by=published_at',
+  )
+  .expectBadge({ label: 'release', message: 'v3.0.0', color: 'blue' })
+
+t.create('Release (latest by semver)')
+  .get(
+    '/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&sort=semver',
+  )
+  .expectBadge({ label: 'release', message: 'v4.0.0', color: 'blue' })
+
+t.create('Release (latest by semver pre-release)')
+  .get(
+    '/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org&sort=semver&include_prereleases',
+  )
+  .expectBadge({ label: 'release', message: 'v5.0.0-rc1', color: 'orange' })
+
+t.create('Release (project not found)')
+  .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org')
+  .expectBadge({ label: 'release', message: 'user or repo not found' })
+
+t.create('Release (no tags)')
+  .get(
+    '/CanisHelix/shields-badge-test-empty.json?gitea_url=https://codeberg.org',
+  )
+  .expectBadge({ label: 'release', message: 'no releases found' })