diff --git a/services/gitea/gitea-helper.js b/services/gitea/gitea-helper.js
index 56bba952078916d7adb2d30f41be761ef6878309..c3c74e3d8435de9a3be8fe9c97db3ede97b62c9a 100644
--- a/services/gitea/gitea-helper.js
+++ b/services/gitea/gitea-helper.js
@@ -8,7 +8,7 @@ To specify another instance like [codeberg](https://codeberg.org/), [forgejo](ht
 function httpErrorsFor() {
   return {
     403: 'private repo',
-    404: 'user or repo not found',
+    404: 'user, repo or path not found',
   }
 }
 
diff --git a/services/gitea/gitea-last-commit.service.js b/services/gitea/gitea-last-commit.service.js
index cb40bb48becb70fe88ffdfb5110cee0f595f900e..bf9c3c28df956def05824036ad1d373c00c5cb08 100644
--- a/services/gitea/gitea-last-commit.service.js
+++ b/services/gitea/gitea-last-commit.service.js
@@ -1,8 +1,8 @@
 import Joi from 'joi'
+import { age as ageColor } from '../color-formatters.js'
 import { pathParam, queryParam } from '../index.js'
-import { optionalUrl } from '../validators.js'
 import { formatDate } from '../text-formatters.js'
-import { age as ageColor } from '../color-formatters.js'
+import { optionalUrl, relativeUri } from '../validators.js'
 import GiteaBase from './gitea-base.js'
 import { description, httpErrorsFor } from './gitea-helper.js'
 
@@ -25,10 +25,11 @@ const schema = Joi.array()
 const displayEnum = ['author', 'committer']
 
 const queryParamSchema = Joi.object({
-  gitea_url: optionalUrl,
+  path: relativeUri,
   display_timestamp: Joi.string()
     .valid(...displayEnum)
     .default('author'),
+  gitea_url: optionalUrl,
 }).required()
 
 export default class GiteaLastCommit extends GiteaBase {
@@ -54,6 +55,12 @@ export default class GiteaLastCommit extends GiteaBase {
             name: 'repo',
             example: 'tea',
           }),
+          queryParam({
+            name: 'path',
+            example: 'README.md',
+            schema: { type: 'string' },
+            description: 'File path to resolve the last commit for.',
+          }),
           queryParam({
             name: 'display_timestamp',
             example: 'committer',
@@ -84,6 +91,12 @@ export default class GiteaLastCommit extends GiteaBase {
             name: 'branch',
             example: 'main',
           }),
+          queryParam({
+            name: 'path',
+            example: 'README.md',
+            schema: { type: 'string' },
+            description: 'File path to resolve the last commit for.',
+          }),
           queryParam({
             name: 'display_timestamp',
             example: 'committer',
@@ -108,12 +121,12 @@ export default class GiteaLastCommit extends GiteaBase {
     }
   }
 
-  async fetch({ user, repo, branch, baseUrl }) {
+  async fetch({ user, repo, branch, baseUrl, path }) {
     // https://gitea.com/api/swagger#/repository
     return super.fetch({
       schema,
       url: `${baseUrl}/api/v1/repos/${user}/${repo}/commits`,
-      options: { searchParams: { sha: branch } },
+      options: { searchParams: { sha: branch, path } },
       httpErrors: httpErrorsFor(),
     })
   }
@@ -123,6 +136,7 @@ export default class GiteaLastCommit extends GiteaBase {
     {
       gitea_url: baseUrl = 'https://gitea.com',
       display_timestamp: displayTimestamp,
+      path,
     },
   ) {
     const body = await this.fetch({
@@ -130,6 +144,7 @@ export default class GiteaLastCommit extends GiteaBase {
       repo,
       branch,
       baseUrl,
+      path,
     })
     return this.constructor.render({
       commitDate: body[0].commit[displayTimestamp].date,
diff --git a/services/gitea/gitea-last-commit.tester.js b/services/gitea/gitea-last-commit.tester.js
index bc1e133cbfd683b0ddc6fc6a41a868e6a26c870e..f77ac6af27b60c8d52c49a03be069dca53d804dd 100644
--- a/services/gitea/gitea-last-commit.tester.js
+++ b/services/gitea/gitea-last-commit.tester.js
@@ -8,6 +8,41 @@ t.create('Last Commit (recent)').get('/gitea/tea.json').expectBadge({
   message: isFormattedDate,
 })
 
+t.create('Last Commit (recent) (top-level file path)')
+  .get('/gitea/tea.json?path=README.md')
+  .expectBadge({
+    label: 'last commit',
+    message: isFormattedDate,
+  })
+
+t.create('Last Commit (recent) (top-level dir path)')
+  .get('/gitea/tea.json?path=docs')
+  .expectBadge({
+    label: 'last commit',
+    message: isFormattedDate,
+  })
+
+t.create('Last Commit (recent) (top-level dir path with trailing slash)')
+  .get('/gitea/tea.json?path=docs/')
+  .expectBadge({
+    label: 'last commit',
+    message: isFormattedDate,
+  })
+
+t.create('Last Commit (recent) (nested dir path)')
+  .get('/gitea/tea.json?path=docs/CLI.md')
+  .expectBadge({
+    label: 'last commit',
+    message: isFormattedDate,
+  })
+
+t.create('Last Commit (recent) (path)')
+  .get('/gitea/tea.json?path=README.md')
+  .expectBadge({
+    label: 'last commit',
+    message: isFormattedDate,
+  })
+
 t.create('Last Commit (recent) (self-managed)')
   .get('/CanisHelix/shields-badge-test.json?gitea_url=https://codeberg.org')
   .expectBadge({
@@ -24,9 +59,23 @@ t.create('Last Commit (on-branch) (self-managed)')
     message: isFormattedDate,
   })
 
-t.create('Last Commit (project not found)')
+t.create('Last Commit (user not found)')
   .get('/CanisHelix/does-not-exist.json?gitea_url=https://codeberg.org')
   .expectBadge({
     label: 'last commit',
-    message: 'user or repo not found',
+    message: 'user, repo or path not found',
+  })
+
+t.create('Last Commit (repo not found)')
+  .get('/gitea/not-a-repo.json')
+  .expectBadge({
+    label: 'last commit',
+    message: 'user, repo or path not found',
+  })
+
+t.create('Last Commit (path not found)')
+  .get('/gitea/tea.json?path=not/a/dir')
+  .expectBadge({
+    label: 'last commit',
+    message: 'user, repo or path not found',
   })
diff --git a/services/github/github-last-commit.service.js b/services/github/github-last-commit.service.js
index 58759c13b1b9d3da42079b3cede0c08ca09194e1..b0fb5d4fc4efc1ffe5c8308462712e78b61c589a 100644
--- a/services/github/github-last-commit.service.js
+++ b/services/github/github-last-commit.service.js
@@ -1,7 +1,8 @@
 import Joi from 'joi'
-import { pathParam, queryParam } from '../index.js'
-import { formatDate } from '../text-formatters.js'
 import { age as ageColor } from '../color-formatters.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
+import { formatDate } from '../text-formatters.js'
+import { relativeUri } from '../validators.js'
 import { GithubAuthV3Service } from './github-auth-service.js'
 import { documentation, httpErrorsFor } from './github-helpers.js'
 
@@ -16,14 +17,14 @@ const schema = Joi.array()
           date: Joi.string().required(),
         }).required(),
       }).required(),
-    }).required(),
+    }),
   )
   .required()
