Migrating from GPG to SSH-Signed Git Commits (Forgejo, Codeberg, GitHub)
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-keygenusually works out of the box. - In WSL,
ssh-keygenis available from your Linux distribution's OpenSSH package. - On Windows, if
Get-Service ssh-agentshowsStopped, you need to start the Windows OpenSSH agent before signing works in PowerShell. - In WSL, you need a separate
ssh-agentprocess 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:
- One shared key pair in your Windows profile.
- 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:
- Open your profile settings.
- Go to the SSH/GPG keys area.
- Add a new SSH key using the contents of
id_ed25519.pub. - 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.
Recommended Forgejo Mirroring Topology
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:
