diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md
index 579c78d6f1f09d32ac5828d520f74d5132461ca2..e5a3ddd47c71bbc4ddfabfb48d9cc134a38d481f 100644
--- a/docs/usage/self-hosted-configuration.md
+++ b/docs/usage/self-hosted-configuration.md
@@ -655,7 +655,9 @@ To learn more about Git hooks, read the [Pro Git 2 book, section on Git Hooks](h
 
 ## gitPrivateKey
 
-This should be an armored private key, so the type you get from running `gpg --export-secret-keys --armor 92066A17F0D1707B4E96863955FEF5171C45FAE5 > private.key`.
+This is a private PGP or SSH key for signing Git commits.
+
+For PGP, it should be an armored private key, so the type you get from running `gpg --export-secret-keys --armor 92066A17F0D1707B4E96863955FEF5171C45FAE5 > private.key`.
 Replace the newlines with `\n` before adding the resulting single-line value to your bot's config.
 
 <!-- prettier-ignore -->
@@ -665,8 +667,8 @@ Replace the newlines with `\n` before adding the resulting single-line value to
 It will be loaded _lazily_.
 Before the first commit in a repository, Renovate will:
 
-1. Run `gpg import` (if you haven't before)
-1. Run `git config user.signingkey` and `git config commit.gpgsign true`
+1. Run `gpg import` (if you haven't before) when using PGP
+1. Run `git config user.signingkey`, `git config commit.gpgsign true` and `git config gpg.format`
 
 The `git` commands are run locally in the cloned repo instead of globally.
 This reduces the chance of unintended consequences with global Git configs on shared systems.
diff --git a/lib/util/git/private-key.spec.ts b/lib/util/git/private-key.spec.ts
index f13c5d40a7862f20e49862a397c2e33907a187bf..e76ab6b84ef624741ee1fa7d7fc1e4428a4d5f76 100644
--- a/lib/util/git/private-key.spec.ts
+++ b/lib/util/git/private-key.spec.ts
@@ -1,6 +1,8 @@
 import os from 'node:os';
+import fs from 'fs-extra';
 import { any, mockDeep } from 'jest-mock-extended';
 import upath from 'upath';
+import { Fixtures } from '../../../test/fixtures';
 import { mockedExtended } from '../../../test/util';
 import * as exec_ from '../exec';
 import { configSigningKey, writePrivateKey } from './private-key';
@@ -19,6 +21,10 @@ const exec = mockedExtended(exec_);
 
 describe('util/git/private-key', () => {
   describe('writePrivateKey()', () => {
+    beforeEach(() => {
+      Fixtures.reset();
+    });
+
     it('returns if no private key', async () => {
       await expect(writePrivateKey()).resolves.not.toThrow();
       await expect(configSigningKey('/tmp/some-repo')).resolves.not.toThrow();
@@ -38,7 +44,7 @@ describe('util/git/private-key', () => {
       await expect(writePrivateKey()).rejects.toThrow();
     });
 
-    it('imports the private key', async () => {
+    it('imports the private GPG key', async () => {
       const publicKey = 'BADC0FFEE';
       const repoDir = '/tmp/some-repo';
       exec.exec.calledWith(any()).mockResolvedValue({ stdout: '', stderr: '' });
@@ -60,11 +66,72 @@ describe('util/git/private-key', () => {
       expect(exec.exec).toHaveBeenCalledWith('git config commit.gpgsign true', {
         cwd: repoDir,
       });
+      expect(exec.exec).toHaveBeenCalledWith('git config gpg.format opengpg', {
+        cwd: repoDir,
+      });
     });
 
     it('does not import the key again', async () => {
       await expect(writePrivateKey()).resolves.not.toThrow();
       await expect(configSigningKey('/tmp/some-repo')).resolves.not.toThrow();
     });
+
+    it('throws error if the private SSH key has a passphrase', async () => {
+      const privateKeyFile = upath.join(os.tmpdir() + '/git-private-ssh.key');
+      exec.exec.calledWith(any()).mockResolvedValue({ stdout: '', stderr: '' });
+      exec.exec
+        .calledWith(`ssh-keygen -y -P "" -f ${privateKeyFile}`)
+        .mockRejectedValueOnce({
+          stderr: `Load key "${privateKeyFile}": incorrect passphrase supplied to decrypt private key`,
+          stdout: '',
+        });
+      setPrivateKey(`\
+-----BEGIN OPENSSH PRIVATE KEY-----
+some-private-key with-passphrase
+some-private-key with-passphrase
+-----END OPENSSH PRIVATE KEY-----`);
+      await expect(writePrivateKey()).rejects.toThrow();
+    });
+
+    it('imports the private SSH key', async () => {
+      const privateKey = `\
+-----BEGIN OPENSSH PRIVATE KEY-----
+some-private-key
+some-private-key
+-----END OPENSSH PRIVATE KEY-----`;
+      const privateKeyFile = upath.join(os.tmpdir() + '/git-private-ssh.key');
+      const publicKeyFile = `${privateKeyFile}.pub`;
+      const publicKey = 'some-public-key';
+      const repoDir = '/tmp/some-repo';
+      exec.exec.calledWith(any()).mockResolvedValue({ stdout: '', stderr: '' });
+      exec.exec
+        .calledWith(`ssh-keygen -y -P "" -f ${privateKeyFile}`)
+        .mockResolvedValue({
+          stderr: '',
+          stdout: publicKey,
+        });
+      setPrivateKey(privateKey);
+      await expect(writePrivateKey()).resolves.not.toThrow();
+      await expect(configSigningKey(repoDir)).resolves.not.toThrow();
+      expect(exec.exec).toHaveBeenCalledWith(
+        `git config user.signingkey ${privateKeyFile}`,
+        { cwd: repoDir },
+      );
+      const privateKeyFileMode = (await fs.stat(privateKeyFile)).mode;
+      expect((privateKeyFileMode & 0o777).toString(8)).toBe('600');
+      expect((await fs.readFile(privateKeyFile)).toString()).toEqual(
+        privateKey,
+      );
+      expect((await fs.readFile(publicKeyFile)).toString()).toEqual(publicKey);
+      expect(exec.exec).toHaveBeenCalledWith('git config commit.gpgsign true', {
+        cwd: repoDir,
+      });
+      expect(exec.exec).toHaveBeenCalledWith('git config gpg.format ssh', {
+        cwd: repoDir,
+      });
+      process.emit('exit', 0);
+      expect(fs.existsSync(privateKeyFile)).toBeFalse();
+      expect(fs.existsSync(publicKeyFile)).toBeFalse();
+    });
   });
 });
diff --git a/lib/util/git/private-key.ts b/lib/util/git/private-key.ts
index dac6bb275d0801378e31e22cd8fe3b8b303d20f7..d7d37ced9ef3bdb61149335a00283a4c417f74e0 100644
--- a/lib/util/git/private-key.ts
+++ b/lib/util/git/private-key.ts
@@ -5,14 +5,23 @@ import upath from 'upath';
 import { PLATFORM_GPG_FAILED } from '../../constants/error-messages';
 import { logger } from '../../logger';
 import { exec } from '../exec';
-import { newlineRegex } from '../regex';
+import type { ExecResult } from '../exec/types';
+import { newlineRegex, regEx } from '../regex';
 import { addSecretForSanitizing } from '../sanitize';
 
+type PrivateKeyFormat = 'gpg' | 'ssh';
+
+const sshKeyRegex = regEx(
+  /-----BEGIN ([A-Z ]+ )?PRIVATE KEY-----.*?-----END ([A-Z]+ )?PRIVATE KEY-----/,
+  's',
+);
+
 let gitPrivateKey: PrivateKey | undefined;
 
 abstract class PrivateKey {
   protected readonly key: string;
   protected keyId: string | undefined;
+  protected abstract readonly gpgFormat: string;
 
   constructor(key: string) {
     this.key = key.trim();
@@ -39,12 +48,15 @@ abstract class PrivateKey {
     // TODO: types (#22198)
     await exec(`git config user.signingkey ${this.keyId!}`, { cwd });
     await exec(`git config commit.gpgsign true`, { cwd });
+    await exec(`git config gpg.format ${this.gpgFormat}`, { cwd });
   }
 
   protected abstract importKey(): Promise<string | undefined>;
 }
 
 class GPGKey extends PrivateKey {
+  protected readonly gpgFormat = 'opengpg';
+
   protected async importKey(): Promise<string | undefined> {
     const keyFileName = upath.join(os.tmpdir() + '/git-private-gpg.key');
     await fs.outputFile(keyFileName, this.key);
@@ -60,11 +72,63 @@ class GPGKey extends PrivateKey {
   }
 }
 
+class SSHKey extends PrivateKey {
+  protected readonly gpgFormat = 'ssh';
+
+  protected async importKey(): Promise<string | undefined> {
+    const keyFileName = upath.join(os.tmpdir() + '/git-private-ssh.key');
+    if (await this.hasPassphrase(keyFileName)) {
+      throw new Error('SSH key must have an empty passhprase');
+    }
+    await fs.outputFile(keyFileName, this.key);
+    process.on('exit', () => fs.removeSync(keyFileName));
+    await fs.chmod(keyFileName, 0o600);
+    // HACK: `git` calls `ssh-keygen -Y sign ...` internally for SSH-based
+    // commit signing. Technically, only the private key is needed for signing,
+    // but `ssh-keygen` has an implementation quirk which requires also the
+    // public key file to exist. Therefore, we derive the public key from the
+    // private key just to satisfy `ssh-keygen` until the problem has been
+    // resolved.
+    // https://github.com/renovatebot/renovate/issues/18197#issuecomment-2152333710
+    const { stdout } = await exec(`ssh-keygen -y -P "" -f ${keyFileName}`);
+    const pubFileName = `${keyFileName}.pub`;
+    await fs.outputFile(pubFileName, stdout);
+    process.on('exit', () => fs.removeSync(pubFileName));
+    return keyFileName;
+  }
+
+  private async hasPassphrase(keyFileName: string): Promise<boolean> {
+    try {
+      await exec(`ssh-keygen -y -P "" -f ${keyFileName}`);
+    } catch (err) {
+      return (err as ExecResult).stderr.includes(
+        'incorrect passphrase supplied to decrypt private key',
+      );
+    }
+    return false;
+  }
+}
+
+function getPrivateKeyFormat(key: string): PrivateKeyFormat {
+  return sshKeyRegex.test(key) ? 'ssh' : 'gpg';
+}
+
+function createPrivateKey(key: string): PrivateKey {
+  switch (getPrivateKeyFormat(key)) {
+    case 'gpg':
+      logger.debug('gitPrivateKey: GPG key detected');
+      return new GPGKey(key);
+    case 'ssh':
+      logger.debug('gitPrivateKey: SSH key detected');
+      return new SSHKey(key);
+  }
+}
+
 export function setPrivateKey(key: string | undefined): void {
   if (!is.nonEmptyStringAndNotWhitespace(key)) {
     return;
   }
-  gitPrivateKey = new GPGKey(key);
+  gitPrivateKey = createPrivateKey(key);
 }
 
 export async function writePrivateKey(): Promise<void> {
diff --git a/test/fixtures.ts b/test/fixtures.ts
index a65cf54fd75ef0d3cdd74607dc10309960c68d14..2c498ac07d8ddee6e509f57df3b71c8326640194 100644
--- a/test/fixtures.ts
+++ b/test/fixtures.ts
@@ -97,10 +97,12 @@ export class Fixtures {
     vol.reset();
     fsExtraMock.pathExists.mockImplementation(pathExists);
     fsExtraMock.remove.mockImplementation(memfs.promises.rm);
+    fsExtraMock.removeSync.mockImplementation(memfs.rmSync);
     fsExtraMock.readFile.mockImplementation(readFile);
     fsExtraMock.writeFile.mockImplementation(memfs.promises.writeFile);
     fsExtraMock.outputFile.mockImplementation(outputFile);
     fsExtraMock.stat.mockImplementation(stat);
+    fsExtraMock.chmod.mockImplementation(memfs.promises.chmod);
   }
 
   private static getPathToFixtures(fixturesRoot = '.'): string {
@@ -113,10 +115,12 @@ export class Fixtures {
 const fsExtraMock = {
   pathExists: jest.fn(),
   remove: jest.fn(),
+  removeSync: jest.fn(),
   readFile: jest.fn(),
   writeFile: jest.fn(),
   outputFile: jest.fn(),
   stat: jest.fn(),
+  chmod: jest.fn(),
 };
 
 // Temporary solution, when all tests will be rewritten to Fixtures mocks can be moved into __mocks__ folder