-  .min(1)
 
 const displayEnum = ['author', 'committer']
 
 const queryParamSchema = Joi.object({
+  path: relativeUri,
   display_timestamp: Joi.string()
     .valid(...displayEnum)
     .default('author'),
@@ -45,6 +46,12 @@ export default class GithubLastCommit extends GithubAuthV3Service {
         parameters: [
           pathParam({ name: 'user', example: 'google' }),
           pathParam({ name: 'repo', example: 'skia' }),
+          queryParam({
+            name: 'path',
+            example: 'README.md',
+            schema: { type: 'string' },
+            description: 'File path to resolve the last commit for.',
+          }),
           queryParam({
             name: 'display_timestamp',
             example: 'committer',
@@ -62,6 +69,12 @@ export default class GithubLastCommit extends GithubAuthV3Service {
           pathParam({ name: 'user', example: 'google' }),
           pathParam({ name: 'repo', example: 'skia' }),
           pathParam({ name: 'branch', example: 'infra/config' }),
+          queryParam({
+            name: 'path',
+            example: 'README.md',
+            schema: { type: 'string' },
+            description: 'File path to resolve the last commit for.',
+          }),
           queryParam({
             name: 'display_timestamp',
             example: 'committer',
@@ -82,20 +95,24 @@ export default class GithubLastCommit extends GithubAuthV3Service {
     }
   }
 
-  async fetch({ user, repo, branch }) {
+  async fetch({ user, repo, branch, path }) {
     return this._requestJson({
       url: `/repos/${user}/${repo}/commits`,
-      options: { searchParams: { sha: branch } },
+      options: { searchParams: { sha: branch, path } },
       schema,
       httpErrors: httpErrorsFor(),
     })
   }
 
   async handle({ user, repo, branch }, queryParams) {
-    const body = await this.fetch({ user, repo, branch })
+    const { path, display_timestamp: displayTimestamp } = queryParams
+    const body = await this.fetch({ user, repo, branch, path })
+    const [commit] = body.map(obj => obj.commit)
+
+    if (!commit) throw new NotFound({ prettyMessage: 'no commits found' })
 
     return this.constructor.render({
-      commitDate: body[0].commit[queryParams.display_timestamp].date,
+      commitDate: commit[displayTimestamp].date,
     })
   }
 }
diff --git a/services/github/github-last-commit.tester.js b/services/github/github-last-commit.tester.js
index f1399dc3d103420ee3d2a6167d088e55c289d4bd..fe82cc742517855e510664b8676d0a71c7d16c3f 100644
--- a/services/github/github-last-commit.tester.js
+++ b/services/github/github-last-commit.tester.js
@@ -14,6 +14,26 @@ t.create('last commit (on branch)')
   .get('/badges/badgr.co/shielded.json')
   .expectBadge({ label: 'last commit', message: 'july 2013' })
 
+t.create('last commit (by top-level file path)')
+  .get('/badges/badgr.co.json?path=README.md')
+  .expectBadge({ label: 'last commit', message: 'september 2013' })
+
+t.create('last commit (by top-level dir path)')
+  .get('/badges/badgr.co.json?path=badgr')
+  .expectBadge({ label: 'last commit', message: 'june 2013' })
+
+t.create('last commit (by top-level dir path with trailing slash)')
+  .get('/badges/badgr.co.json?path=badgr/')
+  .expectBadge({ label: 'last commit', message: 'june 2013' })
+
+t.create('last commit (by nested file path)')
+  .get('/badges/badgr.co.json?path=badgr/colors.py')
+  .expectBadge({ label: 'last commit', message: 'june 2013' })
+
+t.create('last commit (on branch) (by top-level file path)')
+  .get('/badges/badgr.co/shielded.json?path=README.md')
+  .expectBadge({ label: 'last commit', message: 'june 2013' })
+
 t.create('last commit (by committer)')
   .get('/badges/badgr.co/shielded.json?display_timestamp=committer')
   .expectBadge({ label: 'last commit', message: 'july 2013' })
@@ -21,3 +41,7 @@ t.create('last commit (by committer)')
 t.create('last commit (repo not found)')
   .get('/badges/helmets.json')
   .expectBadge({ label: 'last commit', message: 'repo not found' })
+
+t.create('last commit (no commits found)')
+  .get('/badges/badgr.co/shielded.json?path=not/a/dir')
+  .expectBadge({ label: 'last commit', message: 'no commits found' })
diff --git a/services/gitlab/gitlab-last-commit.service.js b/services/gitlab/gitlab-last-commit.service.js
index c99888305662c3791144c56f747cf531f89f6af8..af17127296ea3ae7df87518f9cfa423a510218df 100644
--- a/services/gitlab/gitlab-last-commit.service.js
+++ b/services/gitlab/gitlab-last-commit.service.js
@@ -1,23 +1,23 @@
 import Joi from 'joi'
-import { pathParam, queryParam } from '../index.js'
-import { optionalUrl } from '../validators.js'
-import { formatDate } from '../text-formatters.js'
 import { age as ageColor } from '../color-formatters.js'
-import { description, httpErrorsFor } from './gitlab-helper.js'
+import { NotFound, pathParam, queryParam } from '../index.js'
+import { formatDate } from '../text-formatters.js'
+import { optionalUrl, relativeUri } from '../validators.js'
 import GitLabBase from './gitlab-base.js'
+import { description, httpErrorsFor } from './gitlab-helper.js'
 
 const schema = Joi.array()
   .items(
     Joi.object({
       committed_date: Joi.string().required(),
-    }).required(),
+    }),
   )
   .required()
-  .min(1)
 
 const queryParamSchema = Joi.object({
   ref: Joi.string(),
   gitlab_url: optionalUrl,
+  path: relativeUri,
 }).required()
 
 const refText = `
@@ -53,6 +53,12 @@ export default class GitlabLastCommit extends GitLabBase {
             name: 'ref',
             example: 'master',
           }),
+          queryParam({
+            name: 'path',
+            example: 'README.md',
+            schema: { type: 'string' },
+            description: 'File path to resolve the last commit for.',
+          }),
         ],
       },
     },
@@ -67,13 +73,13 @@ export default class GitlabLastCommit extends GitLabBase {
     }
   }
 
-  async fetch({ project, baseUrl, ref }) {
+  async fetch({ project, baseUrl, ref, path }) {
     // https://docs.gitlab.com/ee/api/commits.html#list-repository-commits
     return super.fetch({
       url: `${baseUrl}/api/v4/projects/${encodeURIComponent(
         project,
       )}/repository/commits`,
-      options: { searchParams: { ref_name: ref } },
+      options: { searchParams: { ref_name: ref, path } },
       schema,
       httpErrors: httpErrorsFor('project not found'),
     })
@@ -81,9 +87,13 @@ export default class GitlabLastCommit extends GitLabBase {
 
   async handle(
     { project },
-    { gitlab_url: baseUrl = 'https://gitlab.com', ref },
+    { gitlab_url: baseUrl = 'https://gitlab.com', ref, path },
   ) {
-    const data = await this.fetch({ project, baseUrl, ref })
-    return this.constructor.render({ commitDate: data[0].committed_date })
+    const data = await this.fetch({ project, baseUrl, ref, path })
+    const [commit] = data
+
+    if (!commit) throw new NotFound({ prettyMessage: 'no commits found' })
+
+    return this.constructor.render({ commitDate: commit.committed_date })
   }
 }
diff --git a/services/gitlab/gitlab-last-commit.tester.js b/services/gitlab/gitlab-last-commit.tester.js
index ecc29fdbd720892491171146c8c24a5bb16bb490..08ab428468737f3677f042ebb9533f3d7dd12dab 100644
--- a/services/gitlab/gitlab-last-commit.tester.js
+++ b/services/gitlab/gitlab-last-commit.tester.js
@@ -8,13 +8,43 @@ t.create('last commit (recent)').get('/gitlab-org/gitlab.json').expectBadge({
   message: isFormattedDate,
 })
 
-t.create('last commit (on ref and ancient)')
+t.create('last commit (on ref) (ancient)')
   .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee')
   .expectBadge({
     label: 'last commit',
     message: 'march 2021',
   })
 
+t.create('last commit (on ref) (ancient) (by top-level file path)')
+  .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee&path=README.md')
+  .expectBadge({
+    label: 'last commit',
+    message: 'december 2020',
+  })
+
+t.create('last commit (on ref) (ancient) (by top-level dir path)')
+  .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee&path=changelogs')
+  .expectBadge({
+    label: 'last commit',
+    message: 'march 2021',
+  })
+
+t.create(
+  'last commit (on ref) (ancient) (by top-level dir path with trailing slash)',
+)
+  .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee&path=changelogs/')
+  .expectBadge({
+    label: 'last commit',
+    message: 'march 2021',
+  })
+
+t.create('last commit (on ref) (ancient) (by nested file path)')
+  .get('/gitlab-org/gitlab.json?ref=v13.8.6-ee&path=changelogs/README.md')
+  .expectBadge({
+    label: 'last commit',
+    message: 'september 2020',
+  })
+
 t.create('last commit (self-managed)')
   .get('/gitlab-cn/gitlab.json?gitlab_url=https://jihulab.com')
   .expectBadge({
@@ -28,3 +58,10 @@ t.create('last commit (project not found)')
     label: 'last commit',
     message: 'project not found',
   })
+
+t.create('last commit (no commits found)')
+  .get('/gitlab-org/gitlab.json?path=not/a/dir')
+  .expectBadge({
+    label: 'last commit',
+    message: 'no commits found',
+  })
diff --git a/services/validators.js b/services/validators.js
index df8d72a4d46969e6883c30f6627d8f75a8a1450d..b4455278668b42136e915f5fad255fa6a5fb4462 100644
--- a/services/validators.js
+++ b/services/validators.js
@@ -79,3 +79,10 @@ export const optionalUrl = Joi.string().uri({ scheme: ['http', 'https'] })
 export const fileSize = Joi.string()
   .regex(/^[0-9]+(b|kb|mb|gb|tb)$/i)
   .required()
+
+/**
+ * Joi validator that checks if a value is a relative-only URI
+ *
+ * @type {Joi}
+ */
+export const relativeUri = Joi.string().uri({ relativeOnly: true })