Skip to main content

Migrating from GPG to SSH-Signed Git Commits (Forgejo, Codeberg, GitHub)

· 13 min read
Mark Burton
Software Engineer & Technical Writer

If you are still using GPG for commit signing purely out of habit, this is your nudge to reconsider.

In 2026, SSH commit signing is broadly supported by Git and by the platforms I use most:

  • Forgejo
  • Codeberg
  • GitHub

For my workflow, SSH signing is now the cleaner default.

Why Migrate from GPG to SSH Signing?​

Historically, my setup mixed repository authentication via HTTPS credentials (typically cached through Git Credential Manager) with GPG commit signing.

HTTPS credentials (often cached by Git Credential Manager)
-> Repository authentication

GPG key
-> Commit signing

It worked, but it added avoidable operational complexity.

I now prefer:

Repository authentication
-> SSH or HTTPS (either is fine)

SSH key
-> Commit signing

That removes GPG from my signing workflow, reduces moving parts, and preserves verified commits across multiple forges.

Current Architecture​

Laptop
|
| SSH + SSH Signing
v
Forgejo (Source of Truth)
|
+--> Woodpecker CI
|
+--> Codeberg Mirror
|
+--> GitHub Mirror

Principles:

  • Forgejo is authoritative.
  • Woodpecker is the only CI system.
  • Codeberg and GitHub are mirrors/publication targets.
  • Commit trust comes from the signer identity, not the hosting platform.

The Key Insight​

Commit signatures are part of the Git commit object and are created locally before push.

Signed locally
|
v
Forgejo
|
+--> Codeberg
|
+--> GitHub

So mirrors contain the same:

  • Commit hash
  • Commit metadata
  • Signature payload

Each platform verifies that signature independently.

Keep Human and Automation Identities Separate​

Do not reuse automation keys for commit signing.

Use a personal key for your commits:

Human SSH key
id_ed25519_mark

Used for:
- Git authentication
- Commit signing

Use separate mirror-only keys for automated replication:

Mirror keys
forgejo-github-mirror
forgejo-codeberg-mirror

Used for:
- Repository mirroring only

A signed commit should represent a person, not a server process.

Generate a Signing Key (Optional)​

If you do not already have a suitable SSH key:

ssh-keygen -t ed25519 -C "mark@example.com"

Default output:

~/.ssh/id_ed25519
~/.ssh/id_ed25519.pub

Use a Passphrase and ssh-agent​

Use a passphrase on your SSH private key.

Without a passphrase, anyone who gets a copy of your private key can sign commits as you.

The trade-off is convenience: entering a passphrase for every signing operation is tedious. ssh-agent solves this by caching your unlocked key for your session so you enter the passphrase far less often.

Windows setup (OpenSSH agent)​

On my Windows machine, the service was stopped when I checked it:

Get-Service ssh-agent

That shows:

Status   Name       DisplayName
------ ---- -----------
Stopped ssh-agent OpenSSH Authentication Agent

If you see Stopped, start it and make sure it is configured to start automatically. This change must be done in an elevated admin PowerShell window:

Set-Service -Name ssh-agent -StartupType Automatic
Start-Service ssh-agent

If you run the first command in a normal user terminal, Windows will return an access-denied error because the service configuration is system-level.

Load your key:

ssh-add $env:USERPROFILE\.ssh\id_ed25519

You should be prompted for the passphrase once.

Verify loaded keys:

ssh-add -l

WSL setup (Ubuntu / Linux shell)​

If you sign commits from WSL as well, you need a separate ssh-agent session inside that Linux environment.

Check whether your agent is running. A successful startup looks like this:

eval "$(ssh-agent -s)"

Example output:

Agent pid 910357

That Agent pid ... line is the sign that the agent started successfully. If you do not see that, the agent is not running in that shell session.

If you have not generated a key yet:

ssh-keygen -t ed25519 -C "mark@example.com"

Then load the key:

ssh-add ~/.ssh/id_ed25519

If you are using a shared key pair from Windows, point WSL at the Windows file instead:

ssh-add /mnt/c/Users/<you>/.ssh/id_ed25519

The important part is that Windows and WSL are separate sessions. A stopped Windows agent does not mean the WSL agent is also stopped, and vice versa.

If you see an error like this when trying to load a key:

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: UNPROTECTED PRIVATE KEY FILE! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Permissions for '\\wsl.localhost\Ubuntu\home\mark\.ssh\id_ed25519' are too open.
It is required that your private key files are NOT accessible by others.
This private key will be ignored.

then the private key file permissions are too permissive. This is a permissions problem, not a Git trust problem. In the recommended Windows-first setup, the canonical key lives in Windows and WSL reads it via the mounted path /mnt/c/... rather than a UNC path like \\wsl.localhost\....

If you are intentionally using a key that lives inside WSL, fix the permissions there:

chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
ssh-add ~/.ssh/id_ed25519

If you are using the shared Windows key, use the mounted path instead:

ssh-add /mnt/c/Users/<you>/.ssh/id_ed25519

In other words: for a key stored in WSL, use the Linux path. For a key stored in Windows, use /mnt/c/... from WSL. The recommended setup is to keep the private key, public key, and allowed_signers file in Windows and avoid the UNC path in the normal workflow.

Why this matters for AI-assisted workflows​

For interactive tools (for example GitHub Copilot and similar assistants), this model works well:

  • Your key stays passphrase-protected at rest.
  • Signing can still work smoothly while you are present and the agent has the key loaded.
  • The assistant is operating within your authenticated session, not as an independent long-lived identity.

When you move to fully automated agents (unattended jobs), use a separate identity and dedicated key. Do not reuse your personal signing key for automation.

Windows and WSL Notes​

Yes, ssh-keygen exists on modern Windows through the built-in OpenSSH client.

  • In PowerShell, ssh-keygen usually works out of the box.
  • In WSL, ssh-keygen is available from your Linux distribution's OpenSSH package.
  • On Windows, if Get-Service ssh-agent shows Stopped, you need to start the Windows OpenSSH agent before signing works in PowerShell.
  • In WSL, you need a separate ssh-agent process running inside the Linux distro even if the Windows agent is healthy.

If you work in both Windows and WSL, choose one of these models:

  1. One shared key pair in your Windows profile.
  2. Separate key pairs for Windows and WSL.

Both are valid. The key point is consistency in each environment.

My recommendation for a Windows-first workflow is clear: run ssh-keygen on Windows, keep the private key in your Windows profile, keep the public key in the same Windows .ssh directory, and keep allowed_signers in Windows too. Then, when WSL needs to use the same key, point it at the mounted Windows path with /mnt/c/... rather than a \\wsl.localhost\... UNC path.

There is no hard requirement that WSL must read the Windows .ssh directory. The important thing is that the same private key is loaded into the active agent for the environment you are using. But for a Windows-centric setup, the simplest and most reliable approach is to keep everything canonical in Windows.

For one shared key pair, keep the private key in Windows and reference it from WSL via /mnt/c/....

For separate key pairs, add both public keys as signing keys on Forgejo, GitHub and Codeberg, then configure each environment to use its own key.

A private key copy is not inherently wrong, but it does create a second copy of a secret. In day-to-day use I prefer one canonical private key and either a mounted Windows path from WSL or a dedicated WSL key, rather than duplicating the .ssh/id_ed25519 file to both operating systems. The public key can be copied freely; the private key should not be treated as disposable.

Configure Git for SSH Commit Signing​

Enable SSH signature format globally:

git config --global gpg.format ssh

Set your signing key to the public key path for the environment you are actually using:

# Windows
git config --global user.signingkey "C:/Users/<you>/.ssh/id_ed25519.pub"

# WSL using the same Windows key
git config --global user.signingkey /mnt/c/Users/<you>/.ssh/id_ed25519.pub

# WSL using a native Linux key
git config --global user.signingkey ~/.ssh/id_ed25519.pub

Recommended Windows-first path layout:

  • Private key: C:/Users/<you>/.ssh/id_ed25519
  • Public key: C:/Users/<you>/.ssh/id_ed25519.pub
  • Allowed signers: C:/Users/<you>/.ssh/allowed_signers
  • Git config in Windows: git config --global user.signingkey "C:/Users/<you>/.ssh/id_ed25519.pub"
  • Git config in WSL when using the same Windows key: git config --global user.signingkey /mnt/c/Users/<you>/.ssh/id_ed25519.pub

Other valid paths:

  • WSL Git (own WSL key): /home/<you>/.ssh/id_ed25519.pub
  • WSL Git (shared Windows key): /mnt/c/Users/<you>/.ssh/id_ed25519.pub
  • Do not use the Windows UNC-style path for a WSL key: \\wsl.localhost\Ubuntu\home\mark\.ssh\id_ed25519.pub

Always sign commits:

git config --global commit.gpgsign true

Optional: sign annotated tags too:

git config --global tag.gpgsign true

Verify configuration:

git config --global --list

Expected entries include:

gpg.format=ssh
commit.gpgsign=true
user.signingkey=...

One important detail is missing from many SSH-signing guides: Git signature verification also needs an allowed signers file. Without it, a commit can be created successfully and still show as unsigned when you run:

git log --show-signature -1

This is the error you will see if the verification trust file is missing:

error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification
No signature

The fix is to create a file such as ~/.ssh/allowed_signers and point Git at it. The important part is not the SSH key comment; it is the principal Git expects from the commit identity. In this repo, the relevant identity is the output of:

git log -1 --format='%ae %an'

which produces:

mark.burton@zither-it.co.uk MarkZither

So the allowed_signers entry should be built from the Git identity and the matching public key, not from the SSH key comment such as markzither-signing.

The correct format is:

principal keytype public-key-data

For example:

mark.burton@zither-it.co.uk ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIYourPublicKeyHere

Do not write this as a plain email-and-name pair such as:

mark.burton@zither-it.co.uk MarkZither

because the file needs the public key material as well. The name is not a second field in the key entry; it is either a separate principal on its own line, or it is not used for verification at all. If you want to allow both the email and the name as principals, add two lines using the same key blob.

cat ~/.ssh/id_ed25519.pub

Then configure Git:

git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers

Recommended Windows-first configuration:

git config --global gpg.ssh.allowedSignersFile "C:/Users/<you>/.ssh/allowed_signers"

If you are using the same Windows key from WSL, the mounted path is:

git config --global gpg.ssh.allowedSignersFile /mnt/c/Users/<you>/.ssh/allowed_signers

This is what tells Git which SSH public keys are trusted for signature verification. The key comment is metadata; the trust check is against the Git principal, not the comment.

Test the Signature Locally​

Create a test commit:

git commit -m "Test SSH signing"

Inspect it:

git log --show-signature -1

Git should report a valid SSH signature.

If you use both Windows and WSL, run this test in both environments to confirm each one is signing with the key you expect.

Register the Public Key as a Signing Key​

Forgejo​

In Forgejo, add your public key as a signing key for your user account:

  1. Open your profile settings.
  2. Go to the SSH/GPG keys area.
  3. Add a new SSH key using the contents of id_ed25519.pub.
  4. Ensure the key is registered for signing, not only for repository access.

Quick copy command:

cat ~/.ssh/id_ed25519.pub

Then paste that full line into Forgejo and save.

Official Forgejo docs (instance signing and verification behaviour):

Forgejo signing verification settings​

Forgejo has a signature trust model that affects how "Verified" signatures are labelled (for example trusted, untrusted, or unmatched).

For most personal/self-hosted setups, a good default is:

  • Mark signatures as trusted only when the signature matches the committer identity.

Why this is usually best:

  • It is stricter and easier to reason about.
  • It avoids overly broad trust based only on collaborator status.
  • It keeps the trust signal tied to the person shown as committer.

Scope and where to configure it:

  • Yes, there is an installation/server-level default trust model.
  • Repositories inherit that default unless you override it at repo level.
  • You do not need to configure every repository individually unless a specific repository needs different policy.

Practical recommendation:

  • Set the preferred model once at server level.
  • Keep repos on inherited default.
  • Only override per-repo for special cases (for example unusual automation workflows).

Because you set committer, here is the server-level config reference:

In Docker Compose, this is typically set with:

FORGEJO__REPOSITORY_SIGNING__DEFAULT_TRUST_MODEL=committer

GitHub​

Go to Settings -> SSH and GPG keys and add a New SSH signing key.

Codeberg​

Add the same public key as a signing key.

Result for the same commit:

Forgejo   -> Verified
GitHub -> Verified
Codeberg -> Verified

Repository Access Strategy​

For personal projects, both approaches are valid:

  • SSH remotes
  • HTTPS remotes with Git Credential Manager

Example SSH remote:

git@forgejo.example.com:mark/project.git

If you use SSH remotes, advantages include:

  • No Personal Access Tokens.
  • No credential manager dependency.
  • One identity model.
  • Natural fit for self-hosted infrastructure.

If you stay on HTTPS remotes, that is still completely compatible with SSH commit signing.

Azure DevOps Note​

You can keep work repositories on HTTPS with Git Credential Manager and still use SSH commit signing.

This is valid:

HTTPS repository access
+
SSH commit signing

So you can modernise signing independently of remote transport.

VS Code and Visual Studio Edge Cases​

Things are simpler now, but one confusion point still appears in mixed tooling setups.

Visual Studio can still ship with its own Git binary, while VS Code often uses whichever Git is first on your PATH.

That can create "why does signing work here but not there?" moments when:

  • Different Git binaries are used.
  • One environment has signing config and another does not.
  • Windows Git and WSL Git are each reading different global config files.

Practical checks​

Run these in each place you commit from:

  • PowerShell terminal
  • VS Code integrated terminal
  • WSL terminal

Commands:

git --version
git config --show-origin --get gpg.format
git config --show-origin --get user.signingkey
git config --show-origin --get commit.gpgsign
git log --show-signature -1

Also confirm your remote style in each repo:

git remote -v

On Windows, you can list all Git executables seen by PATH with:

where.exe git

If these outputs are consistent across environments, commit signing behaviour is usually consistent too.

Prefer:

Forgejo
|
+--> GitHub
|
+--> Codeberg

Avoid chained mirroring like:

Forgejo
|
+--> Codeberg
|
+--> GitHub

Direct fan-out keeps source-of-truth clear and failure domains smaller.

Recommendation​

If you are maintaining personal repositories across Forgejo, Codeberg and GitHub, I recommend moving to SSH commit signing now.

It preserves strong commit provenance while reducing operational overhead compared with a mixed SSH + GPG setup.

Follow-up​

I wrote a follow-up focused on unsigned commits, branch enforcement, and recovery workflows across multiple machines: