JDM
← All posts
4 min read

GitHub Deploy Keys Are Unique by Public Key, Not by Repo

I tried to reuse an SSH deploy key across two repos. GitHub returned a 422. The key is globally unique per user account, not per repo.

githubdevopssolo-foundergotcha

I was setting up a fresh Coolify app for jdmcasanova/resume-website. The site is a simple static page, but it pulls from a private GitHub repo. I already had a deploy key on Coolify called 'draftedby-github-deploy' (UUID cc4ko8s08gg8g88wsog8g0og). It was already working for one of my other repos. One less key to manage, one less thing to track. I figured I would just reuse it.

I called the GitHub API to add that same public key to the new repo. POST to /repos/jdmcasanova/resume-website/keys with the public key value. The response came back as HTTP 422 with a JSON body: { "code": "custom", "field": "key", "message": "key is already in use" }. No further explanation. No hint that the key was globally unique. Just a generic validation error.

The lesson: GitHub enforces uniqueness of deploy keys per public-key value across all repos under one user account. The same SSH public key cannot be a deploy key on two repos. It does not matter if the key is attached to a different repo, a different title, or a different purpose. The public key itself is the constraint. This is not documented clearly in the GitHub API docs or the Coolify docs. It is a silent gotcha that wastes time if you assume deploy keys are per-repo.

The workaround is straightforward. Generate a fresh ed25519 keypair per repo. I ran this on my local machine:

ssh-keygen -t ed25519 -N '' -C 'coolify-jdmcasanova-deploy' -f /tmp/jdmcasanova-deploy

This creates two files: /tmp/jdmcasanova-deploy (private key) and /tmp/jdmcasanova-deploy.pub (public key). I then uploaded the private key to Coolify via their API:

POST /api/v1/security/keys

With the private key content in the body. Then I added the public key to the GitHub repo using the gh CLI:

gh api -X POST repos/jdmcasanova/resume-website/keys \
  -f title='Coolify jdmcasanova-deploy' \
  -f key=$PUB \
  -F read_only=true

The whole process took three minutes once I knew the constraint. The time sink was the first time, when I spent 20 minutes debugging the 422 and wondering if my API request was malformed or if the key was already attached somewhere else I had forgotten.

Why does this matter? Most docs from Coolify, Vercel, or any CI tool assume you know this. They show you how to generate a key and paste it into the GitHub UI. They never mention that the key must be unique per repo. In the GitHub UI, you attach a deploy key to a specific repo, which makes it feel like the key is scoped to that repo. It is not. The attachment is per repo, but the key itself is globally unique for your user account. If you have six sites like I do, and you try to keep things tidy by reusing one deploy key, GitHub will refuse the second site with that misleading 422.

There are alternatives. You could use a GitHub App with installation permissions. That gives you per-repo access with a single key managed by the app. But for a solo founder managing six small sites, that is overkill. You have to register the app, install it on each repo, and manage the app's private key separately. Another option is a machine user, a separate GitHub account that owns a single deploy key. That is also overkill for a solo operation. A third option is a personal access token (PAT) with repo scope. That works, but it is less secure because the PAT grants broader access than a deploy key. A PAT can read and write to all repos the token has scope for. A deploy key is read-only by default and scoped to one repo. The deploy key is the right shape for this problem. You just need to generate a fresh one per repo.

If you are in the middle of a migration, like I was when moving from Vercel to Coolify, this constraint can trip you up. I wrote about my Vercel-to-Coolify migration in more detail. The deploy key gotcha was one of several small friction points that added up.

For now, the practical takeaway is simple: when you wire a new repo to a CI system, generate a new SSH keypair. Do not try to reuse one. It takes three minutes, and it avoids a 422 that the docs do not prepare you for. The constraint is not a bug. It is a security design choice that makes each deploy key an independent credential. If one repo is compromised, the other repos are not exposed. That is a good thing. The problem is that the error message and the documentation do not explain the constraint clearly. Once you know it, you work around it easily.