Org-wide Keypair in CI
Run one CI decryption keypair across every repository in a GitHub organization. The private key lives in a single organization secret, ORG_GPG_PRIVATE_KEY, scoped to the repos that need it. Each repo’s workflow imports the same key, logs in by its fingerprint, and decrypts that repo’s committed vault.
This is the org-wide alternative to the repo-scoped pattern in CI/CD Secrets, where every repository carries its own GPG_PRIVATE_KEY repository secret. Read Key Scope to decide which model fits before you commit to one.
Prerequisites
Section titled “Prerequisites”- Getting Started completed
- Owner or admin access to the GitHub organization (organization secrets need it)
- A developer machine with
gpganddotsecenvinstalled
-
Generate one CI key for the whole org
Create a single no-passphrase identity. GitHub Actions cannot answer a pinentry prompt, so the CI key carries no passphrase.
Terminal window dotsecenv identity create \--algo RSA4096 \--name "CI (acme-org)" \--email "ci@acme.example" \--no-passphraseCapture its fingerprint. Every repo’s workflow logs in with this exact value.
Terminal window ORG_FP=$(gpg --list-secret-keys --with-colons "ci@acme.example" \| awk -F: '/^fpr:/ { print $10; exit }')echo "$ORG_FP" -
Export the private key ASCII-armored
Terminal window gpg --armor --export-secret-keys "$ORG_FP" > /tmp/org-ci.ascThe output is the
-----BEGIN PGP PRIVATE KEY BLOCK-----text imported as-is in CI. Do not base64-encode it. -
Store it as an organization secret
Scope the secret to selected repositories so it never reaches repos that don’t need it.
Terminal window gh secret set ORG_GPG_PRIVATE_KEY \--org acme-org \--visibility selected \--repos app-api,app-web,jobs-runner \< /tmp/org-ci.ascGo to the organization: Settings > Secrets and variables > Actions > New organization secret.
- Name:
ORG_GPG_PRIVATE_KEY - Value: paste the contents of
/tmp/org-ci.asc - Repository access: Selected repositories, then pick the repos that decrypt the vault
Wipe the export from disk once it is stored:
Terminal window shred -u /tmp/org-ci.asc - Name:
-
Share each repo’s secrets to the org CI fingerprint
The org key only decrypts what it is a recipient of. In every repo whose vault CI must read, grant the org fingerprint from a developer machine that already holds the secrets:
Terminal window dotsecenv secret share DATABASE_URL "$ORG_FP"dotsecenv secret share DEPLOY_TOKEN "$ORG_FP"Commit the updated vault:
Terminal window git add .dotsecenv/vaultgit commit -m "ci: grant org CI identity access to deploy secrets"git pushRepeat per repository. The fingerprint is the same everywhere; only the vault contents differ.
-
Reference the same import, login, and decrypt steps in each repo
The workflow is identical to the repo-scoped pattern except the secret name. Save this at
.github/workflows/deploy.ymlin each repo:name: deployon:push:branches: [main]workflow_dispatch: {}permissions:contents: readjobs:deploy:runs-on: ubuntu-latestenv:GNUPGHOME: ${{ runner.temp }}/gnupgsteps:- name: Checkout repositoryuses: actions/checkout@v4- name: Install dotsecenvuses: dotsecenv/dotsecenv@v0with:version: latestverify-provenance: true- name: Import GPG private keyenv:ORG_GPG_PRIVATE_KEY: ${{ secrets.ORG_GPG_PRIVATE_KEY }}run: |mkdir -p "$GNUPGHOME"chmod 700 "$GNUPGHOME"echo "$ORG_GPG_PRIVATE_KEY" | gpg --batch --importFP=$(gpg --list-secret-keys --with-colons \| awk -F: '/^fpr:/ { print $10; exit }')echo "DOTSECENV_FP=$FP" >> "$GITHUB_ENV"- name: Initialise dotsecenvrun: |dotsecenv init config -v .dotsecenv/vaultdotsecenv login "$DOTSECENV_FP"- name: Decrypt secretsrun: |set -euo pipefailfor SECRET in DATABASE_URL DEPLOY_TOKEN; doVALUE=$(dotsecenv secret get "$SECRET")echo "::add-mask::$VALUE"{echo "$SECRET<<__DOTSECENV_EOF__"echo "$VALUE"echo "__DOTSECENV_EOF__"} >> "$GITHUB_ENV"done- name: Run deployrun: |echo "DATABASE_URL is set: ${DATABASE_URL:+yes}"echo "DEPLOY_TOKEN is set: ${DEPLOY_TOKEN:+yes}"# ./scripts/deploy.shsecrets.ORG_GPG_PRIVATE_KEYresolves to the organization secret in every repo on the access list. No per-repo secret setup is needed.
Expected result
Section titled “Expected result”Each repo’s run logs the install, the import, a login against the org fingerprint, and masked *** for every decrypted value:
Run dotsecenv login <fingerprint>... Login successful! ...DATABASE_URL is set: yesDEPLOY_TOKEN is set: yesAdding a repo to the key is two moves: add it to the secret’s repository-access list, then secret share that repo’s secrets to $ORG_FP and commit.
Org-wide vs repo-scoped
Section titled “Org-wide vs repo-scoped”Org-wide (ORG_GPG_PRIVATE_KEY) | Repo-scoped (GPG_PRIVATE_KEY) | |
|---|---|---|
| Where the key lives | One organization secret | One repository secret per repo |
| Adding a repo | Add to access list, share + commit | Generate key, set repo secret, share + commit |
| Rotation | Rotate one key, re-share in every repo | Rotate per repo independently |
| Blast radius | Every repo on the access list | One repo |
Pick per-repo keys when repos must stay isolated from each other. Pick one org key when a fleet of repos shares the same CI identity and you want a single place to rotate. See Key Scope for the full reasoning.
Per-environment keys
Section titled “Per-environment keys”To gate production behind a separate key, suffix the secret name and use one org secret per environment: ORG_GPG_PRIVATE_KEY_PROD, ORG_GPG_PRIVATE_KEY_STAGING. Reference the matching name in each environment’s job and store the production key behind a GitHub deployment-environment approval gate.
Next steps
Section titled “Next steps”- CI/CD Secrets — the repo-scoped alternative with one key per repository
- Key Scope — choosing between org-wide and repo-scoped keys
- GitHub Action — full reference for the
dotsecenv/dotsecenvaction